This commit is contained in:
2026-09-18 20:39:06 +02:00
parent b3c3cc38f0
commit f7b12bc7cd
24 changed files with 1474 additions and 240 deletions
+11 -6
View File
@@ -99,16 +99,21 @@ on it, hence the index loops in `HyprChromeShell`.
grid) shared by the rail and the polkit prompt; `Widgets/Bar/` holds the rail
and its panels, with `Bar/Panels/BarPanel.qml` the chamfered chrome they all
extend; `Widgets/Polkit/` is the authentication agent and its dialog;
`Widgets/Launcher/` is the primary application launcher (`SUPER_L`);
`Theme/Theme.qml` is this tree's palette singleton. `DebugWindow.qml` stages a
single widget on the secondary monitor for eyeballing it in isolation.
The prompt and the launcher are MODALS: each raises the shared scrim, lands
on the focused monitor, and takes the keyboard off the rail. That is why the
shell instantiates them rather than `shell.qml` — see `modalOpen` there, which
is the one place a new modal has to be named.
- `widgets/bar/``DenseBar` and `StatusBarPanel`, the rail's predecessor. Not
instantiated by `shell.qml` any more; `StatusBarPanel` is still used by the
launchers.
- `widgets/launcher/` — shared `AppModel` search/execution plus eleven launcher
variants. `ApplicationLauncher` (variant 8, and the primary `SUPER` launcher)
keeps its visual core in the headlessly renderable
`ApplicationLauncherContent`; the rest are available on `SUPER CTRL 111` for
comparison.
remaining launcher variants.
- `widgets/launcher/` — shared `AppModel` search/execution plus the ten launcher
variants still under evaluation, on `SUPER CTRL 111`. Variant 8 has moved to
`HyprChrome/Widgets/Launcher/`; `AppModel.qml` is duplicated there so the
HyprChrome tree stands alone, and this copy goes when the variants do.
- `widgets/decoration/` — reusable QtQuick `Shape`-based visual accents (angled panel edges, slashes) used to give bar panels their non-rectangular look. `Dummy.qml` is a placeholder/test rectangle.
- `widgets/input/` — thin wrappers around `QtQuick.Controls` inputs (currently just `TextField`).
- `widgets/layout/``HorizontalStack`/`VerticalStack`: `RowLayout`/`ColumnLayout` wrappers that expose `default property alias content` for terser call sites, with a trailing filler `Item` that soaks up remaining space.
@@ -0,0 +1,182 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.HyprChrome.Theme
import qs.HyprChrome.Widgets.Polkit
// GUI password prompt for `sudo -A`, reusing the polkit dialog.
//
// sudo does NOT speak polkit — it is setuid + PAM reading your tty, and no
// sudoers option bridges the two. What it does support is an ASKPASS helper: a
// program it runs to obtain the password, which prints it on stdout. So this is
// not the polkit agent serving sudo; it is a second, separate path that happens
// to render the same dialog.
//
// Flow, driven by the helper in home.nix (`qs-askpass`):
//
// sudo -A
// -> qs-askpass makes a 0600 fifo under XDG_RUNTIME_DIR
// -> qs ipc call askpass prompt "<prompt>" "<fifo>" (returns at once)
// -> this dialog opens, user types
// -> a one-line writer is started here, secret written to its STDIN
// -> qs-askpass reads the fifo and prints the secret on stdout
// -> sudo reads it
//
// The secret travels on a pipe the whole way. It is never an argument and never
// an environment variable, so it does not appear in /proc for any process — the
// fifo PATH is in argv, which is not secret. It does cross more process
// boundaries than the polkit path, where the password stays inside the PAM
// conversation; that is the inherent cost of askpass, not of this design.
//
// Cancelling answers with an empty line, so the helper reads nothing, exits
// non-zero, and sudo aborts rather than burning a retry on a blank password.
Scope {
id: root
// Which output to appear on; the shell puts it on the focused monitor.
property var screen: null
// The fifo the helper is blocked reading. Non-empty means a request is in
// flight, which is exactly what "a prompt is open" means here.
property string fifoPath: ""
property string promptText: ""
property bool failed: false
readonly property bool active: root.fifoPath !== ""
// Held only between submit and the writer process actually starting: a
// Process cannot be written to before it is running.
property string pendingSecret: ""
IpcHandler {
target: "askpass"
// Called by qs-askpass. Returns immediately — the helper blocks on the
// fifo, not on this call, because an IpcHandler function runs on the
// QML thread and blocking here would freeze the whole shell.
function prompt(message: string, fifo: string): string {
if (root.active)
return "busy";
root.promptText = message === "" ? "Password:" : message;
root.fifoPath = fifo;
root.failed = false;
return "ok";
}
// So a helper that times out can take the dialog down with it rather
// than leaving it on screen with nothing listening.
function cancel(): string {
root.dismiss();
return "ok";
}
}
// Cancelling answers with an EMPTY line rather than by closing silently:
// the helper then reads zero bytes and exits non-zero, so sudo aborts
// instead of spending a retry on a blank password.
function dismiss() {
root.respond("");
}
function submit(secret) {
root.respond(secret);
}
// The writer reads ONE LINE and exits; it does not wait for EOF.
//
// The obvious version — `cat > fifo`, write the secret, then close stdin by
// setting stdinEnabled false — does not terminate. Measured: the secret
// arrives intact but `cat` never sees EOF, so the fifo is never closed and
// the helper blocks until its timeout. sudo would hang after you typed.
//
// A single `read` needs no EOF at all: the trailing newline ends it, the
// shell writes what it got and exits, and THAT close is what gives the
// helper its EOF. `IFS=` keeps leading and trailing whitespace, `-r` keeps
// backslashes, and the secret still travels on stdin rather than in argv.
function respond(secret) {
if (!root.active)
return;
root.pendingSecret = secret + "\n";
writer.command = ["sh", "-c", "IFS= read -r line; printf %s \"$line\" > \"$1\"", "sh", root.fifoPath];
writer.running = true;
root.fifoPath = "";
}
// Opening a fifo for writing BLOCKS until a reader attaches, which is why
// this is a subprocess rather than a FileView: the helper's `cat` is that
// reader, and blocking the QML thread on it would freeze the shell.
Process {
id: writer
stdinEnabled: true
// Written on `started`, not at respond() time: a Process has no stdin
// to write to until it is actually running.
onStarted: {
writer.write(root.pendingSecret);
root.pendingSecret = "";
}
}
PanelWindow {
id: win
screen: root.screen
visible: root.active
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
color: Theme.textAlpha(0)
anchors {
top: true
left: true
right: true
bottom: true
}
// No click-off dismissal, for the same reason the polkit dialog has
// none: something is blocked waiting on the answer, and losing it to a
// stray click would leave sudo hanging with no visible cause.
PolkitPromptContent {
id: content
anchors.horizontalCenter: parent.horizontalCenter
y: Math.max(32, Math.round(parent.height / 3 - height / 2))
width: 520
// Deliberately the polkit dialog's own content component: this is a
// password prompt with the same shape, and keeping one means a
// restyle of PolkitPanel covers both. `identities` stays empty —
// sudo offers no choice of who authenticates — which hides the
// picker and the "AS" line on its own.
message: "Authentication is required to run a command as another user"
actionId: "sudo"
iconName: ""
showIcon: false
identities: []
responseRequired: true
inputPrompt: root.promptText
responseVisible: false
failed: root.failed
onSubmitted: value => root.submit(value)
onCancelled: root.dismiss()
}
onVisibleChanged: {
if (win.visible) {
content.clearResponse();
content.focusInput();
}
}
}
}
@@ -6,6 +6,8 @@ import Quickshell.Wayland
import qs.HyprChrome.Widgets.Bar
import qs.HyprChrome.Widgets
import qs.HyprChrome.Widgets.Polkit
import qs.HyprChrome.Widgets.Launcher
import qs.HyprChrome.Widgets.Askpass
// The hyprchrome shell: owns everything the rail's surfaces have to agree on,
// and instantiates them.
@@ -17,10 +19,11 @@ import qs.HyprChrome.Widgets.Polkit
// * which monitor the shell lives on — every surface has to pick the same one
// * the density — the whole rail expands and collapses as one, so the toggle
// and the shortcut that drives it belong to the shell, not to the bar
// * whether an authorization prompt is up — it raises the same scrim the rail
// uses and freezes the density while it is open, so two surfaces read it.
// That is why the agent lives here rather than as a sibling of the
// launchers in shell.qml
// * whether a MODAL is open — the polkit prompt, the launcher, or the sudo
// askpass dialog. Each raises the same scrim the rail uses, freezes the
// density, lands on the focused monitor and takes the keyboard off the
// rail, so several surfaces read it. That is why they live here rather than
// as siblings of the remaining launcher variants in shell.qml
// * the layer PAIR — the backdrop must sit exactly one layer below the bar in
// both densities. Two surfaces on the same layer stack by creation order,
// which is not something to rely on; one layer apart is a guarantee. Split
@@ -96,24 +99,30 @@ Scope {
// Frozen while a prompt is up, and dropped rather than queued: SUPER A
// during a prompt does nothing at all, instead of arming a change that
// springs the rail open or shut the moment the dialog goes.
if (polkit.prompting)
if (shell.modalOpen)
return;
shell.expanded = !shell.expanded;
}
// Whether the scrim is up, from EITHER cause. This is the fact the surfaces
// actually share — the rail's density is only one of the two things that
// can raise it — so the backdrop and the layer pair below key off this
// rather than off `expanded`.
// The surfaces that take over the screen: they dim EVERY output, land on the
// focused one, and take the keyboard off the rail. Grouped because
// everything below treats them alike, so a fourth one joins by being named
// here and nowhere else.
readonly property bool modalOpen: polkit.prompting || launcher.active || askpass.active
// Whether the scrim is up, from ANY cause. This is the fact the surfaces
// actually share — the rail's density is only one of the things that can
// raise it — so the backdrop and the layer pair below key off this rather
// than off `expanded`.
//
// One backdrop instance serves both. A prompt arriving over an already
// expanded rail therefore changes nothing about the scrim: it is already up,
// already full height, and the dialog simply appears above it. A prompt over
// a COLLAPSED rail expands that same scrim from its bar-height band to the
// whole output, using the animation it already has, and the rail stays
// collapsed throughout.
readonly property bool scrimUp: shell.expanded || polkit.prompting
// One backdrop instance serves all of them. A modal opening over an already
// expanded rail therefore changes nothing about the scrim on that monitor:
// it is already up, already full height, and the modal simply appears above
// it. Over a COLLAPSED rail the same scrim expands from its bar-height band
// to the whole output, using the animation it already has, and the rail
// stays collapsed throughout.
readonly property bool scrimUp: shell.expanded || shell.modalOpen
// Scrim up, the rail is over everything; scrim down, it drops below ordinary
// windows. BOTTOM rather than BACKGROUND for the lowered bar: it is the
@@ -174,7 +183,7 @@ Scope {
required property var modelData
screen: modelData
active: polkit.prompting
active: shell.modalOpen
wlrLayer: WlrLayer.Top
barHeight: 0
}
@@ -196,7 +205,7 @@ Scope {
// the rail. Withheld rather than left to the compositor to arbitrate
// between two exclusive surfaces, which would decide by stacking and
// silently swap the order the day the layers change.
grabsKeyboard: shell.expanded && !polkit.prompting
grabsKeyboard: shell.expanded && !shell.modalOpen
onDismissed: shell.expanded = false
}
@@ -220,4 +229,23 @@ Scope {
screen: shell.focusedScreen
}
// Primary application launcher — SUPER_L. Migrated out of
// widgets/launcher/; the ten remaining variants are still evaluation copies
// and stay in shell.qml. Declared after the bar for the same reason the
// prompt is: while it is open the bar is on Overlay too, and there is no
// layer above Overlay to escape to.
AppLauncher {
id: launcher
screen: shell.focusedScreen
}
// GUI password prompt for `sudo -A`. Not the polkit agent — sudo cannot use
// one — but it renders the same dialog. See the file for the flow.
AskpassPrompt {
id: askpass
screen: shell.focusedScreen
}
}
@@ -4,18 +4,41 @@ import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import QtQuick
import qs.widgets.theme
import qs.HyprChrome.Theme
// Primary application launcher (variant 8). The full-screen layer-shell adapter
// owns focus, DesktopEntries and execution; ApplicationLauncherContent remains
// an Item so the complete visual state can be rendered headlessly.
// Primary application launcher the one on SUPER_L.
//
// Migrated from widgets/launcher/ApplicationLauncher.qml. Two things changed in
// the move, both because HyprChromeShell now owns the state its surfaces share:
//
// * no scrim of its own. The shell raises the single ChromeBackdrop for any
// of its causes expanded rail, polkit prompt, this so opening the
// launcher over an already-expanded rail reuses the scrim that is there
// rather than laying a second dim on top of it.
// * `active` is read by the shell, which uses it to raise that scrim, to
// place this on the focused monitor, and to decide who gets the keyboard.
//
// The full-screen layer-shell adapter owns focus, DesktopEntries and execution;
// AppLauncherContent stays an Item so the whole visual state can be rendered
// headlessly (tests/AppLauncherHeadless.qml).
Scope {
id: root
property bool active: false
function toggle() { root.active = !root.active; }
// Which output to appear on. Driven by the shell, which puts it on the
// focused monitor a launcher belongs where the user is looking, which is
// not necessarily where the rail lives.
property var screen: null
function toggle() { root.active = !root.active; }
function close() { root.active = false; }
// The name is legacy: this was "variant 8" of eleven, and both SUPER_L (via
// open_launcher.sh) and SUPER CTRL 8 still dispatch quickshell:launcher8.
// Renaming it means editing hosts/terra/home/hyprland.nix AND the script
// together, and neither takes effect until a deploy so the shortcut would
// be dead in the running session in between. Kept as-is deliberately.
GlobalShortcut {
name: "launcher8"
description: "Toggle dense application command index"
@@ -25,7 +48,9 @@ Scope {
PanelWindow {
id: win
screen: root.screen
visible: root.active
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
@@ -71,19 +96,18 @@ Scope {
search: content.query
}
Rectangle {
// Click-off dismissal. The scrim itself belongs to the shell and takes
// no input (its mask is empty), so the catcher lives here: a
// transparent full-surface MouseArea UNDER the content, which is what
// keeps clicks on the launcher itself from closing it.
MouseArea {
anchors.fill: parent
color: Theme.surface
opacity: 0.72
MouseArea {
anchors.fill: parent
onClicked: root.active = false
}
onClicked: root.close()
}
ApplicationLauncherContent {
AppLauncherContent {
id: content
anchors.centerIn: parent
width: 1080
height: 620
@@ -95,7 +119,7 @@ Scope {
onSelectionRequested: index => win.selectedIndex = win.clampSelection(index)
onMoveRequested: delta => win.move(delta)
onLaunchRequested: index => win.launch(index)
onDismissRequested: root.active = false
onDismissRequested: root.close()
}
}
}
@@ -5,12 +5,11 @@ import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
import QtQuick.Shapes
import qs.widgets.bar
import qs.widgets.theme
import qs.HyprChrome.Theme
// Headlessly renderable visual core for the primary application launcher.
// Runtime concerns (DesktopEntries, layer shell, launching) stay in
// ApplicationLauncher.qml; this component only renders state and emits intent.
// Runtime concerns (DesktopEntries, layer shell, focus, launching) stay in
// AppLauncher.qml; this component only renders state and emits intent.
Item {
id: root
@@ -70,7 +69,7 @@ Item {
}
}
StatusBarPanel {
LauncherPanel {
anchors.fill: parent
panelId: "008"
title: "APPLICATION COMMAND INDEX"
@@ -78,7 +77,7 @@ Item {
chamfer: 18
// Query module.
StatusBarPanel {
LauncherPanel {
id: queryPanel
x: 18
y: 34
@@ -184,7 +183,7 @@ Item {
}
// Search result table.
StatusBarPanel {
LauncherPanel {
id: resultPanel
x: 18
y: 118
@@ -347,7 +346,7 @@ Item {
}
// Selected application inspector.
StatusBarPanel {
LauncherPanel {
id: inspector
x: 700
y: 34
@@ -569,7 +568,7 @@ Item {
}
// Dense command footer.
StatusBarPanel {
LauncherPanel {
x: 18
y: 550
width: 1044
@@ -0,0 +1,44 @@
import Quickshell
import QtQuick
// Non-visual, reusable app-search model shared by every launcher variant.
// Set `search`; read `apps` (a ranked, filtered list of DesktopEntry).
QtObject {
id: root
property string search: ""
// `keywords`/`categories` come through as string lists, so coerce every
// field to a string before matching (String([]) joins with commas).
function haystack(a) {
return (String(a.name || "") + " " + String(a.genericName || "") + " " + String(a.comment || "") + " " + String(a.keywords || "")).toLowerCase();
}
readonly property var apps: {
const all = DesktopEntries.applications.values.filter(a => !a.noDisplay);
const q = root.search.trim().toLowerCase();
if (q.length === 0)
return all.slice().sort((x, y) => String(x.name).localeCompare(String(y.name)));
const matches = all.filter(a => root.haystack(a).includes(q));
// Prefix matches on the visible name rank first, then alphabetical.
return matches.slice().sort((x, y) => {
const xs = String(x.name).toLowerCase().startsWith(q) ? 0 : 1;
const ys = String(y.name).toLowerCase().startsWith(q) ? 0 : 1;
if (xs !== ys)
return xs - ys;
return String(x.name).localeCompare(String(y.name));
});
}
function launch(index) {
const list = root.apps;
if (index >= 0 && index < list.length) {
list[index].execute();
return true;
}
return false;
}
}
@@ -0,0 +1,136 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import qs.HyprChrome.Theme
// Chamfered panel chrome for the launcher: outline, corner accent lines, and
// the optional header strip (id chip / title / meta / tick marks). Content is
// supplied as children by the call site.
//
// A sibling of BarPanel rather than a use of it: BarPanel is almost entirely
// density machinery (summary slot, animated height, state pair, transitions)
// for a rail that expands and collapses, and the launcher has exactly one
// density. Same reasoning as PolkitPanel — see that file.
//
// Carried over from widgets/bar/StatusBarPanel.qml, which the legacy launcher
// variants still use. Restyle this one freely; it is read only by the launcher.
Item {
id: panel
property string panelId: ""
property string title: ""
property string meta: ""
property bool showHeader: true
property int chamfer: 13
property int offsetY: 2
property int accentLineThickness: 3
Shape {
id: panelShape
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: Theme.surface
strokeColor: Theme.hair
strokeWidth: 1
startX: 0; startY: panel.offsetY
PathLine { x: panelShape.width - panel.chamfer; y: panel.offsetY }
PathLine { x: panelShape.width; y: panel.chamfer }
PathLine { x: panelShape.width; y: panelShape.height }
PathLine { x: panel.chamfer; y: panelShape.height }
PathLine { x: 0; y: panelShape.height - panel.chamfer }
PathLine { x: 0; y: panel.offsetY }
}
// Upper left accent line
ShapePath {
fillColor: Theme.accent
strokeWidth: 0
startX: 0; startY: 0
PathLine { x: Math.min(49, panelShape.width / 3); y: 0 }
PathLine { x: Math.min(49, panelShape.width / 3); y: panel.accentLineThickness }
PathLine { x: 0; y: panel.accentLineThickness }
PathLine { x: 0; y: 0 }
}
// Lower right accent line
ShapePath {
fillColor: Theme.accent
strokeWidth: 0
startX: panelShape.width; startY: panelShape.height
PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height }
PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height - panel.accentLineThickness }
PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness }
PathLine { x: panelShape.width; y: panelShape.height }
}
}
Rectangle {
visible: panel.showHeader
x: 1; y: 22
width: parent.width - 2
height: 1
color: Theme.text
opacity: 0.12
}
Rectangle {
visible: panel.showHeader
x: 5; y: 7
width: panel.panelId.length > 2 ? 29 : 24
height: 11
color: Theme.accent
Text {
anchors.centerIn: parent
text: panel.panelId
color: Theme.surface
font.family: Theme.microFont
font.pixelSize: 8
font.bold: true
}
}
Text {
visible: panel.showHeader
x: 40; y: 7
width: parent.width - 105
text: panel.title
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 9
font.bold: true
font.letterSpacing: 1.1
elide: Text.ElideRight
}
// Inlined rather than reusing DenseBarContent's MicroText, which is an
// inline component and therefore not visible from another file.
Text {
visible: panel.showHeader && panel.meta.length > 0
anchors.right: parent.right
anchors.rightMargin: 12
y: 6
text: panel.meta
width: Math.min(80, parent.width / 4)
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 6
font.letterSpacing: 0.7
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
Row {
visible: panel.showHeader
anchors.right: parent.right
anchors.rightMargin: 10
y: 14
spacing: 2
Repeater {
model: 5
Rectangle { required property int index; width: 4; height: 2; color: Theme.accent }
}
}
}
+5 -2
View File
@@ -20,7 +20,11 @@ Scope {
// Dense multi-monitor status rail; visual core is headlessly renderable.
HyprChromeShell {}
// App launcher variants — 111; variant 8 remains the primary HUD.
// App launcher variants still under evaluation, on SUPER CTRL 111.
// Variant 8 — the primary launcher on SUPER_L — has moved into
// HyprChrome/Widgets/Launcher and is instantiated by HyprChromeShell,
// because the shell owns the scrim, the focused monitor and the keyboard
// arbitration it now shares with the polkit prompt.
LauncherStack {} // 1 — left vertical list
LauncherGrid {} // 2 — centered icon grid
LauncherSpotlight {} // 3 — top-center command bar
@@ -28,7 +32,6 @@ Scope {
LauncherDock {} // 5 — deck rising from the bottom bar
LauncherSlant {} // 6 — angular / sheared panel
LauncherCorner {} // 7 — Slant (V6) copy + floating power panel (shutdown/reboot)
ApplicationLauncher {} // 8 — dense HUD command index (primary)
BladeLauncher {} // 9 — asymmetric blade matrix
OrbitLauncher {} // 10 — radial targeting arena
CyberDock {} // 11 — cyberpunk bottom cartridge dock
@@ -1,7 +1,7 @@
import QtQuick
import qs.widgets.launcher
import qs.HyprChrome.Widgets.Launcher
ApplicationLauncherContent {
AppLauncherContent {
width: 1080
height: 620