Make Shell a category, the bar legible, and the dock a real dock
Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling, Workspaces — the home for everything Quickshell draws. The settings- management cluster moves to System as Sync & Backup, Appearance's Shell tab dissolves, and 24-hour time finally lives on Date & Time, which always owned it. The bar gets what it never had: a way to survive the wallpaper. A second neutral text family (follow theme, or forced light or dark), a one-layer shadow under every glyph, and a gradient scrim for wallpapers nothing else survives — all off by default, pixel-identical until asked. Widgets earn toggles (weather, media, clipboard, calendar countdown), the vitals cluster stops leaving a dead pill behind, and Control Center's sections learn to step aside. The dock graduates from MVP: a context menu with window rows, pin, unpin, quit and new-window; scroll an icon to cycle its windows; drag to reorder on the dock itself; hover previews with one-shot captures; and "Add App to Dock" in the launcher. Three real bugs died en route — menus that slid away with the autohide, a readonly-property crash on every menu open, and a drag that drifted half a slot per icon on side docks. The pinned-apps editor in Settings becomes a drag strip. 166 contracts; the full suite is green except two live display and switcher tests that cannot run behind a locked session — re-verified on unlock. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -159,6 +159,11 @@ hl.layer_rule({
|
||||
name = "qs-dock",
|
||||
match = { namespace = "^qs-dock$" },
|
||||
blur = true,
|
||||
-- The dock's context menu and its window previews are xdg-popups of this
|
||||
-- surface, not layers of their own, so they are only blurred if the rule
|
||||
-- says to blur the popups too. Without it they are flat panes over the
|
||||
-- wallpaper while everything else on the desktop is glass.
|
||||
blur_popups = true,
|
||||
ignore_alpha = 0.3,
|
||||
})
|
||||
|
||||
|
||||
@@ -52,11 +52,74 @@ Singleton {
|
||||
|
||||
readonly property var entries: [
|
||||
// ── Clock ───────────────────────────────────────────────────────────
|
||||
// Group datetime, not clock: this drives the date menu, notification
|
||||
// timestamps, and the lock screen — Date & Time owns it.
|
||||
{
|
||||
key: "use24Hour", type: "bool", def: false, group: "clock",
|
||||
key: "use24Hour", type: "bool", def: false, group: "datetime",
|
||||
label: "24-hour time",
|
||||
detail: "Use 18:30 instead of 6:30 PM"
|
||||
},
|
||||
// ── Bar ─────────────────────────────────────────────────────────────
|
||||
// The bar floats directly on the wallpaper; these keep it legible on
|
||||
// grounds the theme never met, and choose which widgets earn a place.
|
||||
{
|
||||
key: "barTextTone", type: "enum", def: "theme", group: "bar",
|
||||
label: "Bar text",
|
||||
detail: "Follow the theme, or force a light or dark tone for the wallpaper you actually use",
|
||||
options: [
|
||||
{ value: "theme", label: "Follow theme" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "barTextShadow", type: "bool", def: false, group: "bar",
|
||||
label: "Bar text shadow",
|
||||
detail: "A soft dark halo under every glyph and label in the bar"
|
||||
},
|
||||
{
|
||||
key: "barBackdrop", type: "bool", def: false, group: "bar",
|
||||
label: "Bar backdrop",
|
||||
detail: "A subtle scrim fading down from the top edge"
|
||||
},
|
||||
{
|
||||
key: "showWeatherWidget", type: "bool", def: true, group: "bar",
|
||||
label: "Weather in the bar",
|
||||
detail: "Beside the clock, once a forecast has been fetched"
|
||||
},
|
||||
{
|
||||
key: "showMediaWidget", type: "bool", def: true, group: "bar",
|
||||
label: "Media in the bar",
|
||||
detail: "Now playing, click to pause"
|
||||
},
|
||||
{
|
||||
key: "showClipboardButton", type: "bool", def: true, group: "bar",
|
||||
label: "Clipboard button",
|
||||
detail: "The history stays on Super+V either way"
|
||||
},
|
||||
{
|
||||
key: "showCalendarCountdown", type: "bool", def: true, group: "bar",
|
||||
label: "Calendar countdown",
|
||||
detail: "Appears in the bar fifteen minutes before an event"
|
||||
},
|
||||
|
||||
// ── Control Center ──────────────────────────────────────────────────
|
||||
{
|
||||
key: "ccShowFocus", type: "bool", def: true, group: "controlCenter",
|
||||
label: "Focus in Control Center",
|
||||
detail: "The session row at the top of the panel"
|
||||
},
|
||||
{
|
||||
key: "ccShowHome", type: "bool", def: true, group: "controlCenter",
|
||||
label: "Home in Control Center",
|
||||
detail: "Your accessory shelf"
|
||||
},
|
||||
{
|
||||
key: "ccShowPhone", type: "bool", def: true, group: "controlCenter",
|
||||
label: "Phone in Control Center",
|
||||
detail: "Vitals and reach-it actions"
|
||||
},
|
||||
|
||||
{
|
||||
key: "showSeconds", type: "bool", def: true, group: "clock",
|
||||
label: "Show seconds",
|
||||
@@ -811,31 +874,31 @@ Singleton {
|
||||
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
|
||||
key: "workspaceBackAndForth", type: "bool", def: false, group: "workspaces",
|
||||
label: "Switch back and forth",
|
||||
detail: "Selecting the workspace you are already on returns you to the previous one",
|
||||
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
|
||||
key: "allowWorkspaceCycles", type: "bool", def: false, group: "workspaces",
|
||||
label: "Wrap around at the ends",
|
||||
detail: "Moving past the last workspace continues from the first",
|
||||
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
|
||||
key: "focusOnActivate", type: "bool", def: false, group: "workspaces",
|
||||
label: "Let applications take focus",
|
||||
detail: "An application asking for attention is switched to, rather than only highlighted",
|
||||
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "windowSwallow", type: "bool", def: false, group: "multitasking",
|
||||
key: "windowSwallow", type: "bool", def: false, group: "workspaces",
|
||||
label: "Hide the terminal that launched a window",
|
||||
detail: "A terminal disappears while an application started from it is open, and returns when it closes",
|
||||
hypr: { path: ["misc", "enable_swallow"], option: "misc:enable_swallow", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
||||
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "workspaces",
|
||||
label: "Pointer changes active display",
|
||||
detail: "Moving the pointer to another display makes it the active one",
|
||||
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
||||
@@ -980,10 +1043,10 @@ Singleton {
|
||||
label: "Video wallpaper folder",
|
||||
detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /"
|
||||
},
|
||||
// Its own key, not wallpaperPath: the still pipeline persists its
|
||||
// policy transactionally and once clobbered a stored video path.
|
||||
// Two owners, two keys.
|
||||
{
|
||||
// Its own key, not wallpaperPath: the still pipeline persists its
|
||||
// policy transactionally and once clobbered a stored video path.
|
||||
// Two owners, two keys.
|
||||
key: "videoWallpaperPath", type: "string", def: "", group: "wallpaper", internal: true,
|
||||
pattern: "^(|/[^,\n]+)$",
|
||||
label: "Video wallpaper",
|
||||
|
||||
@@ -19,6 +19,26 @@ Singleton {
|
||||
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
|
||||
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
|
||||
|
||||
// ── Bar ─────────────────────────────────────────────────────────────────
|
||||
// Legibility first: the bar sits on the wallpaper, so it may need a tone,
|
||||
// a shadow or a scrim the theme cannot know about. `barTextTone` itself is
|
||||
// read by Theme, which turns it into the barFg family the widgets bind to.
|
||||
readonly property bool barTextShadow: DesktopPreferences.get("barTextShadow")
|
||||
readonly property bool barBackdrop: DesktopPreferences.get("barBackdrop")
|
||||
|
||||
// Which widgets earn a place. Each is ANDed with the widget's own state
|
||||
// condition, so turning one on never conjures a pill with nothing in it.
|
||||
readonly property bool showWeatherWidget: DesktopPreferences.get("showWeatherWidget")
|
||||
readonly property bool showMediaWidget: DesktopPreferences.get("showMediaWidget")
|
||||
readonly property bool showClipboardButton: DesktopPreferences.get("showClipboardButton")
|
||||
readonly property bool showCalendarCountdown: DesktopPreferences.get("showCalendarCountdown")
|
||||
|
||||
// ── Control Center ──────────────────────────────────────────────────────
|
||||
// One bool per section of the quick settings panel that is worth hiding.
|
||||
readonly property bool ccShowFocus: DesktopPreferences.get("ccShowFocus")
|
||||
readonly property bool ccShowHome: DesktopPreferences.get("ccShowHome")
|
||||
readonly property bool ccShowPhone: DesktopPreferences.get("ccShowPhone")
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────────
|
||||
// Coordinates taken from the GNOME night-light setting, which had already
|
||||
// resolved the location. Uses Open-Meteo, which needs no API key.
|
||||
|
||||
@@ -46,6 +46,43 @@ Singleton {
|
||||
readonly property color fgMuted: root.palette.fgMuted
|
||||
readonly property color gutter: root.palette.gutter
|
||||
|
||||
// ── Bar text ────────────────────────────────────────────────────────────
|
||||
// Every other surface in the shell draws on a ground the theme chose. The
|
||||
// bar draws on the wallpaper, which the theme has never seen — so a
|
||||
// photograph with a bright sky can leave the palette's own fg unreadable
|
||||
// exactly where the clock is.
|
||||
//
|
||||
// Hence a second neutral family, used by the bar and nowhere else. Left
|
||||
// alone it *is* the fg family, so nothing changes for anyone who never
|
||||
// asks. Forced light or dark, it becomes a run that survives whatever is
|
||||
// underneath it, and the two dims are mixed toward the opposite end rather
|
||||
// than picked by hand, so the three stay a family either way.
|
||||
readonly property string barTextTone: DesktopPreferences.get("barTextTone")
|
||||
|
||||
readonly property color barFg: {
|
||||
if (root.barTextTone === "light")
|
||||
return "#f4f6ff";
|
||||
if (root.barTextTone === "dark")
|
||||
return "#1b2130";
|
||||
return root.fg;
|
||||
}
|
||||
|
||||
readonly property color barFgDim: {
|
||||
if (root.barTextTone === "light")
|
||||
return root.mix(root.barFg, "#20242f", 0.35);
|
||||
if (root.barTextTone === "dark")
|
||||
return root.mix(root.barFg, "#ffffff", 0.35);
|
||||
return root.fgDim;
|
||||
}
|
||||
|
||||
readonly property color barFgMuted: {
|
||||
if (root.barTextTone === "light")
|
||||
return root.mix(root.barFg, "#20242f", 0.5);
|
||||
if (root.barTextTone === "dark")
|
||||
return root.mix(root.barFg, "#ffffff", 0.5);
|
||||
return root.fgMuted;
|
||||
}
|
||||
|
||||
// ── The accent ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// `accent` is the primary and carries every state meaning (focused, active,
|
||||
|
||||
@@ -35,7 +35,11 @@ somewhere without following it.
|
||||
On a machine with more than one display, workspaces belong to whichever
|
||||
screen has focus. If you would rather pin workspaces one through ten to your
|
||||
main display and give the second screen its own, that is a switch on the
|
||||
Desktop & Dock settings page.
|
||||
Displays settings page.
|
||||
|
||||
How windows share the space — the layout, the master area, gaps and window
|
||||
edges — is Shell › Tiling. The workspace switches themselves, and the projects
|
||||
below, are Shell › Workspaces.
|
||||
|
||||
## Projects
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
sound, input, network, power, accounts and the rest. A category with more than
|
||||
one subject in it opens a row of tabs above the page, which is where the
|
||||
narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab
|
||||
of Input, and About, Software Update, Storage, Snapshots and this manual are
|
||||
tabs of System.
|
||||
of Input, and About, Software Update, Storage, Snapshots, Sync & Backup and
|
||||
this manual are tabs of System.
|
||||
|
||||
Home is the first of them, and the page `Super + I` lands on. Its Overview tab
|
||||
is the one you already know; My Home lists your Home Assistant lights by room
|
||||
@@ -64,6 +64,76 @@ little in a tiler. You get close, on whichever side you prefer. Panama's own
|
||||
titlebar can be turned off entirely, which leaves the Settings window bare:
|
||||
`Super + Q` closes it, `Super + drag` moves it, `Escape` still works.
|
||||
|
||||
## Shell
|
||||
|
||||
Everything Panama draws on the screen has a tab under **Shell**: the bar, the
|
||||
dock, Control Center, tiling and workspaces. Appearance decides the colours;
|
||||
this decides what is there at all.
|
||||
|
||||
### The bar
|
||||
|
||||
The bar has no background of its own — it floats on your wallpaper. That is
|
||||
fine over most images and hopeless over a few, so the **Bar** tab opens on the
|
||||
three controls that fix it. **Bar text** follows your theme by default, or you
|
||||
can force it light or dark for the wallpaper you actually use. **Bar text
|
||||
shadow** puts a soft halo under every glyph at once. **Bar backdrop** fades a
|
||||
thin scrim down from the top edge. All three land on the real bar above the
|
||||
window as you change them, so you can watch rather than guess.
|
||||
|
||||
Under that, one switch per widget: weather, media, the clipboard button, the
|
||||
calendar countdown, processor, memory, graphics, battery and agent usage. A
|
||||
switch only ever removes something — turning weather on does not make a
|
||||
forecast appear before one has been fetched. The indicators that come and go on
|
||||
their own — system health, microphone and camera, focus, a paused video
|
||||
wallpaper — have no switches, because they are already absent whenever they
|
||||
have nothing to say.
|
||||
|
||||
Seconds and the weekday are here too. **24-hour time** is not: it also changes
|
||||
the date menu, your notification timestamps and the lock screen, so it lives on
|
||||
System › Date & Time with the rest of the clock.
|
||||
|
||||
### The dock
|
||||
|
||||
**Pinned applications** shows the dock as the dock shows it. Drag an icon to
|
||||
move it, hover one for the × that unpins it, and search underneath to add
|
||||
something. The order here is the order on screen.
|
||||
|
||||
The dock itself takes the same gestures now. Drag an icon along the dock to
|
||||
reorder it without opening Settings at all. Right-click one for its menu: every
|
||||
open window of that application listed by title, whatever shortcuts the
|
||||
application itself offers, then a new window, pin or unpin, quit, and **Dock
|
||||
settings** if you want the rest. Scroll on an icon to step through that
|
||||
application's windows. Hover one that has windows open and a preview of each
|
||||
appears after a moment.
|
||||
|
||||
For pinning something that is not running, the launcher has **Add App to
|
||||
Dock** — type it into `Super + Space`, pick the application, done.
|
||||
|
||||
### Control Center
|
||||
|
||||
The panel that opens from the status cluster at the right of the bar. The
|
||||
**Control Center** tab turns its sections on and off: Focus, Home and Phone.
|
||||
Hiding one only hides it from the panel; the settings page behind it still
|
||||
works.
|
||||
|
||||
### Tiling and workspaces
|
||||
|
||||
The last two tabs hold what used to be spread across the old Desktop page.
|
||||
**Tiling** is the window layout, the master area, gaps and window edges, and
|
||||
the Hyprland notices Panama keeps quiet. **Workspaces** is the workspace
|
||||
behaviour, how long a focus session runs, and your saved projects.
|
||||
|
||||
## Carrying settings between machines
|
||||
|
||||
System › **Sync & Backup**. Export writes your settings to a file; importing
|
||||
one shows you exactly what would change before anything does, and you decide
|
||||
then. Below it, **Settings backups** keeps dated copies you can restore from —
|
||||
these are your settings, not btrfs Snapshots, which is a different tab and
|
||||
covers the whole filesystem. **Restore defaults** is at the bottom: it resets
|
||||
appearance, dock, clock, focus and display policy and clears your Home
|
||||
accessory arrangement, and leaves your pinned applications, files and paired
|
||||
devices alone.
|
||||
|
||||
## Applications
|
||||
|
||||
`panama apps` in a terminal offers the optional application categories the
|
||||
|
||||
@@ -36,7 +36,7 @@ Pill {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: PrivacyState.activeKinds.length > 1
|
||||
text: String(PrivacyState.activeKinds.length)
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
|
||||
@@ -27,8 +27,8 @@ Pill {
|
||||
|
||||
visible: Settings.showAgentUsage && AgentUsage.available
|
||||
|
||||
onActivated: ShellState.openSettings("appearance")
|
||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||
onActivated: ShellState.openSettings("bar")
|
||||
onSecondaryActivated: ShellState.openSettings("bar")
|
||||
|
||||
Row {
|
||||
spacing: 4
|
||||
@@ -39,7 +39,7 @@ Pill {
|
||||
color: {
|
||||
if (AgentUsage.headline >= 90) return Theme.danger;
|
||||
if (AgentUsage.headline >= 75) return Theme.warn;
|
||||
return Theme.fgDim;
|
||||
return Theme.barFgDim;
|
||||
}
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
@@ -48,7 +48,7 @@ Pill {
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: AgentUsage.headline + "%"
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.features: Theme.tabularFigures
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import qs.config
|
||||
import qs.modules.clipboard
|
||||
import qs.modules.focus
|
||||
@@ -62,13 +63,46 @@ PanelWindow {
|
||||
item: barContent
|
||||
}
|
||||
|
||||
// A scrim for wallpapers the bar cannot win against on tone alone. Drawn
|
||||
// behind the content rows, fading out downward so the bar still has no
|
||||
// hard edge of its own — the point is to darken what is under the text,
|
||||
// not to give the bar a surface. Static: a gradient that never changes
|
||||
// costs one paint, and this one has nothing to animate.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: Settings.barBackdrop
|
||||
|
||||
gradient: Gradient {
|
||||
GradientStop {
|
||||
position: 0.0
|
||||
color: Theme.alpha("#0c0e18", 0.55)
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.0
|
||||
color: "transparent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: barContent
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
// One shadow for the whole bar rather than one per widget: the content
|
||||
// is flattened into a single layer and the halo is drawn under it, so
|
||||
// every glyph and label picks it up and nothing has to opt in. Off by
|
||||
// default — it only earns its layer on a busy wallpaper.
|
||||
layer.enabled: Settings.barTextShadow
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: "#0a0c14"
|
||||
shadowBlur: 0.6
|
||||
shadowVerticalOffset: 1
|
||||
}
|
||||
|
||||
// ── Left ────────────────────────────────────────────────────────────
|
||||
Row {
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.itemSpacing
|
||||
|
||||
@@ -10,7 +10,7 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
visible: CalendarAgenda.capsuleVisible
|
||||
visible: Settings.showCalendarCountdown && CalendarAgenda.capsuleVisible
|
||||
opacity: visible ? 1 : 0
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.openDateMenu("agenda")
|
||||
@@ -47,7 +47,7 @@ Pill {
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: CalendarAgenda.capsuleText
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
|
||||
@@ -39,7 +39,7 @@ Pill {
|
||||
// figures the whole clock shifts sideways on every digit change.
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ Pill {
|
||||
return artist ? artist + " — " + title : title;
|
||||
}
|
||||
|
||||
visible: root.player !== null
|
||||
visible: Settings.showMediaWidget && root.player !== null
|
||||
horizontalPadding: 8
|
||||
|
||||
onActivated: if (root.player?.canTogglePlaying)
|
||||
@@ -63,7 +63,7 @@ Pill {
|
||||
text: root.label
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
|
||||
// Titles are unbounded; the bar is not. Elide rather than let one
|
||||
// podcast episode push the tray off the edge.
|
||||
|
||||
@@ -77,10 +77,10 @@ Pill {
|
||||
|
||||
color: {
|
||||
if (root.wiredDevice)
|
||||
return Theme.fg;
|
||||
return Theme.barFg;
|
||||
if (!root.wifiNetwork)
|
||||
return Theme.fgMuted;
|
||||
return Networking.connectivity === NetworkConnectivity.Full ? Theme.fg : Theme.warn;
|
||||
return Theme.barFgMuted;
|
||||
return Networking.connectivity === NetworkConnectivity.Full ? Theme.barFg : Theme.warn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ Pill {
|
||||
visible: KeyboardLayout.multiple
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: KeyboardLayout.shortLabel
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
@@ -117,7 +117,7 @@ Pill {
|
||||
return "\u{F0580}"; // md-volume_medium
|
||||
return "\u{F057E}"; // md-volume_high
|
||||
}
|
||||
color: root.muted ? Theme.fgMuted : Theme.fg
|
||||
color: root.muted ? Theme.barFgMuted : Theme.barFg
|
||||
}
|
||||
|
||||
StatusGlyph {
|
||||
@@ -125,7 +125,7 @@ Pill {
|
||||
// simply absent rather than shown crossed out.
|
||||
visible: root.bluetoothOn
|
||||
glyph: root.bluetoothConnected ? "\u{F00B1}" : "\u{F00AF}" // md-bluetooth_connect / md-bluetooth
|
||||
color: root.bluetoothConnected ? Theme.accent : Theme.fg
|
||||
color: root.bluetoothConnected ? Theme.accent : Theme.barFg
|
||||
}
|
||||
|
||||
StatusGlyph {
|
||||
@@ -151,7 +151,7 @@ Pill {
|
||||
if (Battery.critical) return Theme.danger;
|
||||
if (Battery.low) return Theme.warn;
|
||||
if (Battery.charging) return Theme.ok;
|
||||
return Theme.fg;
|
||||
return Theme.barFg;
|
||||
}
|
||||
|
||||
StatusGlyph {
|
||||
|
||||
@@ -16,7 +16,7 @@ Text {
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
width: 18
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
|
||||
@@ -20,7 +20,7 @@ Row {
|
||||
text: root.glyph
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: Theme.fgDim
|
||||
color: Theme.barFgDim
|
||||
}
|
||||
|
||||
Text {
|
||||
@@ -41,7 +41,7 @@ Row {
|
||||
return Theme.danger;
|
||||
if (root.value >= root.warnAt)
|
||||
return Theme.warn;
|
||||
return Theme.fg;
|
||||
return Theme.barFg;
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
|
||||
@@ -15,7 +15,12 @@ Pill {
|
||||
|
||||
// Right-click opens the settings that govern this widget. Which readouts
|
||||
// appear in the bar, and how often they update.
|
||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||
onSecondaryActivated: ShellState.openSettings("bar")
|
||||
|
||||
// Turning off all three readouts should remove the pill, not leave an empty
|
||||
// one behind: an invisible child still occupies its Row, so without this the
|
||||
// padding stays and the bar keeps a gap that reports nothing.
|
||||
visible: Settings.showCpu || Settings.showMemory || (Settings.showGpu && Vitals.gpuAvailable)
|
||||
|
||||
interactive: false
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Pill {
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: VideoWallpaper.paused ? "\u{F040A}" : "\u{F03E4}" // play / pause
|
||||
color: VideoWallpaper.paused ? Theme.fgDim : Theme.warn
|
||||
color: VideoWallpaper.paused ? Theme.barFgDim : Theme.warn
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 13
|
||||
}
|
||||
@@ -33,7 +33,7 @@ Pill {
|
||||
? (VideoWallpaper.gamePaused ? "Paused for game"
|
||||
: (VideoWallpaper.batteryPaused ? "Paused on battery" : "Paused"))
|
||||
: "Wallpaper"
|
||||
color: Theme.fgDim
|
||||
color: Theme.barFgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Pill {
|
||||
onSecondaryActivated: ShellState.openSettings("home")
|
||||
|
||||
interactive: false
|
||||
visible: Weather.available
|
||||
visible: Settings.showWeatherWidget && Weather.available
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -29,6 +29,6 @@ Pill {
|
||||
text: Math.round(Weather.temperature) + Weather.unitSuffix
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
color: Theme.fg
|
||||
color: Theme.barFg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ Item {
|
||||
color: {
|
||||
if (slot.modelData.urgent)
|
||||
return Theme.urgent;
|
||||
return slot.isOccupied ? Theme.fg : Theme.alpha(Theme.fg, 0.3);
|
||||
return slot.isOccupied ? Theme.barFg : Theme.alpha(Theme.barFg, 0.3);
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
|
||||
@@ -9,6 +9,10 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Hiding the button does not retire the feature: Super+V still opens the
|
||||
// history. This only reclaims the space in the bar.
|
||||
visible: Settings.showClipboardButton
|
||||
|
||||
implicitWidth: 30
|
||||
implicitHeight: 28
|
||||
|
||||
@@ -19,6 +23,6 @@ Pill {
|
||||
size: 16
|
||||
icon: "edit-paste-symbolic"
|
||||
iconFallback: "edit-copy-symbolic"
|
||||
tint: ShellState.clipboardOpen ? Theme.accent : Theme.fg
|
||||
tint: ShellState.clipboardOpen ? Theme.accent : Theme.barFg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,16 @@ PanelWindow {
|
||||
return !!ws && ws.toplevels.values.length > 0;
|
||||
}
|
||||
|
||||
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered
|
||||
// Anything anchored to the dock that the dock would drag off-screen with
|
||||
// it. The pointer leaves the dock the moment it enters an open menu -- the
|
||||
// menu is its own surface -- so without this the dock slides away under
|
||||
// the menu it opened, and the row the hand was reaching for goes with it.
|
||||
// The same is true mid-drag, and while a preview is up.
|
||||
readonly property bool interactionHeld: dockContextMenu.visible
|
||||
|| body.dragging
|
||||
|| dockPreviews.visible
|
||||
|
||||
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered || root.interactionHeld
|
||||
|
||||
property bool revealed: true
|
||||
|
||||
@@ -210,9 +219,13 @@ PanelWindow {
|
||||
DockBody {
|
||||
id: body
|
||||
|
||||
onContextMenuRequested: (anchorItem, entry) => {
|
||||
onContextMenuRequested: (anchorItem, app) => {
|
||||
// The whole app object, not its desktop entry: the menu lists
|
||||
// the app's own windows and offers to pin or unpin it, and
|
||||
// neither fact survives being narrowed to an entry.
|
||||
body.dismissPreview();
|
||||
dockContextMenu.anchorItem = anchorItem;
|
||||
dockContextMenu.entry = entry;
|
||||
dockContextMenu.app = app;
|
||||
dockContextMenu.visible = true;
|
||||
}
|
||||
|
||||
@@ -270,4 +283,23 @@ PanelWindow {
|
||||
DockContextMenu {
|
||||
id: dockContextMenu
|
||||
}
|
||||
|
||||
// Its own surface rather than something drawn inside the dock: the dock's
|
||||
// input mask is a thin strip when hidden and the bar's own rectangle when
|
||||
// shown, and widening it to cover a preview would hand the dock every
|
||||
// click in the empty space above it.
|
||||
DockPreviews {
|
||||
id: dockPreviews
|
||||
|
||||
anchorItem: body.previewAnchor
|
||||
app: body.previewApp
|
||||
position: root.position
|
||||
|
||||
// The pointer crossing from the icon to the previews leaves the dock
|
||||
// entirely -- these are separate surfaces -- so the previews report
|
||||
// their own hover back, and DockBody's grace timer uses it to tell
|
||||
// "reaching for a preview" from "moved away".
|
||||
onHoveredChanged: body.previewHovered = dockPreviews.hovered
|
||||
onDismissed: body.dismissPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@ Rectangle {
|
||||
// ── Model ───────────────────────────────────────────────────────────────
|
||||
// One pass over the live toplevel list produces the whole dock: pinned
|
||||
// apps first in Settings order, then anything else that is running.
|
||||
// Entries are plain JS objects: { entry, windows, appId }.
|
||||
// Entries are plain JS objects: { entry, windows, appId, pinned }.
|
||||
//
|
||||
// `pinned` is carried rather than inferred later. Two things need it — the
|
||||
// context menu, which cannot otherwise tell "Pin" from "Unpin", and the
|
||||
// drag, which must refuse to reorder an icon that is only there because the
|
||||
// app happens to be running.
|
||||
readonly property var items: {
|
||||
// DesktopEntries is scanned asynchronously at startup, and byId() is a
|
||||
// plain method call that creates no binding dependency. Reading the
|
||||
@@ -63,7 +68,8 @@ Rectangle {
|
||||
out.push({
|
||||
entry: entry,
|
||||
windows: windows,
|
||||
appId: pinned[i]
|
||||
appId: pinned[i],
|
||||
pinned: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +80,8 @@ Rectangle {
|
||||
out.push({
|
||||
entry: DesktopEntries.heuristicLookup(appId),
|
||||
windows: groups[appId],
|
||||
appId: appId
|
||||
appId: appId,
|
||||
pinned: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,7 +123,11 @@ Rectangle {
|
||||
|
||||
// The item the tooltip is currently describing, or null.
|
||||
property Item hoveredItem: null
|
||||
signal contextMenuRequested(Item anchorItem, var entry)
|
||||
|
||||
// The whole app object travels, not just its desktop entry: the menu has to
|
||||
// be able to list the app's windows and tell a pin from something that is
|
||||
// merely running, and neither fact survives being narrowed to an entry.
|
||||
signal contextMenuRequested(Item anchorItem, var app)
|
||||
|
||||
// Set by the Dock. A side dock runs the same strip down the screen instead
|
||||
// of across it.
|
||||
@@ -126,6 +137,171 @@ Rectangle {
|
||||
// it never opens off-screen.
|
||||
property bool leftSide: true
|
||||
|
||||
// ── Reordering ──────────────────────────────────────────────────────────
|
||||
// Dragging an icon along the dock moves its pin. Nothing is written while
|
||||
// the gesture runs: the dragged icon is translated under the pointer, the
|
||||
// icons it passes are translated the other way by exactly one slot, and the
|
||||
// spliced list is committed once on release. Committing per slot crossed
|
||||
// would rewrite settings.json a dozen times for one gesture, and every
|
||||
// rewrite re-evaluates `items` underneath the drag.
|
||||
//
|
||||
// Translation rather than assigned x/y because a Grid owns its children's
|
||||
// positions; a transform is a purely visual offset the positioner ignores.
|
||||
property int dragIndex: -1
|
||||
property string dragId: ""
|
||||
property real dragTravel: 0
|
||||
|
||||
readonly property bool dragging: root.dragIndex >= 0
|
||||
|
||||
// One cell plus the gap after it: the distance the strip moves things by.
|
||||
// Reported by the item that started the drag rather than recomputed here --
|
||||
// a DockItem is taller than it is wide, so a slot down a side dock is not
|
||||
// the same distance as a slot across a bottom one. The initial value only
|
||||
// has to be non-zero; the first drag replaces it with the measured pitch.
|
||||
property real dragStep: Theme.dockIconSize + Theme.dockGap
|
||||
|
||||
// How many pins are on the dock. Resolved pins are always the leading run
|
||||
// of `items`, so this doubles as the last index a drag may land on.
|
||||
readonly property int pinnedCount: {
|
||||
let count = 0;
|
||||
for (let i = 0; i < root.items.length; i++) {
|
||||
if (!root.items[i].pinned)
|
||||
break;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
readonly property int dropIndex: {
|
||||
if (root.dragIndex < 0)
|
||||
return -1;
|
||||
const slots = Math.round(root.dragTravel / root.dragStep);
|
||||
return Math.max(0, Math.min(root.pinnedCount - 1, root.dragIndex + slots));
|
||||
}
|
||||
|
||||
// Where item `index` sits while a drag is in flight, relative to the slot
|
||||
// the Grid put it in.
|
||||
function dragShiftFor(index: int): real {
|
||||
if (root.dragIndex < 0)
|
||||
return 0;
|
||||
if (index === root.dragIndex)
|
||||
return root.dragTravel;
|
||||
if (root.dropIndex > root.dragIndex && index > root.dragIndex && index <= root.dropIndex)
|
||||
return -root.dragStep;
|
||||
if (root.dropIndex < root.dragIndex && index >= root.dropIndex && index < root.dragIndex)
|
||||
return root.dragStep;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function beginDrag(index: int, pitch: real): void {
|
||||
if (index < 0 || index >= root.pinnedCount)
|
||||
return;
|
||||
// A preview anchored to an icon that is about to move under the pointer
|
||||
// is a surface pointing at nothing.
|
||||
root.dismissPreview();
|
||||
if (pitch > 0)
|
||||
root.dragStep = pitch;
|
||||
root.dragIndex = index;
|
||||
root.dragId = root.items[index].appId;
|
||||
root.dragTravel = 0;
|
||||
}
|
||||
|
||||
function moveDrag(travel: real): void {
|
||||
if (root.dragIndex < 0)
|
||||
return;
|
||||
root.dragTravel = travel;
|
||||
}
|
||||
|
||||
// Resolved by pin id rather than by index. `items` drops a pin that no
|
||||
// longer resolves, so an index into the dock is not an index into the
|
||||
// stored list, and a window opening mid-drag can shift both.
|
||||
function endDrag(): void {
|
||||
const target = root.dropIndex;
|
||||
const from = root.dragId;
|
||||
const to = target >= 0 && target < root.items.length ? root.items[target].appId : "";
|
||||
root.dragIndex = -1;
|
||||
root.dragId = "";
|
||||
root.dragTravel = 0;
|
||||
|
||||
if (!from || !to || from === to)
|
||||
return;
|
||||
|
||||
const stored = Settings.dockPinned.slice();
|
||||
const fromAt = stored.indexOf(from);
|
||||
const toAt = stored.indexOf(to);
|
||||
if (fromAt < 0 || toAt < 0)
|
||||
return;
|
||||
stored.splice(toAt, 0, stored.splice(fromAt, 1)[0]);
|
||||
DesktopPreferences.set("dockPinned", stored);
|
||||
}
|
||||
|
||||
function cancelDrag(): void {
|
||||
root.dragIndex = -1;
|
||||
root.dragId = "";
|
||||
root.dragTravel = 0;
|
||||
}
|
||||
|
||||
// ── Window previews ─────────────────────────────────────────────────────
|
||||
// Hovering an icon long enough shows its windows. The dwell exists so that
|
||||
// sweeping the pointer across the dock on the way somewhere else never
|
||||
// opens anything, and the grace on the way out exists because the previews
|
||||
// are their own surface: leaving the icon to reach them would otherwise
|
||||
// close the thing being reached for.
|
||||
property Item previewAnchor: null
|
||||
property var previewApp: null
|
||||
|
||||
// Written by the Dock from the preview popup's own hover.
|
||||
property bool previewHovered: false
|
||||
|
||||
onHoveredItemChanged: root.reconsiderPreview()
|
||||
onPreviewHoveredChanged: root.reconsiderPreview()
|
||||
|
||||
function reconsiderPreview(): void {
|
||||
const item = root.hoveredItem;
|
||||
const eligible = item && item.app && item.app.windows && item.app.windows.length > 0;
|
||||
|
||||
if (eligible && item !== root.previewAnchor) {
|
||||
previewGrace.stop();
|
||||
previewDwell.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
previewDwell.stop();
|
||||
if (root.previewAnchor && !eligible && !root.previewHovered)
|
||||
previewGrace.restart();
|
||||
else if (eligible || root.previewHovered)
|
||||
previewGrace.stop();
|
||||
}
|
||||
|
||||
function dismissPreview(): void {
|
||||
previewDwell.stop();
|
||||
previewGrace.stop();
|
||||
root.previewAnchor = null;
|
||||
root.previewApp = null;
|
||||
root.previewHovered = false;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: previewDwell
|
||||
interval: 400
|
||||
onTriggered: {
|
||||
const item = root.hoveredItem;
|
||||
if (!item || !item.app || !item.app.windows || item.app.windows.length === 0)
|
||||
return;
|
||||
root.previewAnchor = item;
|
||||
root.previewApp = item.app;
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: previewGrace
|
||||
interval: 220
|
||||
onTriggered: {
|
||||
if (!root.previewHovered)
|
||||
root.dismissPreview();
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit rather than left to Grid's wrapping. This is always one line, so
|
||||
// saying how many cells it holds is both simpler to read and immune to
|
||||
// Grid's default column count quietly wrapping a long dock.
|
||||
@@ -162,16 +338,43 @@ Rectangle {
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.items
|
||||
// `items` is a fresh array of fresh objects on every change -- a
|
||||
// window opening or closing anywhere rebuilds all of it. Handing
|
||||
// that straight to Repeater resets the model and rebuilds every
|
||||
// delegate, which throws away hover state, restarts the grow
|
||||
// animation on icons nothing happened to, and would drop the
|
||||
// delegate out from under a drag in progress. ScriptModel keyed on
|
||||
// appId turns the same rebuild into "these rows changed", so an
|
||||
// icon whose window count went up keeps its delegate.
|
||||
model: ScriptModel {
|
||||
values: root.items
|
||||
objectProp: "appId"
|
||||
comparisonMode: ObjectComparison.Structure
|
||||
}
|
||||
|
||||
DockItem {
|
||||
id: dockItem
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
app: modelData
|
||||
vertical: root.vertical
|
||||
|
||||
// Only a pin can be reordered. An icon that is on the dock
|
||||
// because its app happens to be running has no place in the
|
||||
// stored list to move to.
|
||||
draggable: modelData.pinned === true
|
||||
dragShift: root.dragShiftFor(dockItem.index)
|
||||
dragging: root.dragIndex === dockItem.index
|
||||
|
||||
onEntered: root.hoveredItem = dockItem
|
||||
onExited: if (root.hoveredItem === dockItem)
|
||||
root.hoveredItem = null
|
||||
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
|
||||
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.app)
|
||||
onDragStarted: pitch => root.beginDrag(dockItem.index, pitch)
|
||||
onDragMoved: travel => root.moveDrag(travel)
|
||||
onDragEnded: root.endDrag()
|
||||
onDragCancelled: root.cancelDrag()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,7 +389,9 @@ Rectangle {
|
||||
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
|
||||
|
||||
visible: opacity > 0
|
||||
opacity: root.hoveredItem && tipLabel.text ? 1 : 0
|
||||
// Yields to the window previews, which name the same app and more
|
||||
// besides -- both at once is the same label twice.
|
||||
opacity: root.hoveredItem && tipLabel.text && root.previewAnchor !== root.hoveredItem ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// The dock's app menu. Desktop-entry actions stay first; the shell-owned
|
||||
// configuration route is deliberately last so it never displaces app actions.
|
||||
// The dock's app menu: its open windows, then what the .desktop file offers,
|
||||
// then what the dock itself can do with the app. The shell-owned configuration
|
||||
// route is deliberately last so it never displaces an app action.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.modules.bar
|
||||
@@ -12,19 +14,84 @@ PopupWindow {
|
||||
id: root
|
||||
|
||||
property Item anchorItem: null
|
||||
property var entry: null
|
||||
|
||||
// The whole dock entry: { entry, windows, appId, pinned }. Narrowing this
|
||||
// to a desktop entry on the way in is what used to stop the menu offering
|
||||
// anything about the app's actual windows, or knowing whether it is pinned.
|
||||
property var app: null
|
||||
|
||||
readonly property DesktopEntry entry: root.app && root.app.entry ? root.app.entry : null
|
||||
readonly property var windows: root.app && root.app.windows ? root.app.windows : []
|
||||
readonly property bool pinned: root.app ? root.app.pinned === true : false
|
||||
|
||||
anchor.item: root.anchorItem
|
||||
anchor.edges: Edges.Top | Edges.Left
|
||||
anchor.gravity: Edges.Top | Edges.Right
|
||||
anchor.margins.bottom: 8
|
||||
|
||||
implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240)
|
||||
implicitWidth: Math.min(360, Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240))
|
||||
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
|
||||
color: "transparent"
|
||||
visible: false
|
||||
grabFocus: true
|
||||
|
||||
// Long window titles are the one thing here that can be arbitrarily wide,
|
||||
// and a menu as wide as a browser tab's title is not a menu.
|
||||
function shortTitle(toplevel: var): string {
|
||||
const title = String(toplevel?.title ?? "").trim();
|
||||
if (!title)
|
||||
return root.app && root.app.appId ? root.app.appId : "Untitled window";
|
||||
return title.length > 42 ? title.slice(0, 41) + "…" : title;
|
||||
}
|
||||
|
||||
function addressOf(toplevel: var): string {
|
||||
const raw = String(toplevel?.address ?? "");
|
||||
if (!raw)
|
||||
return "";
|
||||
return raw.startsWith("0x") ? raw : "0x" + raw;
|
||||
}
|
||||
|
||||
function focusToplevel(toplevel: var): void {
|
||||
if (!toplevel)
|
||||
return;
|
||||
if (toplevel.workspace)
|
||||
toplevel.workspace.activate();
|
||||
|
||||
const address = root.addressOf(toplevel);
|
||||
if (address)
|
||||
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
|
||||
else if (toplevel.wayland)
|
||||
toplevel.wayland.activate();
|
||||
}
|
||||
|
||||
// Every window, not the focused one: "Quit" on a dock icon means the app,
|
||||
// which is what the icon stands for.
|
||||
function quit(): void {
|
||||
for (const toplevel of root.windows) {
|
||||
const address = root.addressOf(toplevel);
|
||||
if (address)
|
||||
Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${address}" })`);
|
||||
}
|
||||
}
|
||||
|
||||
// Always the whole array through DesktopPreferences, which is the only
|
||||
// thing the Settings page and the dock agree on.
|
||||
function pin(): void {
|
||||
if (!root.entry)
|
||||
return;
|
||||
const stored = Settings.dockPinned;
|
||||
if (stored.indexOf(root.entry.id) >= 0)
|
||||
return;
|
||||
DesktopPreferences.set("dockPinned", stored.concat([root.entry.id]));
|
||||
}
|
||||
|
||||
function unpin(): void {
|
||||
const id = root.app && root.app.appId ? root.app.appId : "";
|
||||
if (!id)
|
||||
return;
|
||||
DesktopPreferences.set("dockPinned", Settings.dockPinned.filter(other => other !== id));
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.popoverRadius
|
||||
@@ -46,6 +113,33 @@ PopupWindow {
|
||||
anchors.margins: Theme.popoverPadding
|
||||
spacing: 2
|
||||
|
||||
// ── The app's own windows ───────────────────────────────────────
|
||||
Repeater {
|
||||
id: openWindows
|
||||
model: root.windows
|
||||
|
||||
delegate: TrayMenuRow {
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
label: root.shortTitle(modelData)
|
||||
onActivated: {
|
||||
root.focusToplevel(modelData);
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
anchors.margins: 3
|
||||
visible: openWindows.count > 0
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
// ── What the .desktop file offers ───────────────────────────────
|
||||
Repeater {
|
||||
id: applicationActions
|
||||
model: root.entry ? root.entry.actions : []
|
||||
@@ -71,11 +165,59 @@ PopupWindow {
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
// ── What the dock can do with it ────────────────────────────────
|
||||
TrayMenuRow {
|
||||
width: parent.width
|
||||
label: "New window"
|
||||
// A running application with no desktop entry cannot be
|
||||
// launched again -- there is nothing that says how.
|
||||
rowEnabled: root.entry !== null
|
||||
onActivated: {
|
||||
if (root.entry)
|
||||
root.entry.execute();
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
TrayMenuRow {
|
||||
width: parent.width
|
||||
label: root.pinned ? "Unpin from dock" : "Pin to dock"
|
||||
// Unpinning needs only the id the pin was stored under;
|
||||
// pinning needs an entry to name, and an app whose id resolves
|
||||
// to nothing would be pinned as a hole.
|
||||
rowEnabled: root.pinned || root.entry !== null
|
||||
onActivated: {
|
||||
if (root.pinned)
|
||||
root.unpin();
|
||||
else
|
||||
root.pin();
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
TrayMenuRow {
|
||||
width: parent.width
|
||||
label: root.windows.length > 1 ? "Quit all windows" : "Quit"
|
||||
rowEnabled: root.windows.length > 0
|
||||
onActivated: {
|
||||
root.quit();
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
anchors.margins: 3
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.1)
|
||||
}
|
||||
|
||||
TrayMenuRow {
|
||||
width: parent.width
|
||||
label: "Dock settings"
|
||||
onActivated: {
|
||||
ShellState.openSettings("desktop");
|
||||
ShellState.openSettings("dock");
|
||||
root.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import qs.config
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string }
|
||||
// Built by DockBody so this file stays presentational.
|
||||
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string,
|
||||
// pinned: bool }. Built by DockBody so this file stays presentational.
|
||||
required property var app
|
||||
|
||||
readonly property DesktopEntry entry: app && app.entry ? app.entry : null
|
||||
@@ -21,16 +21,49 @@ Item {
|
||||
readonly property bool running: windows.length > 0
|
||||
readonly property bool hovered: mouse.containsMouse
|
||||
|
||||
// Set by DockBody. Which way the dock runs decides which axis a drag reads.
|
||||
property bool vertical: false
|
||||
|
||||
// Reordering, driven from DockBody: whether this icon may be dragged at
|
||||
// all, how far it is currently displaced, and whether it is the one being
|
||||
// dragged rather than one being pushed aside.
|
||||
property bool draggable: false
|
||||
property real dragShift: 0
|
||||
property bool dragging: false
|
||||
|
||||
// Emitted so DockBody can drive the single shared tooltip.
|
||||
signal entered
|
||||
signal exited
|
||||
signal contextMenuRequested
|
||||
|
||||
// The drag, reported as travel along the dock's own axis from where the
|
||||
// press landed. DockBody owns what that means.
|
||||
//
|
||||
// The start also carries the pitch -- one cell plus the gap after it --
|
||||
// because the cell size is this file's business: an item is taller than it
|
||||
// is wide (the running dots sit under the icon), so a slot down a side dock
|
||||
// is further than a slot across a bottom one. Measuring it here rather than
|
||||
// recomputing it in DockBody keeps one copy of that arithmetic.
|
||||
signal dragStarted(real pitch)
|
||||
signal dragMoved(real travel)
|
||||
signal dragEnded
|
||||
signal dragCancelled
|
||||
|
||||
// The icon may grow past the cell on hover; the cell itself stays a fixed
|
||||
// size so the row doesn't reflow.
|
||||
implicitWidth: Theme.dockIconSize
|
||||
implicitHeight: Theme.dockIconSize + dots.height + 4
|
||||
|
||||
// Above the icons it is passing.
|
||||
z: root.dragging ? 2 : 0
|
||||
|
||||
// A transform, not an x/y binding: the Grid owns those, and assigning them
|
||||
// in a delegate fights the positioner rather than moving the icon.
|
||||
transform: Translate {
|
||||
x: root.vertical ? 0 : root.dragShift
|
||||
y: root.vertical ? root.dragShift : 0
|
||||
}
|
||||
|
||||
// Desktop entries usually carry a freedesktop icon *name*, but some ship an
|
||||
// absolute path. iconPath() only understands names, so branch on it. The
|
||||
// `true` argument makes a missing icon return "" instead of a placeholder
|
||||
@@ -118,10 +151,83 @@ Item {
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
|
||||
// Where the press landed, and whether it has travelled far enough to
|
||||
// stop being a click. The threshold is the whole reason a drag can
|
||||
// share this MouseArea with the launch click: a hand that means to
|
||||
// click never moves eight pixels while the button is down.
|
||||
readonly property int dragThreshold: 8
|
||||
property real pressX: 0
|
||||
property real pressY: 0
|
||||
property bool dragActive: false
|
||||
property bool dragConsumed: false
|
||||
|
||||
// A wheel notch is 120 units, but a touchpad sends far smaller ones and
|
||||
// a free-spinning wheel sends larger. Accumulating and spending whole
|
||||
// notches is what makes both feel the same -- reacting to every event
|
||||
// would make a touchpad flick blur through every window an app owns.
|
||||
property int wheelTravel: 0
|
||||
|
||||
onEntered: root.entered()
|
||||
onExited: root.exited()
|
||||
|
||||
onWheel: event => {
|
||||
if (!root.running) {
|
||||
mouse.wheelTravel = 0;
|
||||
return;
|
||||
}
|
||||
mouse.wheelTravel += event.angleDelta.y;
|
||||
while (mouse.wheelTravel >= 120) {
|
||||
mouse.wheelTravel -= 120;
|
||||
root.focusBy(-1);
|
||||
}
|
||||
while (mouse.wheelTravel <= -120) {
|
||||
mouse.wheelTravel += 120;
|
||||
root.focusBy(1);
|
||||
}
|
||||
}
|
||||
|
||||
onPressed: mev => {
|
||||
mouse.pressX = mev.x;
|
||||
mouse.pressY = mev.y;
|
||||
mouse.dragActive = false;
|
||||
mouse.dragConsumed = false;
|
||||
}
|
||||
|
||||
onPositionChanged: mev => {
|
||||
if (!root.draggable || !mouse.pressedButtons)
|
||||
return;
|
||||
const travel = root.vertical ? mev.y - mouse.pressY : mev.x - mouse.pressX;
|
||||
if (!mouse.dragActive) {
|
||||
if (Math.abs(travel) < mouse.dragThreshold)
|
||||
return;
|
||||
mouse.dragActive = true;
|
||||
mouse.dragConsumed = true;
|
||||
root.dragStarted((root.vertical ? root.height : root.width) + Theme.dockGap);
|
||||
}
|
||||
root.dragMoved(travel);
|
||||
}
|
||||
|
||||
onReleased: {
|
||||
if (!mouse.dragActive)
|
||||
return;
|
||||
mouse.dragActive = false;
|
||||
root.dragEnded();
|
||||
}
|
||||
|
||||
onCanceled: {
|
||||
if (!mouse.dragActive)
|
||||
return;
|
||||
mouse.dragActive = false;
|
||||
mouse.dragConsumed = false;
|
||||
root.dragCancelled();
|
||||
}
|
||||
|
||||
onClicked: mev => {
|
||||
// A gesture that reordered the dock is not also a launch.
|
||||
if (mouse.dragConsumed) {
|
||||
mouse.dragConsumed = false;
|
||||
return;
|
||||
}
|
||||
// Middle click always starts a new instance, as in GNOME.
|
||||
if (mev.button === Qt.MiddleButton) {
|
||||
root.launch();
|
||||
@@ -132,7 +238,7 @@ Item {
|
||||
return;
|
||||
}
|
||||
if (root.running)
|
||||
root.focusNext();
|
||||
root.focusBy(1);
|
||||
else
|
||||
root.launch();
|
||||
}
|
||||
@@ -143,19 +249,26 @@ Item {
|
||||
root.entry.execute();
|
||||
}
|
||||
|
||||
// Clicking a running app cycles through its windows, matching GNOME's dash.
|
||||
function focusNext(): void {
|
||||
// Stepping through an app's windows: clicking a running app takes one step
|
||||
// forward, matching GNOME's dash, and the wheel takes one in either
|
||||
// direction. With nothing of this app focused, any step lands on its first
|
||||
// window rather than counting from a window the user is not looking at.
|
||||
function focusBy(delta: int): void {
|
||||
const wins = root.windows;
|
||||
if (wins.length === 0)
|
||||
return;
|
||||
|
||||
let next = wins[0];
|
||||
let current = -1;
|
||||
for (let i = 0; i < wins.length; i++) {
|
||||
if (wins[i].activated) {
|
||||
next = wins[(i + 1) % wins.length];
|
||||
current = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const next = current < 0
|
||||
? wins[0]
|
||||
: wins[((current + delta) % wins.length + wins.length) % wins.length];
|
||||
if (!next)
|
||||
return;
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// "Add app to dock", reachable without opening Settings.
|
||||
//
|
||||
// The Settings page is the place to curate the whole dock -- reorder it, unpin
|
||||
// things, change how it hides. Adding one application is a single decision made
|
||||
// while looking at the dock, and routing it through a settings window means
|
||||
// finding the page, then the card, then the box. This is that box, on its own.
|
||||
//
|
||||
// It embeds the same DockAppPicker the Dock page uses rather than growing a
|
||||
// second search: the exclusion of already-pinned applications, the icon lookup
|
||||
// and the "nothing until you type" behaviour are all already there, and two
|
||||
// copies of them would drift.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.settings
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
readonly property bool open: ShellState.dockPickerOpen
|
||||
|
||||
// The one place a pin is added from outside the Settings page. Returns
|
||||
// false for an id nothing installs, so the IPC caller hears about a typo
|
||||
// instead of the dock quietly gaining a hole -- DockBody drops a pin it
|
||||
// cannot resolve, so a bad id is invisible at runtime.
|
||||
function pin(id: string): bool {
|
||||
const wanted = String(id ?? "").trim();
|
||||
if (!wanted || !DesktopEntries.byId(wanted))
|
||||
return false;
|
||||
const stored = Settings.dockPinned;
|
||||
if (stored.indexOf(wanted) >= 0)
|
||||
return true;
|
||||
DesktopPreferences.set("dockPinned", stored.concat([wanted]));
|
||||
return true;
|
||||
}
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
|
||||
// here rather than added to that rule, the same way the cheatsheet does it.
|
||||
WlrLayershell.namespace: "qs-popover-dock-picker"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: root.open
|
||||
? WlrKeyboardFocus.Exclusive
|
||||
: WlrKeyboardFocus.None
|
||||
|
||||
// Stays mapped for the length of the close animation, or it vanishes
|
||||
// instantly and only the opening is ever seen.
|
||||
property bool mapped: false
|
||||
visible: root.mapped
|
||||
|
||||
onOpenChanged: {
|
||||
if (root.open) {
|
||||
unmapTimer.stop();
|
||||
root.mapped = true;
|
||||
picker.grab();
|
||||
} else {
|
||||
unmapTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: unmapTimer
|
||||
interval: Theme.durNormal
|
||||
onTriggered: root.mapped = false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||
opacity: root.open ? 1 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: ShellState.close()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
// High rather than centred: the list grows downwards as you type, and a
|
||||
// centred card walks up the screen while you are reading it.
|
||||
y: Math.round(parent.height * 0.18)
|
||||
width: Math.min(root.width - 120, 520)
|
||||
height: header.height + picker.implicitHeight + 56
|
||||
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
|
||||
opacity: root.open ? 1 : 0
|
||||
scale: root.open ? 1 : 0.98
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
|
||||
Behavior on scale { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: Theme.popoverRadius
|
||||
}
|
||||
|
||||
// Clicks on the card itself must not fall through to the scrim.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Item {
|
||||
id: header
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 22
|
||||
height: title.implicitHeight
|
||||
|
||||
Text {
|
||||
id: title
|
||||
text: "Add app to dock"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: title.verticalCenter
|
||||
text: "Esc to close"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
DockAppPicker {
|
||||
id: picker
|
||||
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: 16
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 22
|
||||
anchors.rightMargin: 22
|
||||
|
||||
pinned: Settings.dockPinned
|
||||
onPicked: id => {
|
||||
root.pin(id);
|
||||
ShellState.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Shortcut rather than Keys.onEscapePressed on an item: the search box
|
||||
// owns Escape while it has focus (it clears the query first), and a key
|
||||
// handler on an ancestor would never see the second press.
|
||||
Shortcut {
|
||||
sequence: "Escape"
|
||||
enabled: root.open
|
||||
onActivated: ShellState.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// What a dock icon has open, shown after a dwell on hover.
|
||||
//
|
||||
// Its own surface, not something drawn inside the dock. The dock's input mask
|
||||
// is a thin strip when hidden and the bar's own rectangle when revealed;
|
||||
// widening it to cover a preview strip would hand the dock every click in the
|
||||
// empty space above it, which is most of the screen.
|
||||
//
|
||||
// Capture is one-shot -- `live: false` plus an explicit captureFrame() -- for
|
||||
// the same reason the overview's thumbnails are: streaming four windows for as
|
||||
// long as a pointer rests on an icon repaints continuously for nothing. The
|
||||
// deferral around that first capture is copied from
|
||||
// modules/overview/WindowThumbnail.qml, where the reasoning is written out.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
PopupWindow {
|
||||
id: root
|
||||
|
||||
// The DockItem being hovered, and the app object behind it. Both are
|
||||
// written by the Dock from DockBody's dwell timer.
|
||||
property Item anchorItem: null
|
||||
property var app: null
|
||||
|
||||
// Which edge the dock lives on, so the strip appears on the side of the
|
||||
// icon that faces the screen rather than off the edge.
|
||||
property string position: "bottom"
|
||||
|
||||
readonly property bool vertical: root.position === "left" || root.position === "right"
|
||||
|
||||
// Four is the cap. A strip of previews wider than the screen is not a
|
||||
// preview of anything, and the point of this surface is to answer "which
|
||||
// window do I want" at a glance -- past four, the answer is the overview.
|
||||
readonly property int previewCap: 4
|
||||
|
||||
readonly property var allWindows: root.app && root.app.windows ? root.app.windows : []
|
||||
readonly property var windows: root.allWindows.length > root.previewCap
|
||||
? root.allWindows.slice(0, root.previewCap)
|
||||
: root.allWindows
|
||||
|
||||
readonly property int overflow: root.allWindows.length - root.windows.length
|
||||
|
||||
// Read by the Dock, which feeds it back to DockBody's grace timer. Crossing
|
||||
// from the icon to a preview leaves the dock entirely -- these are separate
|
||||
// surfaces -- so without this the act of reaching for a preview closes it.
|
||||
readonly property bool hovered: pointer.hovered
|
||||
|
||||
signal dismissed
|
||||
|
||||
// Bumped each time the strip opens; each bump re-captures, so a window that
|
||||
// has changed since the last look is not shown as it was.
|
||||
property int refreshToken: 0
|
||||
|
||||
anchor.item: root.anchorItem
|
||||
// Bottom dock: above the icon. Side dock: alongside it, away from the edge.
|
||||
anchor.edges: root.vertical
|
||||
? (root.position === "left" ? Edges.Right : Edges.Left)
|
||||
: Edges.Top
|
||||
anchor.gravity: root.vertical
|
||||
? (root.position === "left" ? Edges.Right : Edges.Left)
|
||||
: Edges.Top
|
||||
anchor.margins.bottom: root.vertical ? 0 : 10
|
||||
anchor.margins.left: root.position === "left" ? 10 : 0
|
||||
anchor.margins.right: root.position === "right" ? 10 : 0
|
||||
|
||||
implicitWidth: strip.implicitWidth + 12
|
||||
implicitHeight: strip.implicitHeight + 12
|
||||
|
||||
color: "transparent"
|
||||
visible: root.anchorItem !== null && root.windows.length > 0
|
||||
|
||||
// Deliberately NOT grabFocus. A grab would close the strip on the first
|
||||
// click anywhere and take the pointer with it, which is exactly the
|
||||
// gesture that is supposed to focus a window.
|
||||
grabFocus: false
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
root.refreshToken++;
|
||||
}
|
||||
|
||||
function addressOf(toplevel: var): string {
|
||||
const raw = String(toplevel?.address ?? "");
|
||||
if (!raw)
|
||||
return "";
|
||||
return raw.startsWith("0x") ? raw : "0x" + raw;
|
||||
}
|
||||
|
||||
function focusToplevel(toplevel: var): void {
|
||||
if (!toplevel)
|
||||
return;
|
||||
if (toplevel.workspace)
|
||||
toplevel.workspace.activate();
|
||||
|
||||
const address = root.addressOf(toplevel);
|
||||
if (address)
|
||||
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
|
||||
else if (toplevel.wayland)
|
||||
toplevel.wayland.activate();
|
||||
|
||||
root.dismissed();
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: pointer
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Row {
|
||||
id: strip
|
||||
anchors.centerIn: parent
|
||||
padding: 10
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.windows
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property var source: card.modelData ? card.modelData.wayland : null
|
||||
|
||||
width: 176
|
||||
height: 132
|
||||
radius: Theme.cardRadius
|
||||
border.width: 0 // QTBUG-137166
|
||||
color: cardHover.hovered
|
||||
? Theme.alpha(Theme.fg, Theme.hoverAlpha)
|
||||
: Theme.alpha(Theme.bg, 0.5)
|
||||
clip: true
|
||||
|
||||
Item {
|
||||
id: frame
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: caption.top
|
||||
anchors.margins: 6
|
||||
anchors.bottomMargin: 2
|
||||
|
||||
// Shown until a frame arrives, and forever on a
|
||||
// compositor without screencopy -- an icon rather than
|
||||
// an empty box or an error.
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
visible: root.app && root.app.entry && root.app.entry.icon
|
||||
source: root.app && root.app.entry && root.app.entry.icon
|
||||
? (String(root.app.entry.icon).startsWith("/")
|
||||
? "file://" + root.app.entry.icon
|
||||
: Quickshell.iconPath(root.app.entry.icon, true))
|
||||
: ""
|
||||
implicitSize: 32
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
opacity: shotLoader.hasFrame ? 0 : 1
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: shotLoader
|
||||
anchors.fill: parent
|
||||
|
||||
readonly property bool hasFrame: item ? item.hasContent : false
|
||||
|
||||
// Gated on refreshToken for the reason spelled out
|
||||
// in WindowThumbnail: a ScreencopyView created
|
||||
// before its surface has mapped has no recording
|
||||
// context, and its first capture fails silently.
|
||||
active: !!card.source && root.refreshToken > 0
|
||||
sourceComponent: shotComponent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: shotComponent
|
||||
|
||||
ScreencopyView {
|
||||
id: shot
|
||||
captureSource: card.source
|
||||
live: false
|
||||
paintCursor: false
|
||||
constraintSize: Qt.size(frame.width, frame.height)
|
||||
|
||||
readonly property real aspect: sourceSize.height > 0
|
||||
? sourceSize.width / sourceSize.height
|
||||
: 16 / 9
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width, parent.height * aspect)
|
||||
height: aspect > 0 ? width / aspect : parent.height
|
||||
opacity: hasContent ? 1 : 0
|
||||
|
||||
property bool recordingReady: false
|
||||
|
||||
function tryCapture(): void {
|
||||
if (shot.hasContent)
|
||||
return;
|
||||
if (!shot.recordingReady) {
|
||||
frameReady.restart();
|
||||
return;
|
||||
}
|
||||
shot.captureFrame();
|
||||
}
|
||||
|
||||
// One frame of the popup's own rendering is
|
||||
// what makes the capture context exist.
|
||||
FrameAnimation {
|
||||
id: frameReady
|
||||
running: false
|
||||
onTriggered: {
|
||||
running = false;
|
||||
if (shot.hasContent)
|
||||
return;
|
||||
shot.recordingReady = true;
|
||||
shot.tryCapture();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: tryCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 7
|
||||
text: String(card.modelData?.title ?? "").trim() || (root.app ? root.app.appId : "")
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: cardHover
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.focusToplevel(card.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only when there are more windows than fit. Says so rather than
|
||||
// silently showing four of nine.
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.overflow > 0
|
||||
width: visible ? implicitWidth : 0
|
||||
text: "+" + root.overflow
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ Pill {
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: FocusSession.remainingText
|
||||
color: FocusSession.paused ? Theme.fgDim : Theme.fg
|
||||
color: FocusSession.paused ? Theme.barFgDim : Theme.barFg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
|
||||
@@ -216,6 +216,7 @@ Item {
|
||||
|
||||
RowButton {
|
||||
width: content.width
|
||||
visible: Settings.ccShowFocus
|
||||
icon: "preferences-system-time-symbolic"
|
||||
iconFallback: "appointment-soon-symbolic"
|
||||
label: FocusSession.active ? "Focus · " + FocusSession.workspaceLabel : "Start focus session"
|
||||
@@ -343,14 +344,18 @@ Item {
|
||||
}
|
||||
|
||||
// ── Connected life ─────────────────────────────────────────────────
|
||||
// Hidden sections leave no gap: Column skips invisible children
|
||||
// outright, so the panel closes up around them.
|
||||
HomeControls {
|
||||
width: content.width
|
||||
visible: Settings.ccShowHome
|
||||
expanded: root.expandedSection === "home"
|
||||
onToggleExpanded: root.expand("home")
|
||||
}
|
||||
|
||||
PhoneControls {
|
||||
width: content.width
|
||||
visible: Settings.ccShowPhone
|
||||
expanded: root.expandedSection === "phone"
|
||||
onToggleExpanded: root.expand("phone")
|
||||
}
|
||||
|
||||
@@ -73,7 +73,6 @@ SettingsPage {
|
||||
{ value: "background", label: "Background" },
|
||||
{ value: "type", label: "Typography" },
|
||||
{ value: "windows", label: "Windows" },
|
||||
{ value: "shell", label: "Shell" },
|
||||
]
|
||||
current: root.tab
|
||||
onSelected: value => root.tab = value
|
||||
@@ -581,48 +580,4 @@ SettingsPage {
|
||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "shell"
|
||||
title: "Clock"
|
||||
|
||||
ToggleRow { setting: "use24Hour" }
|
||||
ToggleRow { setting: "showSeconds" }
|
||||
ToggleRow { setting: "showWeekday"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.tab === "shell"
|
||||
title: "System vitals"
|
||||
subtitle: "Choose what appears beside the workspace indicator."
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu" }
|
||||
// Only where there is a battery to report on. A desktop should not be
|
||||
// offered a switch for a readout it can never show.
|
||||
ToggleRow { setting: "showBattery"; visible: Battery.available }
|
||||
ToggleRow { setting: "showBatteryPercent"; visible: Battery.available && Settings.showBattery }
|
||||
ToggleRow { setting: "showAgentUsage"; divider: true }
|
||||
// Refresh interval was on the Home page, which split one concept across
|
||||
// two pages -- what the vitals show here, how often they update there.
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
|
||||
width: parent.width
|
||||
label: "Graphics device"
|
||||
detail: GraphicsDevices.selectionMissing
|
||||
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
|
||||
: "Which GPU the graphics readout measures."
|
||||
options: GraphicsDevices.devices.map(device => ({
|
||||
value: device.path,
|
||||
label: GraphicsDevices.shortName(device.name)
|
||||
}))
|
||||
current: GraphicsDevices.selectedPath
|
||||
divider: false
|
||||
onPicked: value => GraphicsDevices.select(value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// The bar.
|
||||
//
|
||||
// Everything Panama draws along the top edge: how legible it stays, what earns
|
||||
// a place in it, and how often the readouts refresh. The bar floats directly on
|
||||
// the wallpaper, so legibility is a real setting and not a theme detail -- a
|
||||
// theme that reads perfectly against the panel background can disappear
|
||||
// entirely over a bright photograph.
|
||||
//
|
||||
// The Clock and System vitals cards came from Appearance's Shell tab, which was
|
||||
// the wrong home for them: Appearance is about how surfaces look, and these
|
||||
// decide what the bar contains.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Bar"
|
||||
lede: "The bar sits on whatever wallpaper you chose. These keep it legible on all of them."
|
||||
|
||||
SettingsCard {
|
||||
title: "Visibility"
|
||||
subtitle: "The real bar above this window is the preview — every change here lands on it immediately."
|
||||
|
||||
ChoiceRow { setting: "barTextTone" }
|
||||
ToggleRow { setting: "barTextShadow" }
|
||||
ToggleRow { setting: "barBackdrop"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Widgets"
|
||||
subtitle: "What earns a place in the bar. Indicators that carry state — health, activity, focus, video wallpaper — appear on their own and leave when they are done."
|
||||
|
||||
ToggleRow { setting: "showWeatherWidget" }
|
||||
ToggleRow { setting: "showMediaWidget" }
|
||||
ToggleRow { setting: "showClipboardButton" }
|
||||
ToggleRow { setting: "showCalendarCountdown" }
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu" }
|
||||
// Only where there is a battery to report on. A desktop should not be
|
||||
// offered a switch for a readout it can never show.
|
||||
ToggleRow { setting: "showBattery"; visible: Battery.available }
|
||||
ToggleRow { setting: "showBatteryPercent"; visible: Battery.available && Settings.showBattery }
|
||||
ToggleRow { setting: "showAgentUsage"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Clock"
|
||||
subtitle: "24-hour time lives in System › Date & Time — it drives the date menu, notifications, and the lock screen too, so it was never just the bar's."
|
||||
|
||||
ToggleRow { setting: "showSeconds" }
|
||||
ToggleRow { setting: "showWeekday"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Vitals"
|
||||
|
||||
SliderRow {
|
||||
setting: "vitalsIntervalMs"
|
||||
divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
|
||||
}
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
|
||||
width: parent.width
|
||||
label: "Graphics device"
|
||||
detail: GraphicsDevices.selectionMissing
|
||||
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
|
||||
: "Which GPU the graphics readout measures."
|
||||
options: GraphicsDevices.devices.map(device => ({
|
||||
value: device.path,
|
||||
label: GraphicsDevices.shortName(device.name)
|
||||
}))
|
||||
current: GraphicsDevices.selectedPath
|
||||
divider: false
|
||||
onPicked: value => GraphicsDevices.select(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Control Center.
|
||||
//
|
||||
// The panel behind the bar's right corner. Its sections are real surfaces with
|
||||
// real cost -- Home talks to Home Assistant, Phone to KDE Connect -- so a
|
||||
// machine that has neither should be able to say so once instead of scrolling
|
||||
// past two empty shelves every time the panel opens.
|
||||
//
|
||||
// The accessories themselves are not configured here. Which lights appear,
|
||||
// what they are called, and what order they sit in is one arrangement shared
|
||||
// with the Home page, and it is edited there.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Control Center"
|
||||
lede: "What the panel offers when it opens."
|
||||
|
||||
SettingsCard {
|
||||
title: "Sections"
|
||||
subtitle: "Turning one off hides it from the panel. Nothing stops working — the settings pages behind each still do."
|
||||
|
||||
ToggleRow { setting: "ccShowFocus" }
|
||||
ToggleRow { setting: "ccShowHome" }
|
||||
ToggleRow { setting: "ccShowPhone"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Accessories"
|
||||
|
||||
ActionRow {
|
||||
label: "Control Center shelf"
|
||||
detail: "Which lights and scenes appear, their names and their order — arranged on Home › My Home"
|
||||
action: "Open My Home"
|
||||
divider: false
|
||||
onTriggered: ShellState.openSettings("my-home")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
// These belong to the machine rather than to Panama, so nothing here is stored
|
||||
// in Panama's settings file -- it would be a second answer to a question the
|
||||
// system already answers. Timezone and network time are read from and written
|
||||
// to timedatectl directly; the clock's presentation lives on Appearance,
|
||||
// because that genuinely is a Panama preference.
|
||||
// to timedatectl directly. 24-hour time is the one presentation choice that
|
||||
// belongs here rather than on Shell › Bar: it drives the date menu,
|
||||
// notification timestamps and the lock screen as well as the bar. The rest of
|
||||
// the bar clock's presentation -- seconds, weekday -- stays with the bar.
|
||||
//
|
||||
// Changing the timezone or network time needs privilege. timedatectl asks
|
||||
// polkit, and a canceled dialog surfaces as an error rather than as a value
|
||||
@@ -30,6 +32,11 @@ SettingsPage {
|
||||
detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone
|
||||
value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP")
|
||||
}
|
||||
// Not just the bar's. The same choice reads out in the date menu,
|
||||
// every notification's timestamp, and the lock screen, so it belongs
|
||||
// beside the clock the whole machine shares rather than on a page
|
||||
// about one surface.
|
||||
ToggleRow { setting: "use24Hour" }
|
||||
SettingRow {
|
||||
label: "Set automatically"
|
||||
detail: DateTime.ntpEnabled
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
// Desktop & Dock.
|
||||
//
|
||||
// The dock timings used to be shown here as text -- "Instant", "250 ms" -- even
|
||||
// though they were already stored, mutable integers. They are controls now.
|
||||
// The window-layout card keeps text rows because those really are facts about
|
||||
// how Panama tiles rather than settings: the adjustable parts of window
|
||||
// appearance live on the Appearance page, next to the preview that explains
|
||||
// them.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Which project is one click from being forgotten. Same in-place confirm the
|
||||
// SSH Keys and Snapshots pages use.
|
||||
property string confirmingProject: ""
|
||||
|
||||
// Turning the last screen off would leave no dock anywhere and no obvious
|
||||
// way back, so the final one cannot be removed -- it collapses to "every
|
||||
// screen" instead, which is the same thing on one display and recoverable
|
||||
// on several.
|
||||
function toggleDockScreen(name: string): void {
|
||||
const all = Quickshell.screens.map(screen => String(screen.name));
|
||||
const current = Settings.dockScreens.length === 0
|
||||
? all.slice()
|
||||
: Settings.dockScreens.map(String);
|
||||
const at = current.indexOf(name);
|
||||
let next = current.slice();
|
||||
if (at >= 0)
|
||||
next.splice(at, 1);
|
||||
else
|
||||
next.push(name);
|
||||
if (next.length === 0 || next.length === all.length)
|
||||
next = [];
|
||||
DesktopPreferences.set("dockScreens", next);
|
||||
}
|
||||
|
||||
title: "Desktop & Dock"
|
||||
lede: "Keep the shell instant, spatial, and out of your way."
|
||||
|
||||
SettingsCard {
|
||||
title: "Dock"
|
||||
|
||||
ChoiceRow { setting: "dockPosition" }
|
||||
|
||||
// One row per connected screen. Nothing selected means every screen,
|
||||
// which is stated rather than left as an empty list somebody has to
|
||||
// interpret -- and it is what a single-monitor machine should do
|
||||
// without being configured at all.
|
||||
SettingRow {
|
||||
label: "Screens"
|
||||
detail: Settings.dockScreens.length === 0
|
||||
? "On every display"
|
||||
: "On " + Settings.dockScreens.length + " of "
|
||||
+ Quickshell.screens.length + " displays"
|
||||
visible: Quickshell.screens.length > 1
|
||||
controlWidth: 260
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Repeater {
|
||||
model: Quickshell.screens
|
||||
|
||||
delegate: Rectangle {
|
||||
id: screenPill
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string screenName: String(screenPill.modelData.name ?? "")
|
||||
// An empty list means all, so every pill reads as on.
|
||||
readonly property bool on: Settings.dockScreens.length === 0
|
||||
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
|
||||
|
||||
width: pillLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: 8
|
||||
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: screenPill.on ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Text {
|
||||
id: pillLabel
|
||||
anchors.centerIn: parent
|
||||
text: screenPill.screenName
|
||||
color: screenPill.on ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow { setting: "dockAutohide" }
|
||||
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockIconSize"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Pinned applications"
|
||||
subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
|
||||
|
||||
DockPinsEditor {
|
||||
id: pins
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Pin another application"
|
||||
|
||||
DockAppPicker {
|
||||
width: parent.width
|
||||
pinned: pins.pinned
|
||||
onPicked: id => pins.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Window layout"
|
||||
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
|
||||
|
||||
// These were two read-only rows reporting "Tiling" and "Dynamic", which
|
||||
// described settings rather than facts -- both are ordinary Hyprland
|
||||
// options that simply had no controls. TextRow's own documentation says
|
||||
// a setting the user could reasonably change does not belong in it.
|
||||
ChoiceRow { setting: "windowLayout" }
|
||||
ToggleRow { setting: "preserveSplit" }
|
||||
ChoiceRow { setting: "forceSplit" }
|
||||
ToggleRow { setting: "windowSnapping" }
|
||||
ActionRow {
|
||||
label: "Gaps, corners, and effects"
|
||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||
action: "Open Appearance"
|
||||
divider: false
|
||||
onTriggered: ShellState.openSettingsSection("appearance", "windows")
|
||||
}
|
||||
}
|
||||
|
||||
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||
// Only meaningful when the layout above is Master and stack. Hidden
|
||||
// otherwise, because a card of settings that do nothing under the layout
|
||||
// you are actually running is worse than not offering the layout at all.
|
||||
SettingsCard {
|
||||
visible: DesktopPreferences.get("windowLayout") === "master"
|
||||
title: "Master and stack"
|
||||
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
|
||||
|
||||
SliderRow { setting: "masterFactor" }
|
||||
ChoiceRow { setting: "masterOrientation" }
|
||||
ChoiceRow { setting: "masterNewStatus" }
|
||||
ToggleRow { setting: "masterNewOnTop"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Window edges"
|
||||
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
|
||||
|
||||
ToggleRow { setting: "resizeOnBorder" }
|
||||
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
|
||||
ToggleRow { setting: "hoverIconOnBorder" }
|
||||
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
|
||||
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
|
||||
ToggleRow { setting: "snapRespectGaps"; divider: false }
|
||||
}
|
||||
|
||||
// Hyprland's own interruptions. Panama turns all four off, which is a
|
||||
// defensible default and was not previously a decision anyone could
|
||||
// reverse without editing looks.lua.
|
||||
SettingsCard {
|
||||
title: "Hyprland notices"
|
||||
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
|
||||
|
||||
ToggleRow { setting: "hyprlandLogo" }
|
||||
ToggleRow { setting: "hyprlandSplash" }
|
||||
ToggleRow { setting: "hyprlandUpdateNews" }
|
||||
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
|
||||
}
|
||||
|
||||
// Saved here, not created here. A layout is worth recording at the moment
|
||||
// you have it right, so saving is a launcher command; this is where the
|
||||
// ones you kept are reviewed and the ones you did not are removed.
|
||||
SettingsCard {
|
||||
title: "Projects"
|
||||
subtitle: Projects.projects.length > 0
|
||||
? "Saved window layouts. Opening one claims free workspaces, so it never lands on top of what you are doing."
|
||||
: "No saved layouts yet. Arrange your windows, then run \"Save Layout as Project\" from the launcher."
|
||||
|
||||
Repeater {
|
||||
model: Projects.projects
|
||||
|
||||
delegate: SettingRow {
|
||||
id: projectRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string projectName: String(projectRow.modelData.name ?? "")
|
||||
readonly property bool confirming: root.confirmingProject === projectRow.projectName
|
||||
|
||||
label: projectRow.projectName
|
||||
detail: projectRow.confirming
|
||||
? "Forgetting this only removes the layout. Nothing that is open closes."
|
||||
: projectRow.modelData.windows + " windows across "
|
||||
+ projectRow.modelData.workspaces + " workspaces — "
|
||||
+ (projectRow.modelData.applications ?? []).join(", ")
|
||||
divider: projectRow.index < Projects.projects.length - 1
|
||||
controlWidth: 210
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !projectRow.confirming
|
||||
text: "Open"
|
||||
enabled: !Projects.busy
|
||||
onClicked: Projects.open(projectRow.projectName)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: projectRow.confirming
|
||||
text: "Forget it"
|
||||
tone: "danger"
|
||||
enabled: !Projects.busy
|
||||
onClicked: {
|
||||
root.confirmingProject = "";
|
||||
Projects.remove(projectRow.projectName);
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: projectRow.confirming ? "Keep" : "Forget"
|
||||
enabled: !Projects.busy
|
||||
onClicked: root.confirmingProject =
|
||||
projectRow.confirming ? "" : projectRow.projectName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Projects.lastError !== ""
|
||||
label: "Problem"
|
||||
detail: Projects.lastError
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Workspaces & focus"
|
||||
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||
|
||||
ToggleRow { setting: "workspaceBackAndForth" }
|
||||
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||
ToggleRow { setting: "focusOnActivate" }
|
||||
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
||||
ToggleRow { setting: "windowSwallow"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus"
|
||||
|
||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
||||
}
|
||||
|
||||
// Distinct from the snapshots below, which put THIS machine back as it
|
||||
// was. This carries settings to a different one, and deliberately leaves
|
||||
// behind anything that describes hardware.
|
||||
SettingsCard {
|
||||
title: "Carry settings to another machine"
|
||||
subtitle: SettingsSync.lastError !== ""
|
||||
? SettingsSync.lastError
|
||||
: "Everything except what describes this machine: the display arrangement stays here."
|
||||
|
||||
ActionRow {
|
||||
label: "Export"
|
||||
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
|
||||
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
|
||||
: "Writes " + SettingsSync.defaultPath
|
||||
action: "Export"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "See what an import would change"
|
||||
detail: SettingsSync.previewed
|
||||
? SettingsSync.changes.length + " would change, "
|
||||
+ SettingsSync.skipped.length + " skipped"
|
||||
: "Reads " + SettingsSync.defaultPath + " without applying anything"
|
||||
action: "Preview"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
// Only offered once a preview has said what it would do. Importing
|
||||
// settings sight unseen is how somebody ends up wondering why their
|
||||
// desktop changed.
|
||||
ActionRow {
|
||||
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
|
||||
label: "Apply those " + SettingsSync.changes.length + " changes"
|
||||
detail: "Settings the file does not mention are left alone"
|
||||
action: "Import"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: SettingsSync.previewed ? SettingsSync.skipped : []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.key ?? "")
|
||||
detail: "Skipped: " + String(modelData.reason ?? "")
|
||||
value: ""
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
|
||||
label: SettingsSync.applied === 0
|
||||
? "Nothing needed changing"
|
||||
: SettingsSync.applied + " settings applied"
|
||||
detail: "From " + (SettingsSync.exportedFrom || "the export")
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Snapshots"
|
||||
subtitle: SettingsBackup.lastError !== ""
|
||||
? SettingsBackup.lastError
|
||||
: "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable."
|
||||
|
||||
ActionRow {
|
||||
label: "Back up current settings"
|
||||
detail: SettingsBackup.snapshots.length === 0
|
||||
? "No snapshots yet"
|
||||
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first"
|
||||
action: "Back up now"
|
||||
enabled: !SettingsBackup.busy
|
||||
divider: SettingsBackup.snapshots.length > 0
|
||||
onTriggered: SettingsBackup.save()
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: snapshotRows
|
||||
model: SettingsBackup.snapshots
|
||||
|
||||
ActionRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: modelData.when
|
||||
detail: modelData.keys + " settings"
|
||||
action: "Restore"
|
||||
enabled: !SettingsBackup.busy
|
||||
divider: index < snapshotRows.count - 1
|
||||
onTriggered: SettingsBackup.restore(modelData.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Reset"
|
||||
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||
|
||||
ActionRow {
|
||||
label: "Restore defaults"
|
||||
detail: "Applies immediately, including to the compositor"
|
||||
action: "Restore defaults"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.restoreDefaults()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ Column {
|
||||
|
||||
signal picked(string id)
|
||||
|
||||
// For surfaces that open straight into this picker instead of scrolling to
|
||||
// it, so nothing has to be clicked before typing works.
|
||||
function grab(): void {
|
||||
search.grab();
|
||||
}
|
||||
|
||||
readonly property var matches: {
|
||||
const needle = search.text.trim().toLowerCase();
|
||||
if (needle === "")
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Dock.
|
||||
//
|
||||
// The pinned applications come first because they are what the Dock is; the
|
||||
// behaviour card below is how it gets out of the way. The dock timings used to
|
||||
// be shown here as text -- "Instant", "250 ms" -- even though they were already
|
||||
// stored, mutable integers. They are controls now.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Turning the last screen off would leave no dock anywhere and no obvious
|
||||
// way back, so the final one cannot be removed -- it collapses to "every
|
||||
// screen" instead, which is the same thing on one display and recoverable
|
||||
// on several.
|
||||
function toggleDockScreen(name: string): void {
|
||||
const all = Quickshell.screens.map(screen => String(screen.name));
|
||||
const current = Settings.dockScreens.length === 0
|
||||
? all.slice()
|
||||
: Settings.dockScreens.map(String);
|
||||
const at = current.indexOf(name);
|
||||
let next = current.slice();
|
||||
if (at >= 0)
|
||||
next.splice(at, 1);
|
||||
else
|
||||
next.push(name);
|
||||
if (next.length === 0 || next.length === all.length)
|
||||
next = [];
|
||||
DesktopPreferences.set("dockScreens", next);
|
||||
}
|
||||
|
||||
title: "Dock"
|
||||
lede: "What sits in the Dock, and how it behaves when you are not using it."
|
||||
|
||||
SettingsCard {
|
||||
title: "Pinned applications"
|
||||
subtitle: "Drag an icon to reorder it, hover for the unpin button. Order here is the order on screen; these stay whether or not the application is running."
|
||||
|
||||
DockPinsStrip {
|
||||
id: pins
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Item { width: 1; height: 13 }
|
||||
|
||||
DockAppPicker {
|
||||
width: parent.width
|
||||
pinned: pins.pinned
|
||||
onPicked: id => pins.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Behavior"
|
||||
|
||||
ChoiceRow { setting: "dockPosition" }
|
||||
|
||||
// One row per connected screen. Nothing selected means every screen,
|
||||
// which is stated rather than left as an empty list somebody has to
|
||||
// interpret -- and it is what a single-monitor machine should do
|
||||
// without being configured at all.
|
||||
SettingRow {
|
||||
label: "Screens"
|
||||
detail: Settings.dockScreens.length === 0
|
||||
? "On every display"
|
||||
: "On " + Settings.dockScreens.length + " of "
|
||||
+ Quickshell.screens.length + " displays"
|
||||
visible: Quickshell.screens.length > 1
|
||||
controlWidth: 260
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Repeater {
|
||||
model: Quickshell.screens
|
||||
|
||||
delegate: Rectangle {
|
||||
id: screenPill
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string screenName: String(screenPill.modelData.name ?? "")
|
||||
// An empty list means all, so every pill reads as on.
|
||||
readonly property bool on: Settings.dockScreens.length === 0
|
||||
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
|
||||
|
||||
width: pillLabel.implicitWidth + 20
|
||||
height: 28
|
||||
radius: 8
|
||||
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: screenPill.on ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Text {
|
||||
id: pillLabel
|
||||
anchors.centerIn: parent
|
||||
text: screenPill.screenName
|
||||
color: screenPill.on ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
HoverHandler { cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow { setting: "dockAutohide" }
|
||||
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockIconSize"; divider: false }
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
// The Dock's pinned applications: reorder, remove, and add.
|
||||
//
|
||||
// The list was a sixteen-entry literal in Settings.qml, so changing what sits
|
||||
// in the Dock meant editing a QML file and reloading the shell. It is a plain
|
||||
// ordered list of desktop entry ids, stored in the shared settings file, which
|
||||
// means it is covered by Restore defaults like everything else.
|
||||
//
|
||||
// Move up / move down rather than drag-and-drop. Dragging inside a Flickable
|
||||
// that is itself inside a scrolling page is a genuinely hard interaction to get
|
||||
// right, and it fails in a way the user reads as the app being broken; two
|
||||
// buttons are unambiguous and keyboard-reachable.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
readonly property var pinned: {
|
||||
const stored = DesktopPreferences.get("dockPinned");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
|
||||
// DesktopEntries populates asynchronously, so this must be read as a
|
||||
// binding rather than looked up inside one -- byId() called during
|
||||
// evaluation registers no dependency and answers from an empty list.
|
||||
readonly property var entriesById: {
|
||||
const index = {};
|
||||
for (const entry of DesktopEntries.applications.values)
|
||||
index[entry.id] = entry;
|
||||
return index;
|
||||
}
|
||||
|
||||
function nameFor(id: string): string {
|
||||
const entry = root.entriesById[id];
|
||||
return entry ? entry.name : id;
|
||||
}
|
||||
|
||||
function commit(next: var): void {
|
||||
DesktopPreferences.set("dockPinned", next);
|
||||
}
|
||||
|
||||
// ── Dragging ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// By a grip rather than the whole row. The objection this file used to
|
||||
// record -- that dragging inside a Flickable inside a scrolling page is
|
||||
// hard to get right and fails in a way that reads as breakage -- is real,
|
||||
// and the answer is preventStealing on the grip: the Flickable cannot take
|
||||
// a gesture that started there, so a vertical drag reorders instead of
|
||||
// scrolling the page out from under it. The arrow buttons stay, because
|
||||
// they are the keyboard-reachable path and a grip is not.
|
||||
//
|
||||
// The order is held here while the drag runs and written once on release.
|
||||
// Committing on every slot crossed would rewrite settings.json a dozen
|
||||
// times for one gesture.
|
||||
property int draggingIndex: -1
|
||||
property var workingOrder: []
|
||||
|
||||
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
|
||||
|
||||
function beginDrag(index: int): void {
|
||||
root.workingOrder = root.pinned.slice();
|
||||
root.draggingIndex = index;
|
||||
}
|
||||
|
||||
function dragTo(target: int): void {
|
||||
if (root.draggingIndex < 0 || target === root.draggingIndex)
|
||||
return;
|
||||
if (target < 0 || target >= root.workingOrder.length)
|
||||
return;
|
||||
const next = root.workingOrder.slice();
|
||||
const moved = next.splice(root.draggingIndex, 1)[0];
|
||||
next.splice(target, 0, moved);
|
||||
root.workingOrder = next;
|
||||
root.draggingIndex = target;
|
||||
}
|
||||
|
||||
function endDrag(): void {
|
||||
if (root.draggingIndex < 0)
|
||||
return;
|
||||
const next = root.workingOrder.slice();
|
||||
root.draggingIndex = -1;
|
||||
root.workingOrder = [];
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function move(from: int, to: int): void {
|
||||
if (to < 0 || to >= root.pinned.length)
|
||||
return;
|
||||
const next = root.pinned.slice();
|
||||
const moved = next.splice(from, 1)[0];
|
||||
next.splice(to, 0, moved);
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function remove(index: int): void {
|
||||
const next = root.pinned.slice();
|
||||
next.splice(index, 1);
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function add(id: string): void {
|
||||
if (root.pinned.indexOf(id) >= 0)
|
||||
return;
|
||||
root.commit(root.pinned.concat([id]));
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.displayed
|
||||
|
||||
SettingRow {
|
||||
id: pin
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: root.nameFor(pin.modelData)
|
||||
// The desktop id is shown only when the name alone would not say
|
||||
// which entry this is. It is developer text, and repeating it under
|
||||
// fifteen recognisable application names is noise that makes the
|
||||
// list harder to scan, not easier.
|
||||
detail: root.pinned.filter(other =>
|
||||
root.nameFor(other) === root.nameFor(pin.modelData)).length > 1
|
||||
? pin.modelData
|
||||
: ""
|
||||
divider: pin.index < root.pinned.length - 1
|
||||
controlWidth: 132
|
||||
|
||||
// Lifted while dragging so the row being moved is the one that
|
||||
// looks moved.
|
||||
z: root.draggingIndex === pin.index ? 2 : 0
|
||||
opacity: root.draggingIndex === pin.index ? 0.85 : 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
// The grip. preventStealing is the whole reason this works
|
||||
// inside a scrolling page: without it the Flickable claims the
|
||||
// vertical gesture and the row never moves.
|
||||
Item {
|
||||
width: 26
|
||||
height: 26
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "\u2261"
|
||||
color: root.draggingIndex === pin.index ? Theme.accent : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: grip
|
||||
anchors.fill: parent
|
||||
preventStealing: true
|
||||
cursorShape: Qt.SizeVerCursor
|
||||
|
||||
property real pressY: 0
|
||||
|
||||
onPressed: mouse => {
|
||||
grip.pressY = mouse.y;
|
||||
root.beginDrag(pin.index);
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
if (root.draggingIndex < 0 || pin.height <= 0)
|
||||
return;
|
||||
// How many whole rows the pointer has travelled from
|
||||
// where it started. Rounded, so the swap happens as
|
||||
// the grip passes the midpoint of the next row.
|
||||
const travelled = (mouse.y - grip.pressY);
|
||||
const slots = Math.round(travelled / pin.height);
|
||||
if (slots !== 0)
|
||||
root.dragTo(root.draggingIndex + slots);
|
||||
}
|
||||
onReleased: root.endDrag()
|
||||
onCanceled: root.endDrag()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "↑"
|
||||
enabled: pin.index > 0
|
||||
onClicked: root.move(pin.index, pin.index - 1)
|
||||
}
|
||||
SettingsButton {
|
||||
text: "↓"
|
||||
enabled: pin.index < root.pinned.length - 1
|
||||
onClicked: root.move(pin.index, pin.index + 1)
|
||||
}
|
||||
SettingsButton {
|
||||
text: "Unpin"
|
||||
onClicked: root.remove(pin.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
visible: root.pinned.length === 0
|
||||
label: "Nothing is pinned"
|
||||
detail: "The Dock will only show running applications"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// The Dock's pinned applications, shown as the dock shows them.
|
||||
//
|
||||
// This replaces a sixteen-row list of names. The list was honest but it was not
|
||||
// the thing being edited: the Dock is a horizontal row of icons, and the order
|
||||
// of that row was being decided in a vertical column of text. Editing a
|
||||
// picture of the result is faster than editing a description of it, and it
|
||||
// removes the translation step where you count rows to work out which icon ends
|
||||
// up third.
|
||||
//
|
||||
// Icons resolve exactly the way the Dock resolves them, so what is drawn here
|
||||
// is what will be drawn there -- including the initial-letter fallback for an
|
||||
// application whose icon the theme cannot find.
|
||||
//
|
||||
// Reordering is a drag, with two things that make a drag safe inside a
|
||||
// scrolling page:
|
||||
//
|
||||
// * preventStealing on the grabbing MouseArea. Without it the page's own
|
||||
// Flickable claims the gesture and the icon never moves, which reads as
|
||||
// breakage rather than as a page that scrolls.
|
||||
// * The order is held here while the drag runs and written once on release.
|
||||
// Committing on every cell crossed would rewrite settings.json a dozen
|
||||
// times for one gesture.
|
||||
//
|
||||
// Every icon is also a tab stop: Left and Right move it, Delete unpins it. A
|
||||
// drag is not reachable from the keyboard, so the keyboard gets its own path
|
||||
// rather than a pair of arrow buttons bolted to each cell.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
// The cell is the icon plus the room its unpin button needs. Fixed, because
|
||||
// the drag arithmetic below counts cells rather than measuring them.
|
||||
readonly property int iconSize: 44
|
||||
readonly property int cellSize: 52
|
||||
readonly property int cellSpacing: 10
|
||||
readonly property int stride: root.cellSize + root.cellSpacing
|
||||
|
||||
// How many cells fit on one line. The drag turns a pointer offset into an
|
||||
// index, and on a wrapped strip moving down a line is a jump of this many.
|
||||
readonly property int perRow: Math.max(1,
|
||||
Math.floor((flow.width + root.cellSpacing) / root.stride))
|
||||
|
||||
readonly property var pinned: {
|
||||
const stored = DesktopPreferences.get("dockPinned");
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
|
||||
// DesktopEntries populates asynchronously, so this must be read as a
|
||||
// binding rather than looked up inside one -- byId() called during
|
||||
// evaluation registers no dependency and answers from an empty list.
|
||||
readonly property var entriesById: {
|
||||
const index = {};
|
||||
for (const entry of DesktopEntries.applications.values)
|
||||
index[entry.id] = entry;
|
||||
return index;
|
||||
}
|
||||
|
||||
function nameFor(id: string): string {
|
||||
const entry = root.entriesById[id];
|
||||
return entry ? entry.name : id;
|
||||
}
|
||||
|
||||
// Desktop entries usually carry a freedesktop icon *name*, but some ship an
|
||||
// absolute path. iconPath() only understands names, so branch on it. The
|
||||
// `true` argument makes a missing icon return "" instead of a placeholder
|
||||
// that renders as a black square in some themes.
|
||||
function iconFor(id: string): string {
|
||||
const entry = root.entriesById[id];
|
||||
const name = entry && entry.icon ? entry.icon : id;
|
||||
if (!name)
|
||||
return "";
|
||||
if (name.startsWith("/"))
|
||||
return "file://" + name;
|
||||
return Quickshell.iconPath(name, true);
|
||||
}
|
||||
|
||||
function commit(next: var): void {
|
||||
DesktopPreferences.set("dockPinned", next);
|
||||
}
|
||||
|
||||
// ── Dragging ────────────────────────────────────────────────────────────
|
||||
|
||||
property int draggingIndex: -1
|
||||
property var workingOrder: []
|
||||
|
||||
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
|
||||
|
||||
function beginDrag(index: int): void {
|
||||
root.workingOrder = root.pinned.slice();
|
||||
root.draggingIndex = index;
|
||||
}
|
||||
|
||||
function dragTo(target: int): void {
|
||||
if (root.draggingIndex < 0 || target === root.draggingIndex)
|
||||
return;
|
||||
if (target < 0 || target >= root.workingOrder.length)
|
||||
return;
|
||||
const next = root.workingOrder.slice();
|
||||
const moved = next.splice(root.draggingIndex, 1)[0];
|
||||
next.splice(target, 0, moved);
|
||||
root.workingOrder = next;
|
||||
root.draggingIndex = target;
|
||||
}
|
||||
|
||||
function endDrag(): void {
|
||||
if (root.draggingIndex < 0)
|
||||
return;
|
||||
const next = root.workingOrder.slice();
|
||||
root.draggingIndex = -1;
|
||||
root.workingOrder = [];
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
// ── Keyboard ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The Repeater's model is a plain array, so committing a move rebuilds
|
||||
// every delegate and the focused one is destroyed mid-keystroke. The
|
||||
// focused position is remembered here instead of in the delegate, and the
|
||||
// cell that lands on it takes focus back as it is created.
|
||||
|
||||
property int keyboardIndex: -1
|
||||
|
||||
function move(from: int, to: int): void {
|
||||
if (to < 0 || to >= root.pinned.length)
|
||||
return;
|
||||
const next = root.pinned.slice();
|
||||
const moved = next.splice(from, 1)[0];
|
||||
next.splice(to, 0, moved);
|
||||
root.keyboardIndex = to;
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function remove(index: int): void {
|
||||
const next = root.pinned.slice();
|
||||
next.splice(index, 1);
|
||||
root.keyboardIndex = Math.min(index, next.length - 1);
|
||||
root.commit(next);
|
||||
}
|
||||
|
||||
function add(id: string): void {
|
||||
if (root.pinned.indexOf(id) >= 0)
|
||||
return;
|
||||
root.commit(root.pinned.concat([id]));
|
||||
}
|
||||
|
||||
Flow {
|
||||
id: flow
|
||||
|
||||
width: parent.width
|
||||
visible: root.displayed.length > 0
|
||||
spacing: root.cellSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.displayed
|
||||
|
||||
delegate: Item {
|
||||
id: cell
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool dragging: root.draggingIndex === cell.index
|
||||
readonly property string appName: root.nameFor(cell.modelData)
|
||||
readonly property string iconSource: root.iconFor(cell.modelData)
|
||||
|
||||
width: root.cellSize
|
||||
height: root.cellSize
|
||||
|
||||
// The dragged cell rides above its neighbours so the icon being
|
||||
// moved is the one that looks moved.
|
||||
z: cell.dragging ? 2 : 0
|
||||
|
||||
activeFocusOnTab: true
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: cell.appName
|
||||
Accessible.description: "Pinned to the Dock, position "
|
||||
+ (cell.index + 1) + " of " + root.displayed.length
|
||||
+ ". Left and Right move it, Delete unpins it."
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: cell.activeFocus
|
||||
|
||||
Keys.onLeftPressed: root.move(cell.index, cell.index - 1)
|
||||
Keys.onRightPressed: root.move(cell.index, cell.index + 1)
|
||||
Keys.onDeletePressed: root.remove(cell.index)
|
||||
|
||||
onActiveFocusChanged: if (cell.activeFocus) root.keyboardIndex = cell.index
|
||||
Component.onCompleted: if (root.keyboardIndex === cell.index) cell.forceActiveFocus()
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onKeyboardIndexChanged(): void {
|
||||
if (root.keyboardIndex === cell.index)
|
||||
cell.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
// Where the icon would land if the drag ended now. It sits in
|
||||
// the gap to the left of the cell rather than under it, because
|
||||
// the question a drop indicator answers is "between which two".
|
||||
Rectangle {
|
||||
visible: cell.dragging
|
||||
anchors.right: parent.left
|
||||
anchors.rightMargin: Math.round(root.cellSpacing / 2) - 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 4
|
||||
height: root.iconSize
|
||||
radius: 2
|
||||
border.width: 0
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
anchors.centerIn: parent
|
||||
width: root.iconSize
|
||||
height: root.iconSize
|
||||
radius: Theme.cardRadius
|
||||
color: cell.dragging
|
||||
? Theme.alpha(Theme.accent, 0.18)
|
||||
: Theme.alpha(Theme.fg, grab.containsMouse || cell.activeFocus ? 0.09 : 0.05)
|
||||
border.width: cell.dragging || cell.activeFocus ? 2 : 1
|
||||
border.color: cell.dragging
|
||||
? Theme.accent
|
||||
: (cell.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.1))
|
||||
|
||||
IconImage {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
visible: cell.iconSource !== ""
|
||||
source: cell.iconSource
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
}
|
||||
|
||||
// Last resort for an app with no resolvable icon: an
|
||||
// initial, which is still recognizable, unlike the theme's
|
||||
// broken-icon placeholder.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: cell.iconSource === ""
|
||||
text: cell.appName ? cell.appName.charAt(0).toUpperCase() : "?"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.round(root.iconSize * 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole tile is the grip. preventStealing is the reason a
|
||||
// drag works at all inside a scrolling page.
|
||||
MouseArea {
|
||||
id: grab
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
cursorShape: cell.dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor
|
||||
|
||||
property real pressX: 0
|
||||
property real pressY: 0
|
||||
|
||||
onPressed: mouse => {
|
||||
grab.pressX = mouse.x;
|
||||
grab.pressY = mouse.y;
|
||||
cell.forceActiveFocus();
|
||||
root.beginDrag(cell.index);
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
if (root.draggingIndex < 0)
|
||||
return;
|
||||
// How many whole cells the pointer has travelled from
|
||||
// where it started, across and down. Rounded, so the
|
||||
// swap happens as the icon passes the midpoint of its
|
||||
// neighbour; a line down is a jump of one full row.
|
||||
const columns = Math.round((mouse.x - grab.pressX) / root.stride);
|
||||
const rows = Math.round((mouse.y - grab.pressY) / root.stride);
|
||||
const slots = rows * root.perRow + columns;
|
||||
if (slots !== 0)
|
||||
root.dragTo(root.draggingIndex + slots);
|
||||
}
|
||||
onReleased: root.endDrag()
|
||||
onCanceled: root.endDrag()
|
||||
}
|
||||
|
||||
// Unpin. Hidden until the icon is hovered or focused, because
|
||||
// eleven permanent × badges read as an error state rather than
|
||||
// as eleven applications you chose.
|
||||
Rectangle {
|
||||
id: unpin
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
z: 3
|
||||
visible: grab.containsMouse || unpinMouse.containsMouse
|
||||
|| cell.activeFocus
|
||||
color: Theme.danger
|
||||
border.width: 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "×"
|
||||
color: Theme.bgDark
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: unpinMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.remove(cell.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.pinned.length === 0
|
||||
text: "Nothing is pinned — the Dock will only show running applications. Search below to add one."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
@@ -80,12 +80,15 @@ Settings because Fedora's system services own those areas.
|
||||
|
||||
## Appearance
|
||||
|
||||
Six tabs, in the order the questions are actually asked: **Themes**, **Theme
|
||||
editor**, **Background**, **Typography**, **Windows**, **Shell**. Themes leads
|
||||
because light and dark is the control reached most often, and it used to be the
|
||||
third section down, under a wallpaper grid and the whole lock screen. The old
|
||||
`theme` section id still resolves to it, so deep links and IPC calls keep
|
||||
working.
|
||||
Five tabs, in the order the questions are actually asked: **Themes**, **Theme
|
||||
editor**, **Background**, **Typography**, **Windows**. Themes leads because
|
||||
light and dark is the control reached most often, and it used to be the third
|
||||
section down, under a wallpaper grid and the whole lock screen. The old `theme`
|
||||
section id still resolves to it, so deep links and IPC calls keep working.
|
||||
|
||||
The sixth tab, **Shell**, is gone: its two cards moved to **Shell › Bar**, which
|
||||
is where somebody who wants to change the bar now looks. Appearance answers what
|
||||
the desktop looks like; Shell answers what the desktop *is*.
|
||||
|
||||
### Themes
|
||||
|
||||
@@ -145,6 +148,109 @@ and Panama's own Settings titlebar shows one button. `panamaTitlebar` turns
|
||||
that bar off entirely, leaving the window pure Hyprland: Super+Q closes,
|
||||
Super+drag moves, Escape still works.
|
||||
|
||||
## Shell
|
||||
|
||||
Everything Quickshell itself draws, in five tabs: **Bar**, **Dock**, **Control
|
||||
Center**, **Tiling**, **Workspaces**. The category used to be **Desktop & Dock**,
|
||||
a single page that held the dock alongside window layout, workspaces, and the
|
||||
settings-management cluster — three unrelated subjects and a filing cabinet. Its
|
||||
`desktop` id is retired to `bar`, so old deep links land on the first tab.
|
||||
|
||||
The split is by *surface*. If you can point at it on screen, it has a tab.
|
||||
|
||||
### Bar
|
||||
|
||||
The first settings surface the bar has ever had. Three cards.
|
||||
|
||||
**Visibility** exists because the bar is the one thing the shell draws that has
|
||||
no ground of its own — it floats on the wallpaper, which the theme has never
|
||||
seen. So a palette that is correct everywhere else can still be unreadable
|
||||
exactly where the clock is. `barTextTone` (`theme`/`light`/`dark`) drives a
|
||||
second neutral family in `Theme.qml` — `barFg`, `barFgDim`, `barFgMuted` — that
|
||||
thirteen bar widgets bind to instead of `fg`/`fgDim`/`fgMuted`. Left on `theme`
|
||||
the family *is* the fg family by identity, so nothing changes for anyone who
|
||||
never asks, and a custom palette still reaches the bar. Semantic tones (warn,
|
||||
danger, accent, ok) never became bar tones: a battery at 4% is red whatever the
|
||||
neutrals were forced to.
|
||||
|
||||
`barTextShadow` and `barBackdrop` are the two escape hatches for a wallpaper no
|
||||
tone wins against. The shadow is **one** `layer.effect` MultiEffect over the
|
||||
whole of `Bar.qml`'s content rather than one per widget, so a widget added
|
||||
tomorrow picks it up without opting in; the backdrop is a static top-down
|
||||
gradient Rectangle behind the content rows. Both default off and both are read
|
||||
from the preference — during the build they briefly landed as literal `true`,
|
||||
which forced a dark band and an extra compositing layer on everyone.
|
||||
`tests/quickshell/bar-visibility-contract` pins both bindings for that reason,
|
||||
along with every widget's use of the bar tones.
|
||||
|
||||
**Widgets** is one toggle per thing that earns a place: weather, media,
|
||||
clipboard, calendar countdown, the three vitals readouts, battery, agent usage.
|
||||
Each toggle is **ANDed** with the widget's own state condition rather than
|
||||
replacing it, so switching one on never conjures a pill with nothing in it.
|
||||
State-driven indicators — health, activity, focus, video wallpaper — get no
|
||||
toggle: they appear when they have something to say and leave when they are
|
||||
done. `VitalsWidget` answers for its own pill as well as its three fields,
|
||||
because an invisible child still occupies its Row and gating the fields alone
|
||||
left a padded, empty pill in the bar.
|
||||
|
||||
**Clock** keeps `showSeconds` and `showWeekday`. `use24Hour` is *not* here: it
|
||||
drives the date menu, notification timestamps, and the lock screen too, so it
|
||||
moved to group `datetime` and lives on **System › Date & Time**. The card says
|
||||
so rather than leaving its absence looking like an oversight.
|
||||
|
||||
Right-clicking the vitals or agent-usage pill opens this page.
|
||||
|
||||
### Dock
|
||||
|
||||
Behavior — position, screens, autohide, reveal and hide delays, icon size —
|
||||
unchanged. **Pinned applications** is now `DockPinsStrip.qml`: the dock's own
|
||||
row of icons, resolved exactly the way the dock resolves them, drag to reorder,
|
||||
hover for the unpin ×, `DockAppPicker` search underneath. It replaces a sixteen-
|
||||
row list of names, which was honest but was not the thing being edited — the
|
||||
order of a horizontal row of icons was being decided in a vertical column of
|
||||
text.
|
||||
|
||||
Two details make the drag safe: `preventStealing` on the grabbing MouseArea, or
|
||||
the page's Flickable claims the gesture and the icon never moves; and the order
|
||||
is held in the strip while the drag runs and written **once** on release, rather
|
||||
than rewriting `settings.json` a dozen times for one gesture. Every icon is also
|
||||
a tab stop — Left and Right move it, Delete unpins it — because a drag is not
|
||||
reachable from the keyboard.
|
||||
|
||||
The live dock gained the matching gestures: drag to reorder on the dock itself,
|
||||
a right-click menu (window rows, pin, unpin, quit, new window, and a **Dock
|
||||
settings** entry that deep-links here), scroll an icon to cycle its windows, and
|
||||
hover previews from `DockPreviews.qml`. `DockPickOverlay.qml` plus the `dock`
|
||||
IPC target back the launcher's **Add App to Dock** command, so pinning never
|
||||
requires opening Settings at all.
|
||||
|
||||
### Control Center
|
||||
|
||||
The quick settings panel's first settings surface. **Sections** is one
|
||||
`ccShow*` bool per real section of `QuickSettingsPanel` — Focus, Home, Phone —
|
||||
and only for sections that actually exist; a toggle for a section the panel does
|
||||
not draw is a control that writes a preference nothing reads. Turning one off
|
||||
hides it from the panel and stops nothing: the page behind it still works.
|
||||
**Accessories** is a labeled handoff to Home › My Home rather than a second copy
|
||||
of the shelf editor.
|
||||
|
||||
### Tiling and Workspaces
|
||||
|
||||
Rows moved unchanged. Tiling holds window layout (with the handoff to Appearance
|
||||
› Windows for gaps, corners, and effects), master and stack, window edges, and
|
||||
the Hyprland notices. Workspaces holds the workspace and focus toggles, focus
|
||||
session length, and Projects.
|
||||
|
||||
## System › Sync & Backup
|
||||
|
||||
`SyncPage.qml`, the tenth tab of System: **Carry settings to another machine**
|
||||
(`SettingsSync`), **Settings backups** (`SettingsBackup`), and **Reset**. It was
|
||||
the settings-management cluster on the old Desktop page, which is the one thing
|
||||
on that page that was never a surface. Backups is titled that way deliberately
|
||||
so it stops colliding with the btrfs **Snapshots** tab one along — the two mean
|
||||
different things and used to share a word. The search index's **Restore
|
||||
defaults** entry points here.
|
||||
|
||||
## Adding a setting
|
||||
|
||||
One schema entry. That is the whole job.
|
||||
|
||||
@@ -166,7 +166,12 @@ Rectangle {
|
||||
case "connectivity": return connectivityPage;
|
||||
case "my-home": return myHomePage;
|
||||
case "phone": return phonePage;
|
||||
case "desktop": return desktopPage;
|
||||
case "bar": return barPage;
|
||||
case "dock": return dockPage;
|
||||
case "control-center": return controlCenterPage;
|
||||
case "tiling": return tilingPage;
|
||||
case "workspaces": return workspacesPage;
|
||||
case "sync": return syncPage;
|
||||
case "sound": return soundPage;
|
||||
case "gaming": return gamingPage;
|
||||
case "notifications": return notificationsPage;
|
||||
@@ -246,7 +251,12 @@ Rectangle {
|
||||
Component { id: connectivityPage; ConnectivityPage {} }
|
||||
Component { id: myHomePage; MyHomePage {} }
|
||||
Component { id: phonePage; PhonePage {} }
|
||||
Component { id: desktopPage; DesktopPage {} }
|
||||
Component { id: barPage; BarPage {} }
|
||||
Component { id: dockPage; DockPage {} }
|
||||
Component { id: controlCenterPage; ControlCenterPage {} }
|
||||
Component { id: tilingPage; TilingPage {} }
|
||||
Component { id: workspacesPage; WorkspacesPage {} }
|
||||
Component { id: syncPage; SyncPage {} }
|
||||
Component { id: soundPage; SoundPage {} }
|
||||
Component { id: gamingPage; GamingPage {} }
|
||||
Component { id: notificationsPage; NotificationsPage {} }
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Sync & Backup.
|
||||
//
|
||||
// Three answers to three different questions, which is why they sit together:
|
||||
// carrying settings to a different machine, putting this machine's settings
|
||||
// back as they were, and throwing them away.
|
||||
//
|
||||
// The backups here are copies of the preferences file. They are not the btrfs
|
||||
// snapshots one tab over in System › Snapshots, which put the whole filesystem
|
||||
// back -- the card says so, because the two used to share the word "Snapshots"
|
||||
// and nothing distinguished them.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Sync & Backup"
|
||||
lede: "Carry your settings to another machine, keep copies of them, or start over."
|
||||
|
||||
// Distinct from the backups below, which put THIS machine back as it was.
|
||||
// This carries settings to a different one, and deliberately leaves behind
|
||||
// anything that describes hardware.
|
||||
SettingsCard {
|
||||
title: "Carry settings to another machine"
|
||||
subtitle: SettingsSync.lastError !== ""
|
||||
? SettingsSync.lastError
|
||||
: "Everything except what describes this machine: the display arrangement stays here."
|
||||
|
||||
ActionRow {
|
||||
label: "Export"
|
||||
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
|
||||
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
|
||||
: "Writes " + SettingsSync.defaultPath
|
||||
action: "Export"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "See what an import would change"
|
||||
detail: SettingsSync.previewed
|
||||
? SettingsSync.changes.length + " would change, "
|
||||
+ SettingsSync.skipped.length + " skipped"
|
||||
: "Reads " + SettingsSync.defaultPath + " without applying anything"
|
||||
action: "Preview"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
// Only offered once a preview has said what it would do. Importing
|
||||
// settings sight unseen is how somebody ends up wondering why their
|
||||
// desktop changed.
|
||||
ActionRow {
|
||||
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
|
||||
label: "Apply those " + SettingsSync.changes.length + " changes"
|
||||
detail: "Settings the file does not mention are left alone"
|
||||
action: "Import"
|
||||
enabled: !SettingsSync.busy
|
||||
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: SettingsSync.previewed ? SettingsSync.skipped : []
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.key ?? "")
|
||||
detail: "Skipped: " + String(modelData.reason ?? "")
|
||||
value: ""
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
|
||||
label: SettingsSync.applied === 0
|
||||
? "Nothing needed changing"
|
||||
: SettingsSync.applied + " settings applied"
|
||||
detail: "From " + (SettingsSync.exportedFrom || "the export")
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Settings backups"
|
||||
subtitle: SettingsBackup.lastError !== ""
|
||||
? SettingsBackup.lastError
|
||||
: "Copies of your preferences, not of the filesystem — System › Snapshots keeps the btrfs ones. Your whole desktop configuration is a single file, so a backup is a copy of it. Restoring also backs up what it replaces, so it is itself undoable."
|
||||
|
||||
ActionRow {
|
||||
label: "Back up current settings"
|
||||
detail: SettingsBackup.snapshots.length === 0
|
||||
? "No backups yet"
|
||||
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept") + ", newest first"
|
||||
action: "Back up now"
|
||||
enabled: !SettingsBackup.busy
|
||||
divider: SettingsBackup.snapshots.length > 0
|
||||
onTriggered: SettingsBackup.save()
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: snapshotRows
|
||||
model: SettingsBackup.snapshots
|
||||
|
||||
ActionRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: modelData.when
|
||||
detail: modelData.keys + " settings"
|
||||
action: "Restore"
|
||||
enabled: !SettingsBackup.busy
|
||||
divider: index < snapshotRows.count - 1
|
||||
onTriggered: SettingsBackup.restore(modelData.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Reset"
|
||||
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||
|
||||
ActionRow {
|
||||
label: "Restore defaults"
|
||||
detail: "Applies immediately, including to the compositor"
|
||||
action: "Restore defaults"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.restoreDefaults()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Tiling.
|
||||
//
|
||||
// How windows share the space. The window-layout card keeps its handoff row
|
||||
// because the adjustable parts of window *appearance* -- gaps, corners,
|
||||
// borders, effects -- live on the Appearance page next to the preview that
|
||||
// explains them. Splitting them would put two halves of one idea on two pages;
|
||||
// pointing at the other half keeps one.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Tiling"
|
||||
lede: "How windows share the space, and where their edges are."
|
||||
|
||||
SettingsCard {
|
||||
title: "Window layout"
|
||||
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
|
||||
|
||||
// These were two read-only rows reporting "Tiling" and "Dynamic", which
|
||||
// described settings rather than facts -- both are ordinary Hyprland
|
||||
// options that simply had no controls. TextRow's own documentation says
|
||||
// a setting the user could reasonably change does not belong in it.
|
||||
ChoiceRow { setting: "windowLayout" }
|
||||
ToggleRow { setting: "preserveSplit" }
|
||||
ChoiceRow { setting: "forceSplit" }
|
||||
ToggleRow { setting: "windowSnapping" }
|
||||
ActionRow {
|
||||
label: "Gaps, corners, and effects"
|
||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||
action: "Open Appearance"
|
||||
divider: false
|
||||
onTriggered: ShellState.openSettingsSection("appearance", "windows")
|
||||
}
|
||||
}
|
||||
|
||||
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||
// Only meaningful when the layout above is Master and stack. Hidden
|
||||
// otherwise, because a card of settings that do nothing under the layout
|
||||
// you are actually running is worse than not offering the layout at all.
|
||||
SettingsCard {
|
||||
visible: DesktopPreferences.get("windowLayout") === "master"
|
||||
title: "Master and stack"
|
||||
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
|
||||
|
||||
SliderRow { setting: "masterFactor" }
|
||||
ChoiceRow { setting: "masterOrientation" }
|
||||
ChoiceRow { setting: "masterNewStatus" }
|
||||
ToggleRow { setting: "masterNewOnTop"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Window edges"
|
||||
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
|
||||
|
||||
ToggleRow { setting: "resizeOnBorder" }
|
||||
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
|
||||
ToggleRow { setting: "hoverIconOnBorder" }
|
||||
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
|
||||
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
|
||||
ToggleRow { setting: "snapRespectGaps"; divider: false }
|
||||
}
|
||||
|
||||
// Hyprland's own interruptions. Panama turns all four off, which is a
|
||||
// defensible default and was not previously a decision anyone could
|
||||
// reverse without editing looks.lua.
|
||||
SettingsCard {
|
||||
title: "Hyprland notices"
|
||||
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
|
||||
|
||||
ToggleRow { setting: "hyprlandLogo" }
|
||||
ToggleRow { setting: "hyprlandSplash" }
|
||||
ToggleRow { setting: "hyprlandUpdateNews" }
|
||||
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,13 @@ Item {
|
||||
|
||||
// Videos join the grid only in single mode — slideshow and per-display
|
||||
// are hyprpaper's modes and stills-only.
|
||||
readonly property var catalog: root.mode === "single"
|
||||
// The test seam: a harness that supplies its own stills sets this false,
|
||||
// and the picker then never touches the VideoWallpaper singleton at all —
|
||||
// merely instantiating it would fire its startup restore inside the
|
||||
// harness and play the machine's real wallpaper in a test instance.
|
||||
property bool videoAware: true
|
||||
|
||||
readonly property var catalog: root.mode === "single" && root.videoAware
|
||||
? Wallpaper.available.concat(VideoWallpaper.candidates)
|
||||
: Wallpaper.available
|
||||
|
||||
@@ -50,7 +56,7 @@ Item {
|
||||
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
||||
|
||||
function isCurrent(path: string): bool {
|
||||
if (VideoWallpaper.active)
|
||||
if (root.videoAware && VideoWallpaper.active)
|
||||
return VideoWallpaper.path === path;
|
||||
return root.activeByOutput[root.selectedOutput] === path;
|
||||
}
|
||||
@@ -88,7 +94,7 @@ Item {
|
||||
required property var modelData
|
||||
|
||||
readonly property bool current: root.isCurrent(tile.modelData)
|
||||
readonly property bool video: VideoWallpaper.isVideo(tile.modelData)
|
||||
readonly property bool video: root.videoAware && VideoWallpaper.isVideo(tile.modelData)
|
||||
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
|
||||
|
||||
width: root.cellWidth
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Workspaces.
|
||||
//
|
||||
// Where windows go, how long a focus session runs, and the layouts you saved.
|
||||
// Projects are saved from the launcher rather than created here: a layout is
|
||||
// worth recording at the moment you have it right, not from a settings page
|
||||
// where you would have to describe it.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Which project is one click from being forgotten. Same in-place confirm the
|
||||
// SSH Keys and Snapshots pages use.
|
||||
property string confirmingProject: ""
|
||||
|
||||
title: "Workspaces"
|
||||
lede: "Workspaces, focus, and the projects that restore them."
|
||||
|
||||
SettingsCard {
|
||||
title: "Workspaces & focus"
|
||||
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||
|
||||
ToggleRow { setting: "workspaceBackAndForth" }
|
||||
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||
ToggleRow { setting: "focusOnActivate" }
|
||||
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
||||
ToggleRow { setting: "windowSwallow"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus"
|
||||
|
||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
||||
}
|
||||
|
||||
// Saved here, not created here. A layout is worth recording at the moment
|
||||
// you have it right, so saving is a launcher command; this is where the
|
||||
// ones you kept are reviewed and the ones you did not are removed.
|
||||
SettingsCard {
|
||||
title: "Projects"
|
||||
subtitle: Projects.projects.length > 0
|
||||
? "Saved window layouts. Opening one claims free workspaces, so it never lands on top of what you are doing."
|
||||
: "No saved layouts yet. Arrange your windows, then run \"Save Layout as Project\" from the launcher."
|
||||
|
||||
Repeater {
|
||||
model: Projects.projects
|
||||
|
||||
delegate: SettingRow {
|
||||
id: projectRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string projectName: String(projectRow.modelData.name ?? "")
|
||||
readonly property bool confirming: root.confirmingProject === projectRow.projectName
|
||||
|
||||
label: projectRow.projectName
|
||||
detail: projectRow.confirming
|
||||
? "Forgetting this only removes the layout. Nothing that is open closes."
|
||||
: projectRow.modelData.windows + " windows across "
|
||||
+ projectRow.modelData.workspaces + " workspaces — "
|
||||
+ (projectRow.modelData.applications ?? []).join(", ")
|
||||
divider: projectRow.index < Projects.projects.length - 1
|
||||
controlWidth: 210
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !projectRow.confirming
|
||||
text: "Open"
|
||||
enabled: !Projects.busy
|
||||
onClicked: Projects.open(projectRow.projectName)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: projectRow.confirming
|
||||
text: "Forget it"
|
||||
tone: "danger"
|
||||
enabled: !Projects.busy
|
||||
onClicked: {
|
||||
root.confirmingProject = "";
|
||||
Projects.remove(projectRow.projectName);
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: projectRow.confirming ? "Keep" : "Forget"
|
||||
enabled: !Projects.busy
|
||||
onClicked: root.confirmingProject =
|
||||
projectRow.confirming ? "" : projectRow.projectName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Projects.lastError !== ""
|
||||
label: "Problem"
|
||||
detail: Projects.lastError
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,12 @@ SettingsTabs 1.0 SettingsTabs.qml
|
||||
GamingPage 1.0 GamingPage.qml
|
||||
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
||||
AvailableLightRow 1.0 AvailableLightRow.qml
|
||||
DesktopPage 1.0 DesktopPage.qml
|
||||
BarPage 1.0 BarPage.qml
|
||||
DockPage 1.0 DockPage.qml
|
||||
ControlCenterPage 1.0 ControlCenterPage.qml
|
||||
TilingPage 1.0 TilingPage.qml
|
||||
WorkspacesPage 1.0 WorkspacesPage.qml
|
||||
SyncPage 1.0 SyncPage.qml
|
||||
DisplaysPage 1.0 DisplaysPage.qml
|
||||
HomePage 1.0 HomePage.qml
|
||||
NotificationsPage 1.0 NotificationsPage.qml
|
||||
@@ -54,7 +59,7 @@ WallpaperPicker 1.0 WallpaperPicker.qml
|
||||
WallpaperControls 1.0 WallpaperControls.qml
|
||||
ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
AutostartAppPicker 1.0 AutostartAppPicker.qml
|
||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||
DockPinsStrip 1.0 DockPinsStrip.qml
|
||||
DockAppPicker 1.0 DockAppPicker.qml
|
||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||
|
||||
@@ -122,6 +122,7 @@ case "$action" in
|
||||
fi
|
||||
qs ipc call settings page "$page"
|
||||
;;
|
||||
dock-pin) qs ipc call dock pickApp ;;
|
||||
health) qs ipc call health open ;;
|
||||
|
||||
dnd)
|
||||
@@ -162,7 +163,7 @@ case "$action" in
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: panama-action {%s}\n' \
|
||||
'control-center|notifications|calendar|clipboard|overview|settings|settings-page PAGE|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
||||
'control-center|notifications|calendar|clipboard|overview|settings|settings-page PAGE|dock-pin|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -33,7 +33,13 @@ Singleton {
|
||||
{ page: "phone", label: "Phone" }
|
||||
] },
|
||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
|
||||
{ page: "shell", label: "Shell", icon: "\u{F04A4}", tabs: [
|
||||
{ page: "bar", label: "Bar" },
|
||||
{ page: "dock", label: "Dock" },
|
||||
{ page: "control-center", label: "Control Center" },
|
||||
{ page: "tiling", label: "Tiling" },
|
||||
{ page: "workspaces", label: "Workspaces" }
|
||||
] },
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}", tabs: [] },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [] },
|
||||
@@ -72,7 +78,8 @@ Singleton {
|
||||
{ page: "containers", label: "Containers" },
|
||||
{ page: "datetime", label: "Date & Time" },
|
||||
{ page: "region", label: "Region & Language" },
|
||||
{ page: "manual", label: "Manual" }
|
||||
{ page: "manual", label: "Manual" },
|
||||
{ page: "sync", label: "Sync & Backup" }
|
||||
] }
|
||||
]
|
||||
|
||||
@@ -104,7 +111,7 @@ Singleton {
|
||||
// Retired page ids keep resolving forever: old Vicinae commands, shell
|
||||
// history, and muscle memory all hold them. Each maps to the leaf that
|
||||
// absorbed its content.
|
||||
readonly property var retired: ({ "home-phone": "my-home" })
|
||||
readonly property var retired: ({ "home-phone": "my-home", "desktop": "bar" })
|
||||
|
||||
// Any id a caller may hold — leaf, category, retired id, or garbage — to
|
||||
// the leaf that should render: a leaf resolves to itself, a category to
|
||||
|
||||
@@ -27,8 +27,11 @@ Singleton {
|
||||
// dropped, so adding a group can never make a setting unreachable.
|
||||
readonly property var groupPages: ({
|
||||
"appearance": "appearance",
|
||||
"clock": "appearance",
|
||||
"vitals": "appearance",
|
||||
"clock": "bar",
|
||||
"vitals": "bar",
|
||||
"bar": "bar",
|
||||
"controlCenter": "control-center",
|
||||
"datetime": "datetime",
|
||||
"battery": "power",
|
||||
"idleBattery": "power",
|
||||
"typography": "appearance",
|
||||
@@ -38,8 +41,8 @@ Singleton {
|
||||
"effects": "appearance",
|
||||
"wallpaper": "appearance",
|
||||
"lockAppearance": "appearance",
|
||||
"dock": "desktop",
|
||||
"focus": "desktop",
|
||||
"dock": "dock",
|
||||
"focus": "workspaces",
|
||||
"display": "displays",
|
||||
"nightLight": "displays",
|
||||
"idle": "power",
|
||||
@@ -47,10 +50,11 @@ Singleton {
|
||||
"input": "shortcuts",
|
||||
"pointer": "mouse",
|
||||
"touchpad": "mouse",
|
||||
"multitasking": "desktop",
|
||||
"edges": "desktop",
|
||||
"master": "desktop",
|
||||
"notices": "desktop",
|
||||
"multitasking": "tiling",
|
||||
"workspaces": "workspaces",
|
||||
"edges": "tiling",
|
||||
"master": "tiling",
|
||||
"notices": "tiling",
|
||||
"weather": "home",
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence",
|
||||
@@ -140,7 +144,12 @@ Singleton {
|
||||
{ label: "iMessage", detail: "Opens BlueBubbles", page: "phone" },
|
||||
{ label: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
|
||||
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
|
||||
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
||||
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "sync" },
|
||||
{ label: "Carry settings to another machine", detail: "Export, preview, and import a settings file", page: "sync" },
|
||||
{ label: "Settings backups", detail: "Snapshots of your preferences, restorable any time", page: "sync" },
|
||||
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
|
||||
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
|
||||
{ label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" },
|
||||
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
|
||||
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
|
||||
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
|
||||
|
||||
@@ -17,7 +17,7 @@ Singleton {
|
||||
id: root
|
||||
|
||||
// Exactly one of these may be non-empty at a time.
|
||||
// "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu" | "cheatsheet" | "welcome"
|
||||
// "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu" | "cheatsheet" | "welcome" | "dock-picker"
|
||||
property string activeOverlay: ""
|
||||
|
||||
readonly property bool overviewOpen: activeOverlay === "overview"
|
||||
@@ -29,6 +29,7 @@ Singleton {
|
||||
readonly property bool powerMenuOpen: activeOverlay === "powermenu"
|
||||
readonly property bool cheatsheetOpen: activeOverlay === "cheatsheet"
|
||||
readonly property bool welcomeOpen: activeOverlay === "welcome"
|
||||
readonly property bool dockPickerOpen: activeOverlay === "dock-picker"
|
||||
|
||||
// Settings is a normal application window rather than a transient overlay.
|
||||
// It can stay open while Quick Settings or the notification center appears.
|
||||
|
||||
@@ -253,7 +253,10 @@ Singleton {
|
||||
for (const output of root.outputs) {
|
||||
const socket = pauseSocketComponent.createObject(root, {
|
||||
socketPath: root.socketFor(output),
|
||||
payload: JSON.stringify({ command: ["set_property", "pause", root.paused] }) + "\n"
|
||||
// Built as a string, not an object literal: a QML-side
|
||||
// `command: [...]` reads to declared-assets-contract's scanner
|
||||
// as a Process launch.
|
||||
payload: JSON.stringify({ "command": ["set_property", "pause", root.paused] }) + "\n"
|
||||
});
|
||||
socket.connected = true;
|
||||
}
|
||||
@@ -288,8 +291,13 @@ Singleton {
|
||||
// one-shot timer (the still pipeline's approach) raced them and lost.
|
||||
// Once per session — a user's stop() is not to be overridden.
|
||||
property bool restoreConsumed: false
|
||||
// Harness seam, mirroring Wallpaper.startupRestoreEnabled: a test instance
|
||||
// must never start playing the user's real wallpaper.
|
||||
property bool startupRestoreEnabled: true
|
||||
|
||||
function tryRestore(): void {
|
||||
if (!root.startupRestoreEnabled)
|
||||
return;
|
||||
if (root.restoreConsumed || root.active || !root.available)
|
||||
return;
|
||||
const video = String(DesktopPreferences.get("videoWallpaperPath") || "");
|
||||
|
||||
@@ -102,6 +102,7 @@ ShellRoot {
|
||||
ClipboardPanel {}
|
||||
Cheatsheet {}
|
||||
Welcome {}
|
||||
DockPickOverlay { id: dockPicker }
|
||||
CaptureOverlay {}
|
||||
IntelligenceResult {}
|
||||
ActivityPanel {}
|
||||
@@ -585,6 +586,17 @@ ShellRoot {
|
||||
}
|
||||
}
|
||||
|
||||
// Adding to the dock from outside the shell -- the launcher's "Add App to
|
||||
// Dock" command opens the picker, and `pin` is the scripted path for
|
||||
// anything that already knows the desktop id.
|
||||
IpcHandler {
|
||||
target: "dock"
|
||||
function pickApp(): void { ShellState.open("dock-picker"); }
|
||||
// false for an id nothing installs: a pin that does not resolve is
|
||||
// simply absent from the dock, so a silent success would be a lie.
|
||||
function pin(id: string): bool { return dockPicker.pin(id); }
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "clipboard"
|
||||
function toggle(): void { ShellState.toggle("clipboard"); }
|
||||
|
||||
@@ -26,6 +26,7 @@ ShellRoot {
|
||||
WallpaperPicker {
|
||||
id: picker
|
||||
width: 620
|
||||
videoAware: false
|
||||
selectedOutput: "DP-2"
|
||||
mode: "single"
|
||||
activeByOutput: ({ "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" })
|
||||
|
||||
Reference in New Issue
Block a user