publish: the public projection begins here
This is a projection, not a development branch. The tree above was constructed from the internal source named below under a manifest that decides which paths may leave, then scanned as a whole tree rather than as a series of patches, and only then published. Public history starts here because the history before it was not admissible, and neither was the tree. What used to stand in this repository included a rescue copy of another machine, a directory of phone handoffs, deployment wired to one house, and a submodule pointing at a forge no stranger can reach. None of that was ever the product. It stays in the private forge, which is allowed to hold the whole working organism, and this is what was deliberately sent out instead. Three mechanisms produced this tree, in decreasing order of trust. A top-level path the manifest does not name never arrives at all, which is the one that catches directories nobody has thought of yet. Named internal files inside admitted roots are dropped. A short, reviewed table replaces deployment defaults that a public build must not carry -- an endpoint aimed at one LAN, a VPN profile belonging to one phone, packaging built from one checkout path. Everything after this commit is an ordinary publication with the same three trailers, so a force push stops being routine and starts meaning that something deliberate happened. The trailers bind the projection to its source without pretending the public SHA is the private one: same lineage, different tree, and the record says so. Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047 Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27 Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
This commit is contained in:
commit
8f42fc953d
1476 changed files with 238455 additions and 0 deletions
254
surfaces/quickshell/modules/settings/DeviceConfig.qml
Normal file
254
surfaces/quickshell/modules/settings/DeviceConfig.qml
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Device — Souveraine form-factor and (later) per-device overrides.
|
||||
// Principle: pages are views over Config.options / the owning daemon;
|
||||
// nothing app-private. This page holds the knobs that describe WHAT this
|
||||
// device is, so behaviors elsewhere gate on config, not hardcoded checks.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "smartphone"
|
||||
title: Translation.tr("Form factor")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "smartphone"
|
||||
text: Translation.tr("Phone mode")
|
||||
checked: Config.options.souveraine.phone
|
||||
onCheckedChanged: {
|
||||
Config.options.souveraine.phone = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Gates phone behaviors: OSK rises for polkit prompts, phone-only pages, single-column layouts. Laptop deploys leave this off.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Proprioception ───────────────────────────────────────────────────
|
||||
// TASK-08(f) / TASK-19: the state machine computes its state, its
|
||||
// evidence, its confidence and its per-source health, and until now none
|
||||
// of it reached a screen. `forensic.jsonl` knew; the device could not tell
|
||||
// you. A body that cannot feel itself is the thing this OS is not
|
||||
// supposed to be.
|
||||
//
|
||||
// READOUT ONLY, deliberately. TASK-19: the confidence gates are computed,
|
||||
// logged and never branched on, so a control over them "would be lying" —
|
||||
// showing a threshold slider nothing consults breaks this page's own rule
|
||||
// against success-shaped switches. Observations can be shown honestly
|
||||
// today; controls wait on TASK-08(g).
|
||||
property bool _watchingEvidence: false
|
||||
|
||||
function _startWatching() {
|
||||
if (_watchingEvidence) return;
|
||||
_watchingEvidence = true;
|
||||
DeviceEvidence.watch();
|
||||
}
|
||||
function _stopWatching() {
|
||||
if (!_watchingEvidence) return;
|
||||
_watchingEvidence = false;
|
||||
DeviceEvidence.unwatch();
|
||||
}
|
||||
|
||||
Component.onCompleted: _startWatching()
|
||||
Component.onDestruction: _stopWatching()
|
||||
|
||||
ContentSection {
|
||||
icon: "monitor_heart"
|
||||
title: Translation.tr("Device state")
|
||||
|
||||
// The laptop has no sessiond. Say so, rather than rendering zeroes
|
||||
// that look like a healthy reading (§10: "no evidence" and "evidence
|
||||
// says nothing is happening" must not be the same state).
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: !DeviceEvidence.available
|
||||
text: Translation.tr("sessiond is not answering on this device — no state to report.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: DeviceEvidence.available ? [
|
||||
{ k: Translation.tr("State"), v: DeviceEvidence.state.device_state ?? "—" },
|
||||
{ k: Translation.tr("Lock phase"), v: DeviceEvidence.state.phase ?? "—" },
|
||||
{ k: Translation.tr("Locked"), v: (DeviceEvidence.state.locked ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||
{ k: Translation.tr("Panel"), v: (DeviceEvidence.state.panel_on ?? false) ? Translation.tr("on") : Translation.tr("off") },
|
||||
{ k: Translation.tr("Dimmed"), v: (DeviceEvidence.state.dimmed ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||
{ k: Translation.tr("Display active"), v: (DeviceEvidence.state.display_active ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||
{ k: Translation.tr("Idle"), v: (DeviceEvidence.state.idle_secs ?? 0) + "s" },
|
||||
{ k: Translation.tr("Observed"), v: (DeviceEvidence.state.observed ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||
{ k: Translation.tr("Confidence"), v: Number(DeviceEvidence.state.observed_confidence ?? 0).toFixed(2) },
|
||||
{ k: Translation.tr("Wake suppressed"), v: (DeviceEvidence.state.suppress_dpms_wake ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||
{ k: Translation.tr("Shell alive"), v: (DeviceEvidence.state.shell_alive ?? false) ? Translation.tr("yes") : Translation.tr("no") }
|
||||
] : []
|
||||
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.k
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
StyledText {
|
||||
text: String(modelData.v)
|
||||
color: Appearance.colors.colOnLayer1
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "sensors"
|
||||
title: Translation.tr("Evidence sources")
|
||||
|
||||
// The flag §10 was built for. It rides every forensic snapshot and had
|
||||
// nowhere to appear: the SLPI outage on 2026-07-25 killed every sensor
|
||||
// for four hours and exited status 0, so the crash reporter
|
||||
// structurally could not help.
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: DeviceEvidence.available && (DeviceEvidence.state.sensors_degraded ?? false)
|
||||
text: Translation.tr("A source reported and then went silent. Readings below are not trustworthy.")
|
||||
color: Appearance.colors.colError
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: DeviceEvidence.available
|
||||
text: Translation.tr("live = reporting · unknown = never heard from (no reporter wired) · down = spoke, then stopped")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: {
|
||||
if (!DeviceEvidence.available) return [];
|
||||
const health = DeviceEvidence.state.sensor_health ?? {};
|
||||
const fresh = DeviceEvidence.state.evidence_fresh ?? {};
|
||||
return Object.keys(health).map(name => ({
|
||||
name: name,
|
||||
health: health[name],
|
||||
fresh: fresh[name] === true
|
||||
}));
|
||||
}
|
||||
|
||||
delegate: RowLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
MaterialSymbol {
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
text: modelData.health === "live" ? "sensors"
|
||||
: modelData.health === "down" ? "sensors_off"
|
||||
: "help"
|
||||
color: modelData.health === "down" ? Appearance.colors.colError
|
||||
: modelData.health === "live" ? Appearance.colors.colOnLayer1
|
||||
: Appearance.colors.colSubtext
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.name
|
||||
color: Appearance.colors.colOnLayer1
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
StyledText {
|
||||
// "unknown" is not a failure — accel, light and touch have
|
||||
// no reporter on this device and correctly sit there
|
||||
// forever. Only a source that spoke and then stopped failed.
|
||||
text: modelData.health + (modelData.fresh ? Translation.tr(" · fresh") : "")
|
||||
color: modelData.health === "down" ? Appearance.colors.colError
|
||||
: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "history"
|
||||
title: Translation.tr("Recent decisions")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("What the machine last decided, and what it decided it from. The same entries the forensic trail hash-chains.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Repeater {
|
||||
// Newest first, by seq — independent of the order the buffer
|
||||
// happens to return.
|
||||
model: (DeviceEvidence.recentDecisions ?? []).slice()
|
||||
.sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0))
|
||||
.slice(0, 12)
|
||||
|
||||
delegate: ColumnLayout {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 1
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
StyledText {
|
||||
text: {
|
||||
// `event` is a tagged union (decision,
|
||||
// state-transition, sensor-input, error-*). Take
|
||||
// whichever key it carries rather than assuming.
|
||||
const ev = modelData.event ?? {};
|
||||
const kind = Object.keys(ev)[0] ?? "event";
|
||||
const body = ev[kind] ?? {};
|
||||
return body.decision ?? body.to ?? kind;
|
||||
}
|
||||
color: Appearance.colors.colOnLayer1
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
StyledText {
|
||||
text: "#" + (modelData.seq ?? "?")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: (modelData.reason ?? "").length > 0
|
||||
text: modelData.reason ?? ""
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "tune"
|
||||
title: Translation.tr("Device overrides")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Per-device profile overrides (panel size, sensor set, feel presets) land here as the framework grows. One config tree, many devices.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
71
surfaces/quickshell/modules/settings/DisplayConfig.qml
Normal file
71
surfaces/quickshell/modules/settings/DisplayConfig.qml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
ContentPage {
|
||||
id: page
|
||||
forceWidth: true
|
||||
|
||||
readonly property var monitor: Brightness.monitors.length > 0
|
||||
? Brightness.monitors[0] : null
|
||||
|
||||
ContentSection {
|
||||
icon: "brightness_6"
|
||||
title: Translation.tr("Brightness")
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
MaterialSymbol {
|
||||
text: "brightness_low"
|
||||
iconSize: 22
|
||||
}
|
||||
StyledSlider {
|
||||
Layout.fillWidth: true
|
||||
enabled: page.monitor ? page.monitor.ready : false
|
||||
value: page.monitor ? page.monitor.brightness : 0
|
||||
from: 0.01
|
||||
to: 1
|
||||
onMoved: {
|
||||
if (page.monitor) page.monitor.setBrightness(value);
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
text: page.monitor && page.monitor.ready
|
||||
? `${Math.round(page.monitor.brightness * 100)}%` : "—"
|
||||
font.family: Appearance.font.family.numbers
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "flash_off"
|
||||
text: Translation.tr("Anti-flashbang dimming")
|
||||
checked: Config.options.light.antiFlashbang.enable
|
||||
onCheckedChanged: Config.options.light.antiFlashbang.enable = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Temporarily softens large brightness jumps when content changes.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "monitor"
|
||||
title: Translation.tr("Built-in display")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Quickshell.screens.length > 0
|
||||
? Translation.tr("%1 · %2 × %3 logical pixels")
|
||||
.arg(Quickshell.screens[0].name)
|
||||
.arg(Quickshell.screens[0].width)
|
||||
.arg(Quickshell.screens[0].height)
|
||||
: Translation.tr("No display reported")
|
||||
color: Appearance.colors.colSubtext
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
331
surfaces/quickshell/modules/settings/DockConfig.qml
Normal file
331
surfaces/quickshell/modules/settings/DockConfig.qml
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Dock — every configurable the dock components read. Feel knobs are the
|
||||
// single source (components read Config, never hardcode); stacks and pins
|
||||
// are managed by drag on the dock itself — an editor lands here later.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "dock_to_bottom"
|
||||
title: Translation.tr("Behavior")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "dock_to_bottom"
|
||||
text: Translation.tr("Enable dock")
|
||||
checked: Config.options.dock.enable
|
||||
onCheckedChanged: {
|
||||
Config.options.dock.enable = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("The dock surface itself. This settings app runs standalone, so it can always re-enable it.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "bottom_panel_open"
|
||||
text: Translation.tr("Auto-hide on Home")
|
||||
checked: Config.options.dock.autoHide
|
||||
onCheckedChanged: {
|
||||
Config.options.dock.autoHide = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Hide the Home dock until the pointer reaches the bottom edge.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "push_pin"
|
||||
text: Translation.tr("Reserve screen space")
|
||||
checked: Config.options.dock.pinnedOnStartup
|
||||
enabled: !Config.options.dock.autoHide
|
||||
onCheckedChanged: {
|
||||
Config.options.dock.pinnedOnStartup = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Keep normal windows above the visible dock instead of allowing them behind it.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "swipe_up"
|
||||
text: Translation.tr("Reveal from other zones")
|
||||
checked: Config.options.dock.hoverToReveal
|
||||
onCheckedChanged: {
|
||||
Config.options.dock.hoverToReveal = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Keep a bottom-edge reveal strip available when the dock is not Home furniture.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "hourglass_top"
|
||||
text: Translation.tr("Reveal delay (ms)")
|
||||
value: Config.options.dock.revealDelayMs
|
||||
from: 0
|
||||
to: 1000
|
||||
stepSize: 25
|
||||
onValueChanged: {
|
||||
Config.options.dock.revealDelayMs = value;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "hourglass_bottom"
|
||||
text: Translation.tr("Hide delay (ms)")
|
||||
value: Config.options.dock.hideDelayMs
|
||||
from: 0
|
||||
to: 2000
|
||||
stepSize: 25
|
||||
onValueChanged: {
|
||||
Config.options.dock.hideDelayMs = value;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "timer"
|
||||
text: Translation.tr("Drag dwell (ms)")
|
||||
value: Config.options.dock.dragDwellMs
|
||||
from: 100
|
||||
to: 2000
|
||||
stepSize: 50
|
||||
onValueChanged: {
|
||||
Config.options.dock.dragDwellMs = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("How long a drag hovers an icon or stack before it reads as combine-intent.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "palette"
|
||||
title: Translation.tr("Appearance")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "filter_b_and_w"
|
||||
text: Translation.tr("Monochrome icons")
|
||||
checked: Config.options.dock.monochromeIcons
|
||||
onCheckedChanged: {
|
||||
Config.options.dock.monochromeIcons = checked;
|
||||
}
|
||||
}
|
||||
|
||||
// The dock's own scale. Two numbers, because the button is the row's
|
||||
// height and the icon is what you actually see — sizing one from the
|
||||
// other would mean either cramped icons in a tall row or icons
|
||||
// overflowing a short one, depending on which way the ratio was fixed.
|
||||
ConfigSpinBox {
|
||||
icon: "aspect_ratio"
|
||||
text: Translation.tr("Button size (px)")
|
||||
value: Config.options.dock.buttonSize
|
||||
from: 36
|
||||
to: 88
|
||||
stepSize: 2
|
||||
onValueChanged: {
|
||||
Config.options.dock.buttonSize = value;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "apps"
|
||||
text: Translation.tr("Icon size (px)")
|
||||
value: Config.options.dock.iconSize
|
||||
from: 24
|
||||
to: 72
|
||||
stepSize: 2
|
||||
onValueChanged: {
|
||||
Config.options.dock.iconSize = value;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "height"
|
||||
text: Translation.tr("Dock height (px)")
|
||||
value: Config.options.dock.height
|
||||
from: 40
|
||||
to: 120
|
||||
stepSize: 2
|
||||
onValueChanged: {
|
||||
Config.options.dock.height = value;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "expand"
|
||||
text: Translation.tr("Reveal region height (px)")
|
||||
value: Config.options.dock.hoverRegionHeight
|
||||
from: 1
|
||||
to: 20
|
||||
stepSize: 1
|
||||
onValueChanged: {
|
||||
Config.options.dock.hoverRegionHeight = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Height of the invisible bottom strip that triggers hover-reveal.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "push_pin"
|
||||
title: Translation.tr("Pinned apps")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Drag on the dock is the primary way to manage these (drag onto an icon to stack, drag a member off the arc to split). This list is the fallback editor.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// One row per pinned app. Model binds the raw config list so writes
|
||||
// (from here, the dock, or the agent) re-render immediately.
|
||||
Repeater {
|
||||
model: Config.options.dock.pinnedApps
|
||||
delegate: RowLayout {
|
||||
required property string modelData
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
implicitSize: 24
|
||||
source: Quickshell.iconPath(AppSearch.guessIcon(modelData), "image-missing")
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: modelData
|
||||
elide: Text.ElideMiddle
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
}
|
||||
RippleButton {
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
onClicked: TaskbarApps.togglePin(modelData)
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: "close"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
StyledToolTip { text: Translation.tr("Unpin") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: (Config.options.dock.pinnedApps?.length ?? 0) === 0
|
||||
text: Translation.tr("Nothing pinned. Long-press a running app on the dock to pin it.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "stacks"
|
||||
title: Translation.tr("Stacks")
|
||||
|
||||
// One block per stack: editable name, then member rows. All edits go
|
||||
// through TaskbarApps so the rules (id never changes, empty stacks
|
||||
// dissolve, unstacked members re-pin) live in one place.
|
||||
Repeater {
|
||||
model: Config.options.dock.stacks
|
||||
delegate: ColumnLayout {
|
||||
id: stackBlock
|
||||
required property string modelData
|
||||
readonly property var stack: TaskbarApps.parseStack(modelData)
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
MaterialSymbol {
|
||||
text: "stacks"
|
||||
iconSize: Appearance.font.pixelSize.large
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
MaterialTextField {
|
||||
Layout.fillWidth: true
|
||||
text: stackBlock.stack.name
|
||||
placeholderText: Translation.tr("Stack name")
|
||||
onEditingFinished: {
|
||||
if (text.length > 0 && text !== stackBlock.stack.name)
|
||||
TaskbarApps.renameStack(stackBlock.stack.id, text);
|
||||
}
|
||||
}
|
||||
RippleButton {
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
onClicked: {
|
||||
// Dissolve: every member goes back to a pin.
|
||||
for (const m of stackBlock.stack.members.slice())
|
||||
TaskbarApps.unstackMember(stackBlock.stack.id, m);
|
||||
}
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: "delete"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
StyledToolTip { text: Translation.tr("Dissolve stack (members become pins)") }
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: stackBlock.stack.members
|
||||
delegate: RowLayout {
|
||||
required property string modelData
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 28
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
implicitSize: 22
|
||||
source: Quickshell.iconPath(AppSearch.guessIcon(modelData), "image-missing")
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: modelData
|
||||
elide: Text.ElideMiddle
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
}
|
||||
RippleButton {
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
onClicked: TaskbarApps.unstackMember(stackBlock.stack.id, modelData)
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: "remove"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.m3colors.m3onSurface
|
||||
}
|
||||
StyledToolTip { text: Translation.tr("Unstack (back to a pin)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: (Config.options.dock.stacks?.length ?? 0) === 0
|
||||
text: Translation.tr("No stacks yet. Drag one dock icon onto another and hold until it highlights.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
}
|
||||
}
|
||||
346
surfaces/quickshell/modules/settings/IdleConfig.qml
Normal file
346
surfaces/quickshell/modules/settings/IdleConfig.qml
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Idle & sleep — the staged idle projection, exposed honestly.
|
||||
//
|
||||
// The governing idea is REFERENCE-EXTRACTION.md's "idle is a transition graph,
|
||||
// not a timer": the page shows the live stage the shell is actually in, not
|
||||
// just three timeout knobs pretending idle is linear.
|
||||
//
|
||||
// Two honesty constraints drive the layout, and both come straight from the
|
||||
// extraction's build order (truth before visuals):
|
||||
//
|
||||
// 1. Two authorities, two sections, never blended. The shell's own
|
||||
// IdleMonitors decide when a session IN USE dims and locks; sessiond's
|
||||
// device state machine decides how long a LOCKED panel may burn. The
|
||||
// second set is read live from the daemon (SessiondPolicy) rather than
|
||||
// from a config file, because the daemon is what actuates them —
|
||||
// TASK-19's "no success-shaped switches".
|
||||
//
|
||||
// hypridle no longer owns screen-off: its idle listeners were deleted
|
||||
// 2026-07-25 once sessiond got real actuators, because "lock, then off"
|
||||
// held only while its 300s lock happened to precede its 600s blank.
|
||||
//
|
||||
// 2. Settings is a window in the authoritative shell process, so this page
|
||||
// reads the idle/session singletons directly. It must not spawn `qs ipc`
|
||||
// children, which can become accidental shell instances when display
|
||||
// selection is ambiguous.
|
||||
ContentPage {
|
||||
id: page
|
||||
forceWidth: true
|
||||
|
||||
// --- Live stage readout ----------------------------------------------
|
||||
// 0 Active · 1 Dimmed · 2 Lock requested · 3 Lock secure ·
|
||||
// 4 Suspending · 5 Asleep · 6 Waking — the IdleCoordinator.State
|
||||
// enum, read directly from the shell-owned coordinator.
|
||||
readonly property int liveStage: IdleCoordinator.state
|
||||
readonly property bool liveNative: IdleCoordinator.nativeEnabled
|
||||
readonly property bool probeOk: true
|
||||
readonly property bool sleepInhibitorHeld: SessionEvents.sleepInhibitorHeld
|
||||
readonly property bool stepUpEnabled: StepUpAuth.grantTtlMs > 0
|
||||
|
||||
// Seconds on the wire, human words on screen. "Never" is 0, which is what
|
||||
// the daemon already means by it.
|
||||
readonly property var blankPresets: [
|
||||
{ displayName: Translation.tr("15s"), icon: "timer", value: 15 },
|
||||
{ displayName: Translation.tr("30s"), icon: "timer", value: 30 },
|
||||
{ displayName: Translation.tr("1 min"), icon: "timer", value: 60 },
|
||||
{ displayName: Translation.tr("5 min"), icon: "timer", value: 300 },
|
||||
{ displayName: Translation.tr("Never"), icon: "timer_off", value: 0 }
|
||||
]
|
||||
readonly property var idlePresets: [
|
||||
{ displayName: Translation.tr("30s"), icon: "timer", value: 30 },
|
||||
{ displayName: Translation.tr("1 min"), icon: "timer", value: 60 },
|
||||
{ displayName: Translation.tr("2 min"), icon: "timer", value: 120 },
|
||||
{ displayName: Translation.tr("5 min"), icon: "timer", value: 300 },
|
||||
{ displayName: Translation.tr("10 min"), icon: "timer", value: 600 }
|
||||
]
|
||||
// ONE dim setting, for both authorities.
|
||||
//
|
||||
// This used to be two: a session dim (15s–5min, before the lock) and a
|
||||
// separate lock-screen dim grace (5–20s, before the blank). They are two
|
||||
// daemons, but they are not two questions — the user is answering "how
|
||||
// much warning do I get before the screen goes away", once. Splitting it
|
||||
// made the page describe our architecture instead of their screen.
|
||||
//
|
||||
// Written to both, unchanged: the shell's dimBeforeLockSeconds and
|
||||
// sessiond's dim_grace_secs.
|
||||
readonly property var dimPresets: [
|
||||
{ displayName: Translation.tr("5s"), icon: "brightness_medium", value: 5 },
|
||||
{ displayName: Translation.tr("10s"), icon: "brightness_medium", value: 10 },
|
||||
{ displayName: Translation.tr("30s"), icon: "brightness_medium", value: 30 },
|
||||
{ displayName: Translation.tr("1 min"), icon: "brightness_medium", value: 60 },
|
||||
{ displayName: Translation.tr("3 min"), icon: "brightness_medium", value: 180 }
|
||||
]
|
||||
|
||||
// Write the chosen value through, unchanged, to both authorities.
|
||||
//
|
||||
// An earlier version clamped the lock-screen grace to `blank - 1` so a
|
||||
// long dim would still "fit". That was invented policy nobody asked for
|
||||
// and it inverted the setting: a 30 s dim against a 15 s blank became a
|
||||
// 14 s grace, which starts the dim one second after you stop touching the
|
||||
// phone. A setting that silently means something else is worse than one
|
||||
// that does not apply.
|
||||
function applyDim(v) {
|
||||
Config.options.lock.idle.dimBeforeLockSeconds = v;
|
||||
if (SessiondPolicy.available)
|
||||
SessiondPolicy.apply({ dim_grace_secs: v });
|
||||
}
|
||||
|
||||
// "2 min" reads better than "120 s" in a sentence about when things happen.
|
||||
function clockText(secs) {
|
||||
if (secs >= 60 && secs % 60 === 0)
|
||||
return Translation.tr("%1 min").arg(secs / 60);
|
||||
if (secs >= 60)
|
||||
return Translation.tr("%1 min %2 s").arg(Math.floor(secs / 60)).arg(secs % 60);
|
||||
return Translation.tr("%1 s").arg(secs);
|
||||
}
|
||||
|
||||
readonly property var stageNames: [
|
||||
Translation.tr("Active"),
|
||||
Translation.tr("Dimmed"),
|
||||
Translation.tr("Lock requested"),
|
||||
Translation.tr("Lock secure"),
|
||||
Translation.tr("Suspending"),
|
||||
Translation.tr("Asleep"),
|
||||
Translation.tr("Waking")
|
||||
]
|
||||
function stageLabel(s) {
|
||||
return (s >= 0 && s < stageNames.length) ? stageNames[s] : Translation.tr("unknown");
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "motion_sensor_active"
|
||||
title: Translation.tr("Current state")
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
StyledText {
|
||||
text: page.probeOk
|
||||
? Translation.tr("Idle stage: %1").arg(page.stageLabel(page.liveStage))
|
||||
: Translation.tr("Idle stage: (shell not reachable)")
|
||||
font.pixelSize: Appearance.font.pixelSize.normal
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
text: page.liveNative
|
||||
? Translation.tr("The native coordinator is driving idle transitions. Dim and lock fire on the timers below.")
|
||||
: Translation.tr("The native coordinator is off, so the session timers below do not run. Nothing else locks on idle now that hypridle's listeners are gone — turn it on, or the phone only locks when you lock it.")
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
StyledText {
|
||||
text: page.sleepInhibitorHeld
|
||||
? Translation.tr("Sleep inhibitor: held (suspend blocked until lock is secure)")
|
||||
: Translation.tr("Sleep inhibitor: released (suspend may proceed)")
|
||||
font.pixelSize: Appearance.font.pixelSize.normal
|
||||
color: page.sleepInhibitorHeld
|
||||
? Appearance.colors.colOnLayer1
|
||||
: Appearance.colors.colSubtext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "experiment"
|
||||
title: Translation.tr("Native idle coordinator")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "science"
|
||||
text: Translation.tr("Enable native coordinator (experimental)")
|
||||
checked: Config.options.lock.idle.nativeCoordinatorEnabled
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.idle.nativeCoordinatorEnabled = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Use Wayland idle-notify to drive the dim/lock timers. Unverified on the Pixel 3 compositor build — leave off unless you are testing it. When off, hypridle handles idle and screen-off.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "timer"
|
||||
title: Translation.tr("Session timers")
|
||||
|
||||
// Config keys, effective only while the native coordinator is on.
|
||||
// Presets rather than a seconds spinner, same reasoning as below.
|
||||
ContentSubsectionLabel {
|
||||
text: Translation.tr("Lock the session after")
|
||||
}
|
||||
ConfigSelectionArray {
|
||||
currentValue: Config.options.lock.idle.lockAfterSeconds
|
||||
onSelected: v => Config.options.lock.idle.lockAfterSeconds = v
|
||||
options: page.idlePresets
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: !Config.options.lock.idle.nativeCoordinatorEnabled
|
||||
wrapMode: Text.WordWrap
|
||||
color: Appearance.colors.colError
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
text: Translation.tr("The native coordinator is off, so these do not run.")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Brightness dimming, one question ---------------------------------
|
||||
// Deliberately not split by authority. Two daemons own the actuation, and
|
||||
// the page used to say so by giving each its own dim control — which made
|
||||
// the user answer the same question twice and left them to work out that
|
||||
// the two interact. One control, written to both. See applyDim().
|
||||
ContentSection {
|
||||
icon: "brightness_medium"
|
||||
title: Translation.tr("Brightness dimming")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "brightness_low"
|
||||
text: Translation.tr("Dim before the screen goes away")
|
||||
checked: SessiondPolicy.dimWarning
|
||||
enabled: SessiondPolicy.available
|
||||
onCheckedChanged: {
|
||||
if (SessiondPolicy.available && checked !== SessiondPolicy.dimWarning)
|
||||
SessiondPolicy.apply({ dim_warning: checked });
|
||||
}
|
||||
}
|
||||
|
||||
ContentSubsectionLabel {
|
||||
text: Translation.tr("Start dimming this long before")
|
||||
}
|
||||
ConfigSelectionArray {
|
||||
currentValue: Config.options.lock.idle.dimBeforeLockSeconds
|
||||
onSelected: v => page.applyDim(v)
|
||||
options: page.dimPresets
|
||||
}
|
||||
|
||||
// Say what it actually does, on both surfaces, in one sentence each.
|
||||
// A relationship the user has to compute in their head is one they
|
||||
// will get wrong.
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
text: {
|
||||
const lock = Config.options.lock.idle.lockAfterSeconds;
|
||||
const grace = Config.options.lock.idle.dimBeforeLockSeconds;
|
||||
const dimAt = Math.max(1, Math.min(lock - 1, lock - grace));
|
||||
return Translation.tr("In use: dims at %1, locks at %2.")
|
||||
.arg(page.clockText(dimAt))
|
||||
.arg(page.clockText(lock));
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: SessiondPolicy.available
|
||||
wrapMode: Text.WordWrap
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
text: {
|
||||
const blank = SessiondPolicy.lockBlankAfterSecs;
|
||||
if (blank === 0)
|
||||
return Translation.tr("On the lock screen: never blanks, so nothing dims.");
|
||||
const g = SessiondPolicy.dimGraceSecs;
|
||||
return Translation.tr("On the lock screen: dims %1 before blanking at %2.")
|
||||
.arg(page.clockText(g))
|
||||
.arg(page.clockText(blank));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lock screen timers, owned by sessiond ----------------------------
|
||||
// A separate section because these are a different authority. The two
|
||||
// above are the shell's own IdleMonitors deciding when to dim and lock a
|
||||
// session in use; these are the device state machine deciding how long a
|
||||
// LOCKED, lit panel may burn before it goes dark. That machine has its own
|
||||
// clock and its own actuators, so its numbers must come from it — which is
|
||||
// what SessiondPolicy is for, and what this page did not do until
|
||||
// 2026-07-25 (SetPolicy had zero callers; the daemon ran on its built-in
|
||||
// 15 s while this page showed whatever was in the JSON file).
|
||||
Component.onCompleted: SessiondPolicy.refresh()
|
||||
|
||||
ContentSection {
|
||||
icon: "phonelink_lock"
|
||||
title: Translation.tr("Lock screen (device authority)")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: SessiondPolicy.available
|
||||
? Appearance.colors.colSubtext
|
||||
: Appearance.colors.colError
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
text: SessiondPolicy.available
|
||||
? Translation.tr("Read live from souveraine-sessiond. Changes here go straight to the daemon that blanks the panel.")
|
||||
: Translation.tr("sessiond is not answering — these values are NOT authoritative. %1").arg(SessiondPolicy.lastError)
|
||||
}
|
||||
|
||||
// Presets, not a seconds spinner. The policy struct's own comment
|
||||
// names the shape — "iOS's Auto-Lock: a user-chosen timeout with a
|
||||
// visible dim shortly before it, and a 'never' option for the
|
||||
// desk-clock case" — and nobody reasons about a lock screen in
|
||||
// 5-second increments. Values are still seconds on the wire; the
|
||||
// daemon's vocabulary does not change because the UI got legible.
|
||||
// One blank timeout, not two.
|
||||
//
|
||||
// The daemon still has a separate held-vs-resting budget, and it is
|
||||
// still the right idea — a phone in your hand should not blank on the
|
||||
// same schedule as one face-up on a desk. It is not a *setting*
|
||||
// though. Asking the user to pick two numbers made them responsible
|
||||
// for arbitrating a guess the accelerometer was making on their
|
||||
// behalf, and the machine already has a better vocabulary for that:
|
||||
// §4's confidence arithmetic. Held-ness belongs there, as an
|
||||
// adjustment to one budget, not as a second budget on this page.
|
||||
//
|
||||
// Until that lands, both fields get the same value, so the behaviour
|
||||
// is uniform and predictable rather than silently forking on a sensor
|
||||
// reading nothing surfaces.
|
||||
ContentSubsectionLabel {
|
||||
text: Translation.tr("Blank after")
|
||||
}
|
||||
ConfigSelectionArray {
|
||||
currentValue: SessiondPolicy.lockBlankAfterSecs
|
||||
onSelected: v => SessiondPolicy.apply({
|
||||
lock_blank_after_secs: v,
|
||||
lock_blank_after_held_secs: v
|
||||
})
|
||||
options: page.blankPresets
|
||||
}
|
||||
|
||||
// Only claim the ordering guarantee when the daemon actually reports
|
||||
// the field. An older sessiond has no lock_ack_budget_secs, and
|
||||
// rendering that absence as "waits 0s" would be the page inventing a
|
||||
// number — the same class of lie as the timers this section replaced.
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: SessiondPolicy.available && SessiondPolicy.lockAckBudgetSecs > 0
|
||||
wrapMode: Text.WordWrap
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
text: Translation.tr("The screen never goes dark on an unlocked session. sessiond locks first and waits %1s for the compositor to acknowledge; if it cannot, it blanks anyway and records a security error rather than pretending the session locked.")
|
||||
.arg(SessiondPolicy.lockAckBudgetSecs)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: SessiondPolicy.available && SessiondPolicy.lockAckBudgetSecs === 0
|
||||
wrapMode: Text.WordWrap
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
text: Translation.tr("This sessiond predates the lock-before-blank ordering. Update the souveraine package to get it.")
|
||||
}
|
||||
}
|
||||
}
|
||||
32
surfaces/quickshell/modules/settings/KeyboardConfig.qml
Normal file
32
surfaces/quickshell/modules/settings/KeyboardConfig.qml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// On-screen keyboard.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "keyboard"
|
||||
title: Translation.tr("On-screen keyboard")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "push_pin"
|
||||
text: Translation.tr("Pinned on startup")
|
||||
checked: Config.options.osk.pinnedOnStartup
|
||||
onCheckedChanged: {
|
||||
Config.options.osk.pinnedOnStartup = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Keep the on-screen keyboard visible from launch rather than on demand.")
|
||||
}
|
||||
}
|
||||
// osk.layout is intentionally not exposed here: its value space
|
||||
// (adapter default "qwerty_full" vs the layouts.js byName registry
|
||||
// keyed by display name like "English (US)") is inconsistent and
|
||||
// needs reconciling before a selector is honest about it.
|
||||
}
|
||||
}
|
||||
320
surfaces/quickshell/modules/settings/LockConfig.qml
Normal file
320
surfaces/quickshell/modules/settings/LockConfig.qml
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.folderlistmodel
|
||||
import Quickshell
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Lock screen — behavior + appearance. Pure Config bindings: every control
|
||||
// writes an option that already exists in Config.qml's adapter, so
|
||||
// persistence + hot-apply come for free.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "lock"
|
||||
title: Translation.tr("Behavior")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "pin"
|
||||
text: Translation.tr("Touch keypad (phone)")
|
||||
checked: Config.options.lock.touchKeypad
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.touchKeypad = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Show the on-lock PIN keypad. The on-screen keyboard can't rise above a session lock, so the lock surface carries its own input.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "rocket_launch"
|
||||
text: Translation.tr("Launch lock on startup")
|
||||
checked: Config.options.lock.launchOnStartup
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.launchOnStartup = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Start the session locked so a PIN is required before the shell is exposed.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "key_off"
|
||||
text: Translation.tr("Require password to power off")
|
||||
checked: Config.options.lock.security.requirePasswordToPower
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.security.requirePasswordToPower = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Guard the power menu behind the lock so the device can't be silenced without a PIN.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "power_settings_new"
|
||||
text: Translation.tr("Allow power off / reboot from lock screen")
|
||||
checked: Config.options.lock.security.allowPowerFromLock
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.security.allowPowerFromLock = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Show the power and reboot buttons on the lock screen. Off by default — a destructive action from the locked surface is opt-in. The buttons still respect “require password to power off” above.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "vpn_key"
|
||||
text: Translation.tr("Unlock keyring on PIN unlock")
|
||||
checked: Config.options.lock.security.unlockKeyring
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.security.unlockKeyring = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Feed the PIN to the keyring so stored secrets unlock together with the session.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "palette"
|
||||
title: Translation.tr("Appearance")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "format_align_center"
|
||||
text: Translation.tr("Center the clock")
|
||||
checked: Config.options.lock.centerClock
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.centerClock = checked;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "schedule"
|
||||
text: Translation.tr("12-hour clock (am/pm)")
|
||||
checked: Config.options.lock.twelveHourClock
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.twelveHourClock = checked;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "text_fields"
|
||||
text: Translation.tr("Show locked text")
|
||||
checked: Config.options.lock.showLockedText
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.showLockedText = checked;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "blur_on"
|
||||
text: Translation.tr("Blur background")
|
||||
checked: Config.options.lock.blur.enable
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.blur.enable = checked;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "category"
|
||||
text: Translation.tr("Material shapes for PIN dots")
|
||||
checked: Config.options.lock.materialShapeChars
|
||||
onCheckedChanged: {
|
||||
Config.options.lock.materialShapeChars = checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
id: lockWallSection
|
||||
icon: "wallpaper"
|
||||
title: Translation.tr("Lock screen wallpaper")
|
||||
|
||||
readonly property string wallpaperDir: Quickshell.env("HOME") + "/Pictures/Wallpapers"
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "sync"
|
||||
text: Translation.tr("Follow system wallpaper")
|
||||
checked: !Config.options.lock.wallpaperPath
|
||||
onCheckedChanged: {
|
||||
if (checked) Config.options.lock.wallpaperPath = "";
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("The lock screen shows the same wallpaper as the shell. Pick an image below to pin the lock screen's own.")
|
||||
}
|
||||
}
|
||||
|
||||
GridView {
|
||||
id: lockWallGrid
|
||||
Layout.fillWidth: true
|
||||
readonly property int cols: 3
|
||||
cellWidth: Math.floor(width / cols)
|
||||
cellHeight: Math.floor(cellWidth * 2)
|
||||
implicitHeight: Math.ceil(lockWallModel.count / cols) * cellHeight
|
||||
interactive: false
|
||||
clip: true
|
||||
|
||||
model: FolderListModel {
|
||||
id: lockWallModel
|
||||
folder: "file://" + lockWallSection.wallpaperDir
|
||||
nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.webp"]
|
||||
showDirs: false
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
required property string filePath
|
||||
width: lockWallGrid.cellWidth
|
||||
height: lockWallGrid.cellHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer1
|
||||
border.width: Config.options.lock.wallpaperPath === filePath ? 3 : 0
|
||||
border.color: Appearance.colors.colPrimary
|
||||
clip: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 3
|
||||
source: "file://" + filePath
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
sourceSize.width: 240
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: Config.options.lock.wallpaperPath = filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "visibility"
|
||||
title: Translation.tr("Lock screen content")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "music_note"
|
||||
text: Translation.tr("Show media controls")
|
||||
checked: Config.options.lock.content.showMediaControls
|
||||
onCheckedChanged: Config.options.lock.content.showMediaControls = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Shows previous, play/pause, and next. Track details remain private unless enabled below.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "visibility"
|
||||
text: Translation.tr("Show media title and artist")
|
||||
checked: Config.options.lock.content.mediaMetadataAmbient
|
||||
enabled: Config.options.lock.content.showMediaControls
|
||||
onCheckedChanged: Config.options.lock.content.mediaMetadataAmbient = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Treats current media metadata as ambient. Leave off to keep it hidden until unlock.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "battery_android_full"
|
||||
text: Translation.tr("Show battery on lock screen")
|
||||
checked: Config.options.lock.content.showBattery
|
||||
onCheckedChanged: Config.options.lock.content.showBattery = checked
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "notifications"
|
||||
text: Translation.tr("Show notifications while locked")
|
||||
checked: Config.options.lock.content.showNotifications
|
||||
onCheckedChanged: Config.options.lock.content.showNotifications = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Shows which apps had notifications arrive and how many. Content stays private unless enabled below.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "visibility"
|
||||
text: Translation.tr("Show notification summaries")
|
||||
checked: Config.options.lock.content.notificationContentAmbient
|
||||
enabled: Config.options.lock.content.showNotifications
|
||||
onCheckedChanged: Config.options.lock.content.notificationContentAmbient = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Treats the notification title line as ambient. Bodies never show on the lock screen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "key"
|
||||
title: Translation.tr("Step-up authentication")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "shield_lock"
|
||||
text: Translation.tr("Enable step-up authentication")
|
||||
checked: Config.options.lock.stepUp.enabled
|
||||
onCheckedChanged: Config.options.lock.stepUp.enabled = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Require re-authentication for sensitive operations (send, delete, payment). Requires the souveraine-stepup PAM service to be installed on the system.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "timer"
|
||||
text: Translation.tr("Grant validity (seconds)")
|
||||
value: Config.options.lock.stepUp.grantTtlMs / 1000
|
||||
from: 30
|
||||
to: 3600
|
||||
stepSize: 30
|
||||
enabled: Config.options.lock.stepUp.enabled
|
||||
onValueChanged: Config.options.lock.stepUp.grantTtlMs = value * 1000
|
||||
StyledToolTip {
|
||||
text: Translation.tr("How long a step-up grant remains valid after authentication. The user can perform sensitive operations within this window without re-authenticating.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "fingerprint"
|
||||
title: Translation.tr("Fingerprint integration")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "fingerprint"
|
||||
text: Translation.tr("Show fingerprint wiring preview")
|
||||
checked: Config.options.lock.fingerprintPreview.enabled
|
||||
onCheckedChanged: Config.options.lock.fingerprintPreview.enabled = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Shows a three-second hold test on the lock screen. It never unlocks the device; post-login Polkit is configured separately below.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "timer"
|
||||
text: Translation.tr("Preview hold (seconds)")
|
||||
value: Config.options.lock.fingerprintPreview.holdMs / 1000
|
||||
from: 1
|
||||
to: 10
|
||||
stepSize: 1
|
||||
enabled: Config.options.lock.fingerprintPreview.enabled
|
||||
onValueChanged: Config.options.lock.fingerprintPreview.holdMs = value * 1000
|
||||
StyledToolTip {
|
||||
text: Translation.tr("This controls the visible lock-screen exercise only. It does not change the temporary post-login Polkit factor.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "admin_panel_settings"
|
||||
text: Translation.tr("Allow temporary FPC confirmation for Polkit")
|
||||
checked: Config.options.lock.fingerprintPolkit.enabled
|
||||
onCheckedChanged: Config.options.lock.fingerprintPolkit.enabled = checked
|
||||
StyledToolTip {
|
||||
text: Translation.tr("After PIN login, user-facing Polkit prompts may accept a fresh reader assertion after the visible confirmation interval. It never unlocks the session or replaces first-login PIN.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
surfaces/quickshell/modules/settings/NavigationConfig.qml
Normal file
42
surfaces/quickshell/modules/settings/NavigationConfig.qml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Souveraine's integrated phone navigation surface.
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "gesture"
|
||||
title: Translation.tr("Layout")
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "swap_vert"
|
||||
text: Translation.tr("Navigation rail height (px)")
|
||||
value: Config.options.dock.gestureRailHeight
|
||||
from: 0
|
||||
to: 96
|
||||
stepSize: 2
|
||||
onValueChanged: Config.options.dock.gestureRailHeight = value
|
||||
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Bottom strip reserved for Souveraine navigation — visually and in the dock's input mask. The navigation rail must always win touch here.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "swipe"
|
||||
title: Translation.tr("Gestures")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Double-tap toggles app fullscreen. Swipe up reveals the dock. Swipe down dismisses the nearest surface: the keyboard when it's open (the rail rides on top of it), otherwise a visible dock — pinned included. Timing and threshold controls will appear here once their defaults prove out.")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
199
surfaces/quickshell/modules/settings/NetworkConfig.qml
Normal file
199
surfaces/quickshell/modules/settings/NetworkConfig.qml
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Bluetooth
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// First-party connectivity page. The controls are direct views over the
|
||||
// resident Network/Cellular/Bluetooth services; no settings-only state is
|
||||
// allowed to pretend a radio changed when its owning service did not.
|
||||
ContentPage {
|
||||
id: page
|
||||
forceWidth: true
|
||||
|
||||
function wifiIcon(strength) {
|
||||
return strength > 80 ? "signal_wifi_4_bar"
|
||||
: strength > 60 ? "network_wifi_3_bar"
|
||||
: strength > 40 ? "network_wifi_2_bar"
|
||||
: strength > 20 ? "network_wifi_1_bar"
|
||||
: "signal_wifi_0_bar";
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "wifi"
|
||||
title: Translation.tr("Wi-Fi")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: Network.materialSymbol
|
||||
text: Network.wifiEnabled
|
||||
? Translation.tr("Wi-Fi on") : Translation.tr("Wi-Fi off")
|
||||
checked: Network.wifiEnabled
|
||||
onCheckedChanged: {
|
||||
if (checked !== Network.wifiEnabled) Network.enableWifi(checked);
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Network.active
|
||||
? Translation.tr("Connected to %1").arg(Network.active.ssid)
|
||||
: Translation.tr("Not connected")
|
||||
color: Appearance.colors.colSubtext
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
RippleButton {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 44
|
||||
enabled: Network.wifiEnabled && !Network.wifiScanning
|
||||
buttonRadius: Appearance.rounding.full
|
||||
onClicked: Network.rescanWifi()
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: Network.wifiScanning ? "progress_activity" : "refresh"
|
||||
iconSize: 21
|
||||
}
|
||||
StyledToolTip { text: Translation.tr("Scan for networks") }
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Network.wifiEnabled ? Network.friendlyWifiNetworks : []
|
||||
|
||||
delegate: DialogListItem {
|
||||
id: networkRow
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
active: modelData.active
|
||||
buttonRadius: Appearance.rounding.normal
|
||||
onClicked: {
|
||||
if (modelData.active) Network.disconnectWifiNetwork();
|
||||
else Network.connectToWifiNetwork(modelData);
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
anchors {
|
||||
fill: parent
|
||||
leftMargin: networkRow.horizontalPadding
|
||||
rightMargin: networkRow.horizontalPadding
|
||||
topMargin: networkRow.verticalPadding
|
||||
bottomMargin: networkRow.verticalPadding
|
||||
}
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
MaterialSymbol {
|
||||
text: page.wifiIcon(networkRow.modelData.strength)
|
||||
iconSize: 22
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: networkRow.modelData.ssid
|
||||
textFormat: Text.PlainText
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
MaterialSymbol {
|
||||
text: networkRow.modelData.active ? "check"
|
||||
: networkRow.modelData.isSecure ? "lock" : ""
|
||||
iconSize: 20
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTextField {
|
||||
Layout.fillWidth: true
|
||||
visible: networkRow.modelData.askingPassword
|
||||
placeholderText: Translation.tr("Network password")
|
||||
echoMode: TextInput.Password
|
||||
inputMethodHints: Qt.ImhSensitiveData
|
||||
onAccepted: {
|
||||
Network.changePassword(networkRow.modelData, text);
|
||||
text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: Cellular.materialSymbol
|
||||
title: Translation.tr("Mobile network")
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Cellular.available
|
||||
? (Cellular.operatorName || Translation.tr("Mobile network"))
|
||||
: Translation.tr("No modem detected")
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Cellular.available
|
||||
? Translation.tr("%1 · %2% signal%3")
|
||||
.arg(Cellular.accessTech || Cellular.state)
|
||||
.arg(Cellular.signalQuality)
|
||||
.arg(Cellular.roaming ? Translation.tr(" · roaming") : "")
|
||||
: Translation.tr("ModemManager has not exposed a modem")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
RippleButton {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 44
|
||||
buttonRadius: Appearance.rounding.full
|
||||
onClicked: Cellular.update()
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: "refresh"
|
||||
iconSize: 21
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "bluetooth"
|
||||
title: Translation.tr("Bluetooth")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: BluetoothStatus.connected ? "bluetooth_connected" : "bluetooth"
|
||||
text: BluetoothStatus.available
|
||||
? Translation.tr("Bluetooth") : Translation.tr("Bluetooth unavailable")
|
||||
enabled: BluetoothStatus.available
|
||||
checked: BluetoothStatus.enabled
|
||||
onCheckedChanged: {
|
||||
if (Bluetooth.defaultAdapter && checked !== Bluetooth.defaultAdapter.enabled)
|
||||
Bluetooth.defaultAdapter.enabled = checked;
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: !BluetoothStatus.available
|
||||
? Translation.tr("BlueZ has not exposed an adapter; no success-shaped toggle is shown.")
|
||||
: BluetoothStatus.activeDeviceCount > 0
|
||||
? Translation.tr("%1 connected device(s)").arg(BluetoothStatus.activeDeviceCount)
|
||||
: Translation.tr("No connected devices")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
80
surfaces/quickshell/modules/settings/OverviewConfig.qml
Normal file
80
surfaces/quickshell/modules/settings/OverviewConfig.qml
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Home screen (app drawer) — the TASK-14 split's launcher half. The drawer
|
||||
// reads its grid straight from Config (see AppGrid.qml), so these knobs are
|
||||
// the single source; the grid re-lays-out on the next drawer open. Rows and
|
||||
// columns at the phone's 540x1080: 4 columns keeps a thumb travel across a
|
||||
// page, 5 rows fills the panel without scrolling. The old desktop overview's
|
||||
// rows/columns are untouched — this page owns Config.options.overview.appGrid.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "grid_view"
|
||||
title: Translation.tr("App drawer")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "apps"
|
||||
text: Translation.tr("Enable drawer")
|
||||
checked: Config.options.overview.enable
|
||||
onCheckedChanged: {
|
||||
Config.options.overview.enable = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("The Home surface: search on top, the app grid below. Off, the pill's swipe-home does nothing.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "view_column"
|
||||
text: Translation.tr("Columns")
|
||||
value: Config.options.overview.appGrid.columns
|
||||
from: 1
|
||||
to: 10
|
||||
stepSize: 1
|
||||
onValueChanged: {
|
||||
Config.options.overview.appGrid.columns = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Icons per row. More columns = denser grid and smaller tiles.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "view_agenda"
|
||||
text: Translation.tr("Rows")
|
||||
value: Config.options.overview.appGrid.rows
|
||||
from: 1
|
||||
to: 10
|
||||
stepSize: 1
|
||||
onValueChanged: {
|
||||
Config.options.overview.appGrid.rows = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Icon rows per page. More rows fills the screen; fewer leaves room for the page dots.")
|
||||
}
|
||||
}
|
||||
|
||||
ConfigSpinBox {
|
||||
icon: "photo_size_select_large"
|
||||
text: Translation.tr("Icon size (px)")
|
||||
value: Config.options.overview.appGrid.iconSize
|
||||
from: 24
|
||||
to: 96
|
||||
stepSize: 4
|
||||
onValueChanged: {
|
||||
Config.options.overview.appGrid.iconSize = value;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("The icon glyph itself; labels always scale to the tile.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
surfaces/quickshell/modules/settings/SoundConfig.qml
Normal file
93
surfaces/quickshell/modules/settings/SoundConfig.qml
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "volume_up"
|
||||
title: Translation.tr("Output")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Audio.sink ? Audio.friendlyDeviceName(Audio.sink)
|
||||
: Translation.tr("No audio output")
|
||||
color: Appearance.colors.colSubtext
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
RippleButton {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 44
|
||||
enabled: Audio.sink ? Audio.sink.ready : false
|
||||
buttonRadius: Appearance.rounding.full
|
||||
onClicked: Audio.toggleMute()
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: Audio.sink && Audio.sink.audio.muted ? "volume_off" : "volume_up"
|
||||
iconSize: 22
|
||||
}
|
||||
}
|
||||
StyledSlider {
|
||||
Layout.fillWidth: true
|
||||
enabled: Audio.sink ? Audio.sink.ready : false
|
||||
value: Audio.sink ? Audio.sink.audio.volume : 0
|
||||
from: 0
|
||||
to: 1
|
||||
onMoved: {
|
||||
if (Audio.sink) Audio.sink.audio.volume = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "mic"
|
||||
title: Translation.tr("Microphone")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Audio.source ? Audio.friendlyDeviceName(Audio.source)
|
||||
: Translation.tr("No microphone")
|
||||
color: Appearance.colors.colSubtext
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
RippleButton {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 44
|
||||
enabled: Audio.source ? Audio.source.ready : false
|
||||
buttonRadius: Appearance.rounding.full
|
||||
onClicked: Audio.toggleMicMute()
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: Audio.source && Audio.source.audio.muted ? "mic_off" : "mic"
|
||||
iconSize: 22
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Audio.source && Audio.source.audio.muted
|
||||
? Translation.tr("Unmute microphone") : Translation.tr("Mute microphone")
|
||||
}
|
||||
}
|
||||
StyledSlider {
|
||||
Layout.fillWidth: true
|
||||
enabled: Audio.source ? Audio.source.ready : false
|
||||
value: Audio.source ? Audio.source.audio.volume : 0
|
||||
from: 0
|
||||
to: 1
|
||||
onMoved: {
|
||||
if (Audio.source) Audio.source.audio.volume = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
surfaces/quickshell/modules/settings/SpeechConfig.qml
Normal file
135
surfaces/quickshell/modules/settings/SpeechConfig.qml
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Io
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Speech services — dictation (STT) and speech synthesis (TTS).
|
||||
// The endpoints live in Config so the souveraine-stt CLI (and the
|
||||
// keyboard's mic key that shells out to it) read exactly what's set
|
||||
// here. The probe hits the STT /health route so "it's configured" and
|
||||
// "it's answering" are visibly different states.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
ContentSection {
|
||||
icon: "mic"
|
||||
title: Translation.tr("Dictation (speech to text)")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "record_voice_over"
|
||||
text: Translation.tr("Enable dictation")
|
||||
checked: Config.options.speech.stt.enable
|
||||
onCheckedChanged: {
|
||||
Config.options.speech.stt.enable = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Voice input through the transcription server. The keyboard's mic key and the souveraine-stt command both use this.")
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTextField {
|
||||
Layout.fillWidth: true
|
||||
text: Config.options.speech.stt.endpoint
|
||||
placeholderText: Translation.tr("Transcription endpoint (http://host:port/transcribe)")
|
||||
onEditingFinished: {
|
||||
if (text !== Config.options.speech.stt.endpoint) {
|
||||
Config.options.speech.stt.endpoint = text;
|
||||
healthProbe.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
|
||||
StyledText {
|
||||
text: healthProbe.statusText
|
||||
color: healthProbe.healthy ? Appearance.colors.colOnLayer1
|
||||
: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
|
||||
RippleButton {
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
buttonRadius: Appearance.rounding.full
|
||||
onClicked: healthProbe.refresh()
|
||||
contentItem: MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: "refresh"
|
||||
iconSize: 18
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Check the server")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentSection {
|
||||
icon: "text_to_speech"
|
||||
title: Translation.tr("Speech synthesis (text to speech)")
|
||||
|
||||
ConfigSwitch {
|
||||
buttonIcon: "campaign"
|
||||
text: Translation.tr("Enable speech output")
|
||||
checked: Config.options.speech.tts.enable
|
||||
onCheckedChanged: {
|
||||
Config.options.speech.tts.enable = checked;
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Spoken responses through the synthesis server. Off until a TTS endpoint exists.")
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTextField {
|
||||
Layout.fillWidth: true
|
||||
text: Config.options.speech.tts.endpoint
|
||||
placeholderText: Translation.tr("Synthesis endpoint (empty = none yet)")
|
||||
onEditingFinished: {
|
||||
if (text !== Config.options.speech.tts.endpoint)
|
||||
Config.options.speech.tts.endpoint = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// STT /health probe. Derives the health URL from the transcribe
|
||||
// endpoint (…/transcribe -> …/health) rather than storing a second URL.
|
||||
QtObject {
|
||||
id: healthProbe
|
||||
property bool healthy: false
|
||||
property string statusText: Translation.tr("Checking server…")
|
||||
|
||||
function refresh() {
|
||||
statusText = Translation.tr("Checking server…");
|
||||
healthy = false;
|
||||
probeProc.running = false;
|
||||
probeProc.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: probeProc
|
||||
running: true
|
||||
command: ["curl", "-s", "--max-time", "5",
|
||||
Config.options.speech.stt.endpoint.replace(/\/[^\/]*$/, "/health")]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const h = JSON.parse(text);
|
||||
healthProbe.healthy = h.status === "ok";
|
||||
healthProbe.statusText = healthProbe.healthy
|
||||
? Translation.tr("Server up · %1 on %2").arg(h.model ?? "?").arg(h.device ?? "?")
|
||||
: Translation.tr("Server answered but not ok");
|
||||
} catch (e) {
|
||||
healthProbe.healthy = false;
|
||||
healthProbe.statusText = Translation.tr("Server unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
278
surfaces/quickshell/modules/settings/WallpaperConfig.qml
Normal file
278
surfaces/quickshell/modules/settings/WallpaperConfig.qml
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.folderlistmodel
|
||||
import Quickshell
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
// Wallpaper — pick from ~/Pictures/Wallpapers inside the settings app.
|
||||
// Settings is a window in the main shell process, so applying calls the
|
||||
// shell-owned Wallpapers singleton directly. Never spawn a nested `qs ipc`
|
||||
// process from this page.
|
||||
|
||||
ContentPage {
|
||||
forceWidth: true
|
||||
|
||||
readonly property string homeDir: Quickshell.env("HOME")
|
||||
property string currentDir: homeDir + "/Pictures/Wallpapers"
|
||||
property var quickDirs: [
|
||||
{ name: "Wallpapers", path: homeDir + "/Pictures/Wallpapers", icon: "wallpaper" },
|
||||
{ name: "Pictures", path: homeDir + "/Pictures", icon: "image" },
|
||||
{ name: "Downloads", path: homeDir + "/Downloads", icon: "download" },
|
||||
{ name: "Home", path: homeDir, icon: "home" }
|
||||
]
|
||||
|
||||
ContentSection {
|
||||
icon: "wallpaper"
|
||||
title: Translation.tr("Wallpaper")
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Current: %1").arg(
|
||||
(Config.options.background.wallpaperPath || Translation.tr("none")).split("/").pop())
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
// Content filter — which wallhaven categories the random pick may
|
||||
// include. Persisted to config.json where the download script reads it.
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Allowed content")
|
||||
color: Appearance.colors.colOnLayer1
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
}
|
||||
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ key: "sfw", label: Translation.tr("SFW") },
|
||||
{ key: "sketchy", label: Translation.tr("Sketchy") },
|
||||
{ key: "nsfw", label: Translation.tr("NSFW") }
|
||||
]
|
||||
|
||||
RippleButton {
|
||||
id: purityChip
|
||||
required property var modelData
|
||||
property bool on: modelData.key === "sfw" ? WallpaperDownload.puritySfw
|
||||
: modelData.key === "sketchy" ? WallpaperDownload.puritySketchy
|
||||
: WallpaperDownload.purityNsfw
|
||||
padding: 8
|
||||
buttonRadius: Appearance.rounding.small
|
||||
toggled: on
|
||||
colBackgroundToggled: Appearance.colors.colSecondaryContainer
|
||||
colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover
|
||||
colRippleToggled: Appearance.colors.colSecondaryContainerActive
|
||||
|
||||
onClicked: {
|
||||
if (modelData.key === "sfw")
|
||||
WallpaperDownload.puritySfw = !WallpaperDownload.puritySfw;
|
||||
else if (modelData.key === "sketchy")
|
||||
WallpaperDownload.puritySketchy = !WallpaperDownload.puritySketchy;
|
||||
else
|
||||
WallpaperDownload.purityNsfw = !WallpaperDownload.purityNsfw;
|
||||
WallpaperDownload.savePurity();
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 4
|
||||
MaterialSymbol {
|
||||
iconSize: 16
|
||||
text: purityChip.on ? "check" : "add"
|
||||
color: purityChip.on ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
|
||||
}
|
||||
StyledText {
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
text: modelData.label
|
||||
color: purityChip.on ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download a fresh random wallpaper from wallhaven. Downloads land in
|
||||
// ~/Pictures/Wallpapers (each a distinct wallhaven_<id> file), then
|
||||
// apply through the shell like any picked image.
|
||||
RippleButton {
|
||||
Layout.fillWidth: true
|
||||
padding: 10
|
||||
buttonRadius: Appearance.rounding.small
|
||||
enabled: !WallpaperDownload.downloading
|
||||
onClicked: WallpaperDownload.download()
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
MaterialSymbol {
|
||||
iconSize: 20
|
||||
text: WallpaperDownload.downloading ? "hourglass_top" : "cloud_download"
|
||||
color: Appearance.colors.colOnLayer1
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: Appearance.font.pixelSize.normal
|
||||
text: WallpaperDownload.downloading
|
||||
? Translation.tr("Downloading…")
|
||||
: Translation.tr("Download random wallpaper")
|
||||
color: Appearance.colors.colOnLayer1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: WallpaperDownload.lastError.length > 0
|
||||
Layout.fillWidth: true
|
||||
text: WallpaperDownload.lastError
|
||||
color: Appearance.colors.colError
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// Quick directory buttons
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: quickDirs
|
||||
|
||||
RippleButton {
|
||||
required property var modelData
|
||||
property bool isCurrent: currentDir === modelData.path
|
||||
padding: 8
|
||||
buttonRadius: Appearance.rounding.small
|
||||
toggled: isCurrent
|
||||
colBackgroundToggled: Appearance.colors.colSecondaryContainer
|
||||
colBackgroundToggledHover: Appearance.colors.colSecondaryContainerHover
|
||||
colRippleToggled: Appearance.colors.colSecondaryContainerActive
|
||||
|
||||
onClicked: {
|
||||
currentDir = modelData.path;
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 4
|
||||
MaterialSymbol {
|
||||
iconSize: 16
|
||||
text: modelData.icon
|
||||
color: isCurrent ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
|
||||
}
|
||||
StyledText {
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
text: modelData.name
|
||||
color: isCurrent ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Current path display
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: currentDir.replace(homeDir, "~")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
GridView {
|
||||
id: grid
|
||||
Layout.fillWidth: true
|
||||
// Rows of ~3 across the phone width; height fits the model.
|
||||
readonly property int cols: 3
|
||||
cellWidth: Math.floor(width / cols)
|
||||
cellHeight: Math.floor(cellWidth * 2) // portrait-ish tiles
|
||||
implicitHeight: Math.ceil(folderModel.count / cols) * cellHeight
|
||||
interactive: false // the page scrolls, not the grid
|
||||
clip: true
|
||||
|
||||
model: FolderListModel {
|
||||
id: folderModel
|
||||
folder: "file://" + currentDir
|
||||
nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.webp", "*.avif"]
|
||||
showDirs: true
|
||||
showDotAndDotDot: false
|
||||
showOnlyReadable: true
|
||||
sortField: FolderListModel.Name
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
required property string filePath
|
||||
required property string fileName
|
||||
required property bool fileIsDir
|
||||
width: grid.cellWidth
|
||||
height: grid.cellHeight
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer1
|
||||
border.width: (!fileIsDir && Config.options.background.wallpaperPath === filePath) ? 3 : 0
|
||||
border.color: Appearance.colors.colPrimary
|
||||
clip: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 3
|
||||
source: fileIsDir ? "" : "file://" + filePath
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
sourceSize.width: 240
|
||||
visible: !fileIsDir
|
||||
}
|
||||
|
||||
// Directory indicator
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
visible: fileIsDir
|
||||
spacing: 4
|
||||
|
||||
MaterialSymbol {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
iconSize: 32
|
||||
text: "folder"
|
||||
color: Appearance.colors.colPrimary
|
||||
}
|
||||
StyledText {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
text: fileName
|
||||
color: Appearance.colors.colOnLayer1
|
||||
elide: Text.ElideRight
|
||||
width: grid.cellWidth - 16
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
if (fileIsDir) {
|
||||
currentDir = filePath;
|
||||
} else {
|
||||
// Shell process owns selection + theming.
|
||||
Wallpapers.apply(filePath);
|
||||
Config.options.background.wallpaperPath = filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: folderModel.count === 0
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("No images in %1").arg(currentDir.replace(homeDir, "~"))
|
||||
color: Appearance.colors.colSubtext
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
21
surfaces/quickshell/modules/settings/qmldir
Normal file
21
surfaces/quickshell/modules/settings/qmldir
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
About 1.0 About.qml
|
||||
AdvancedConfig 1.0 AdvancedConfig.qml
|
||||
BackgroundConfig 1.0 BackgroundConfig.qml
|
||||
BarConfig 1.0 BarConfig.qml
|
||||
DeviceConfig 1.0 DeviceConfig.qml
|
||||
DisplayConfig 1.0 DisplayConfig.qml
|
||||
DockConfig 1.0 DockConfig.qml
|
||||
GeneralConfig 1.0 GeneralConfig.qml
|
||||
IdleConfig 1.0 IdleConfig.qml
|
||||
InterfaceConfig 1.0 InterfaceConfig.qml
|
||||
KeyboardConfig 1.0 KeyboardConfig.qml
|
||||
LockConfig 1.0 LockConfig.qml
|
||||
NavigationConfig 1.0 NavigationConfig.qml
|
||||
NetworkConfig 1.0 NetworkConfig.qml
|
||||
OverviewConfig 1.0 OverviewConfig.qml
|
||||
QuickConfig 1.0 QuickConfig.qml
|
||||
ServicesConfig 1.0 ServicesConfig.qml
|
||||
SettingsHome 1.0 SettingsHome.qml
|
||||
SpeechConfig 1.0 SpeechConfig.qml
|
||||
SoundConfig 1.0 SoundConfig.qml
|
||||
WallpaperConfig 1.0 WallpaperConfig.qml
|
||||
Loading…
Reference in a new issue