Give the desktop real themes, video wallpapers, and honest titlebars

Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 23:39:04 -04:00
parent 7578348db1
commit cb7c09d208
68 changed files with 6115 additions and 1138 deletions
@@ -118,6 +118,10 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
}
WallpaperIndicator {
anchors.verticalCenter: parent.verticalCenter
}
FocusIndicator {
anchors.verticalCenter: parent.verticalCenter
}
@@ -0,0 +1,40 @@
// Visible only while a video wallpaper is active: one click pauses or resumes
// it. Exists because a playing wallpaper has real costs the user may want to
// stop right now — a remote desktop session, a recording, or just quiet —
// without opening Settings. Same conditional pattern as ActivityIndicator.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: VideoWallpaper.active
horizontalPadding: 10
onActivated: VideoWallpaper.togglePause()
onSecondaryActivated: ShellState.openSettings("appearance")
Accessible.name: VideoWallpaper.paused ? "Resume video wallpaper" : "Pause video wallpaper"
Text {
anchors.verticalCenter: parent.verticalCenter
text: VideoWallpaper.paused ? "\u{F040A}" : "\u{F03E4}" // play / pause
color: VideoWallpaper.paused ? Theme.fgDim : Theme.warn
font.family: Theme.fontMono
font.pixelSize: 13
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: VideoWallpaper.paused
? (VideoWallpaper.gamePaused ? "Paused for game"
: (VideoWallpaper.batteryPaused ? "Paused on battery" : "Paused"))
: "Wallpaper"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
@@ -1,10 +1,18 @@
// Advanced accent editing remains a labelled, keyboard-operable extension of
// the named fast path. Hue is always accompanied by saturation, value, a
// numeric readout, and the two-colour preview supplied by Appearance.
// Hue, saturation and value for both ends of the Prism accent.
//
// This is the fine-tune behind a disclosure, not the fast path: the four wells
// above cover what most edits are, and these six rows are for the last few
// degrees. Each row is a labelled, keyboard-operable slider with a numeric
// readout, because a hue ring alone tells someone with a colour vision
// deficiency nothing.
//
// Committed on a debounce rather than per move. Each move used to write BOTH
// accent preferences, so a single drag across the hue row spent the whole
// gesture in apply-and-verify round trips and left the desktop repainting
// behind the pointer. The sliders now track the drag locally and one commit
// lands once it stops.
import QtQuick
import Quickshell
import Quickshell.Io
import qs.config
import qs.services
import qs.widgets
@@ -16,13 +24,18 @@ Column {
width: parent ? parent.width : 620
spacing: 0
property string pickerTarget: "primary"
property string lastError: ""
// The pair being dragged; null means "nothing pending, show what is stored".
property var pending: null
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(
ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 })
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(
ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 })
readonly property string shownAccent: editor.pending
? editor.pending.accent : String(ThemeProfiles.activeProfile.accent)
readonly property string shownSecondary: editor.pending
? editor.pending.secondary : String(ThemeProfiles.activeProfile.secondary)
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(editor.shownAccent)
|| ({ h: 0, s: 0, v: 0 })
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(editor.shownSecondary)
|| ({ h: 0, s: 0, v: 0 })
function changeChannel(target: string, channel: string, ratio: real): void {
const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv;
@@ -30,34 +43,17 @@ Column {
next[channel] = Math.round(Math.max(0, Math.min(1, ratio))
* (channel === "h" ? 360 : 100));
const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v);
const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent;
const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary;
ThemeProfiles.setAccentPair(primary, secondary);
}
function pick(target: string): void {
if (screenPicker.running)
if (!changed)
return;
editor.pickerTarget = target;
editor.lastError = "";
screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
}
function acceptPicked(value: string): void {
const picked = String(value).trim().toLowerCase();
if (ThemeProfileModel.hexToHsv(picked) === null) {
editor.lastError = "The sampled colour was not valid.";
return;
}
const primary = editor.pickerTarget === "primary"
? picked : ThemeProfiles.activeProfile.accent;
const secondary = editor.pickerTarget === "secondary"
? picked : ThemeProfiles.activeProfile.secondary;
ThemeProfiles.setAccentPair(primary, secondary);
editor.pending = {
accent: target === "primary" ? changed : editor.shownAccent,
secondary: target === "secondary" ? changed : editor.shownSecondary
};
commitTimer.restart();
}
component HsvRow: SettingRow {
id: root
id: row
required property string target
required property string channel
@@ -77,19 +73,18 @@ Column {
activeFocusOnTab: true
Accessible.role: Accessible.Slider
Accessible.name: root.label
Accessible.description: Math.round(root.channelValue) + root.suffix
+ ", range 0 to " + root.channelMaximum
Accessible.name: row.label
Accessible.description: Math.round(row.channelValue) + row.suffix
+ ", range 0 to " + row.channelMaximum
Accessible.focusable: true
Accessible.focused: activeFocus
Accessible.onIncreaseAction: keyboardSlider.step(1)
Accessible.onDecreaseAction: keyboardSlider.step(-1)
function step(direction: int): void {
const increment = root.channel === "h" ? 1 : 1;
const value = Math.max(0, Math.min(root.channelMaximum,
root.channelValue + direction * increment));
editor.changeChannel(root.target, root.channel, value / root.channelMaximum);
const value = Math.max(0, Math.min(row.channelMaximum,
row.channelValue + direction));
editor.changeChannel(row.target, row.channel, value / row.channelMaximum);
}
Keys.onPressed: event => {
@@ -100,10 +95,10 @@ Column {
keyboardSlider.step(1);
event.accepted = true;
} else if (event.key === Qt.Key_Home) {
editor.changeChannel(root.target, root.channel, 0);
editor.changeChannel(row.target, row.channel, 0);
event.accepted = true;
} else if (event.key === Qt.Key_End) {
editor.changeChannel(root.target, root.channel, 1);
editor.changeChannel(row.target, row.channel, 1);
event.accepted = true;
}
}
@@ -117,13 +112,14 @@ Column {
radius: 9
color: "transparent"
border.width: keyboardSlider.activeFocus ? 2 : 1
border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
border.color: keyboardSlider.activeFocus
? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
ValueSlider {
anchors.fill: parent
anchors.margins: 4
value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0
onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio)
value: row.channelMaximum > 0 ? row.channelValue / row.channelMaximum : 0
onMoved: ratio => editor.changeChannel(row.target, row.channel, ratio)
}
}
@@ -133,7 +129,7 @@ Column {
anchors.verticalCenter: parent.verticalCenter
width: 56
horizontalAlignment: Text.AlignRight
text: Math.round(root.channelValue) + root.suffix
text: Math.round(row.channelValue) + row.suffix
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
@@ -165,54 +161,26 @@ Column {
HsvRow {
target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100
label: "Secondary value"; detail: "Brightness from black to full colour"
}
SettingRow {
label: "Pick colour from screen"
detail: editor.lastError !== ""
? editor.lastError
: "Sample either end of the accent gradient with hyprpicker"
divider: false
controlWidth: 330
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
SettingsButton {
text: "Pick primary from screen"
enabled: !screenPicker.running
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: editor.pick("primary")
Keys.onReturnPressed: if (enabled) editor.pick("primary")
Keys.onSpacePressed: if (enabled) editor.pick("primary")
}
SettingsButton {
text: "Pick secondary from screen"
enabled: !screenPicker.running
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: editor.pick("secondary")
Keys.onReturnPressed: if (enabled) editor.pick("secondary")
Keys.onSpacePressed: if (enabled) editor.pick("secondary")
}
Timer {
id: commitTimer
interval: 200
onTriggered: {
if (editor.pending === null)
return;
ThemeProfiles.setAccentPair(editor.pending.accent, editor.pending.secondary);
releaseTimer.restart();
}
}
Process {
id: screenPicker
stdout: StdioCollector {
onStreamFinished: editor.acceptPicked(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
editor.lastError = "Screen colour picking was cancelled or unavailable.";
}
// Hands the sliders back to the stored pair. If the write was refused they
// snap back to what is really in effect rather than showing a colour
// nothing accepted.
Timer {
id: releaseTimer
interval: 160
onTriggered: editor.pending = null
}
}
@@ -1,122 +0,0 @@
// Choosing the desktop accent.
//
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
// dot, because the gradient is the thing being chosen -- the focused window
// border, the bar hairline and every active state are the two colors meeting.
// A row of flat circles would misrepresent all of them.
//
// Named accents rather than a color wheel: each name carries a curated pair
// per scheme, so every choice stays legible in both light and dark. See
// config/Theme.qml for the palette and the reasoning.
import QtQuick
import qs.config
import qs.services
Flow {
id: root
spacing: 10
readonly property string current: ThemeProfiles.activeAccentName
// The schema's own option list, not Theme.accents directly -- two lists
// hand-kept in sync is how they drift. This is the same pattern
// ChoiceRow.qml uses for every other enum row.
readonly property var spec: PreferenceSchema.spec("accentName")
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
Repeater {
// The schema's option order is the palette's order, so blue is first
// because it is what Panama ships.
model: root.options
Column {
id: entry
required property var modelData
readonly property string name: entry.modelData.value
readonly property var pair: Theme.accents[entry.name]
readonly property bool selected: entry.name === root.current
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
spacing: 5
// The hit target is the whole swatch+label unit, not just the
// 46px circle: the label exists specifically so someone with a
// color vision deficiency can identify an accent without it, and
// a label that cannot itself be tapped defeats that.
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (!SystemSettings.commitPreference("accentName", entry.name))
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
}
}
Rectangle {
id: swatch
width: 46
height: 46
radius: 23
anchors.horizontalCenter: parent.horizontalCenter
color: "transparent"
// The ring sits outside the gradient rather than over it, so a
// selected swatch still shows its true colors.
border.width: swatch.activeFocus || entry.selected ? 2 : 1
border.color: swatch.activeFocus
? Theme.accentSecondary
: (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14))
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: entry.pair.label + " accent"
Accessible.description: String(entry.start) + " to " + String(entry.end)
Accessible.focusable: true
Accessible.focused: activeFocus
function choose(): void {
if (!ThemeProfiles.useCuratedAccent(entry.modelData))
console.warn("AccentPicker: accent was rejected", entry.modelData);
}
Keys.onReturnPressed: swatch.choose()
Keys.onSpacePressed: swatch.choose()
Rectangle {
anchors.fill: parent
anchors.margins: entry.selected ? 4 : 3
radius: width / 2
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: entry.start }
GradientStop { position: 1.0; color: entry.end }
}
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: swatch.choose() }
}
// Always shown, not a tooltip. Telling swatches apart by color is
// exactly what someone with a color vision deficiency cannot do,
// and it is the reason the palette is named rather than freeform --
// hiding the names behind a hover would waste that.
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: entry.modelData.label
color: entry.selected ? Theme.fg : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: entry.selected ? Font.DemiBold : Font.Normal
}
}
}
}
@@ -19,16 +19,25 @@ SettingsPage {
property string expandedPicker: ""
// Which group of cards is on screen. Theme leads deliberately: light and
// Whether the accent's HSV fine-tune is open. Collapsed by default: it is
// six sliders that answer a question the four colour wells above have
// usually already answered.
property bool fineTuneOpen: false
// Which group of cards is on screen. Themes leads deliberately: light and
// dark is the control reached most often, and it used to be the third
// section down, below a wallpaper grid and the whole lock screen.
property string tab: "theme"
property string tab: "themes"
// Arriving from another page that named a section opens on it. Taken once
// rather than bound, so the tabs still work normally afterwards.
// rather than bound, so the tabs still work normally afterwards. The old
// `theme` id still resolves: deep links, IPC calls and muscle memory all
// hold it, and it means the same thing this tab does.
Component.onCompleted: {
const section = ShellState.takeSettingsSection();
if (section !== "")
if (section === "theme")
root.tab = "themes";
else if (section !== "")
root.tab = section;
}
@@ -59,7 +68,8 @@ SettingsPage {
SettingsTabs {
tabs: [
{ value: "theme", label: "Theme" },
{ value: "themes", label: "Themes" },
{ value: "editor", label: "Theme editor" },
{ value: "background", label: "Background" },
{ value: "type", label: "Typography" },
{ value: "windows", label: "Windows" },
@@ -110,6 +120,47 @@ SettingsPage {
}
}
SettingsCard {
visible: root.tab === "background"
title: "Video playback"
subtitle: VideoWallpaper.lastError !== ""
? VideoWallpaper.lastError
: "Videos loop muted and decode on the GPU. Considerate by default — a video wallpaper should never cost you a frame you care about."
TextEntryRow { setting: "videoWallpaperDir"; placeholder: "Videos/Wallpapers" }
ToggleRow { setting: "videoWallpaperPauseOnBattery" }
TextRow {
label: "During games and fullscreen"
detail: "Pauses automatically — not optional, and free"
value: "Automatic"
}
TextRow {
label: "Pause from the bar"
detail: "While a video plays, a pill sits in the bar — one click pauses it, for remote desktop or just quiet"
value: ""
}
TextRow {
label: "Lock screen"
detail: "Uses a still frame of the video — hyprlock cannot play motion"
value: "Still frame"
}
ActionRow {
label: "Look for new videos"
detail: VideoWallpaper.available
? VideoWallpaper.candidates.length + " video" + (VideoWallpaper.candidates.length === 1 ? "" : "s") + " found"
: "mpvpaper is not installed — run panama update to pick it up"
action: "Rescan"
divider: false
enabled: VideoWallpaper.available
onTriggered: VideoWallpaper.rescan()
}
}
SettingsCard {
visible: root.tab === "background"
title: "Lock screen"
@@ -129,45 +180,220 @@ SettingsPage {
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
}
SettingsCard {
visible: root.tab === "theme"
// Not "Color scheme" any more: the card holds the saved themes as well
// as the scheme, and a theme carries both ends of the accent with it.
title: "Theme"
subtitle: ColorScheme.lastError !== ""
? ColorScheme.lastError
: "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent."
// ── Themes ──────────────────────────────────────────────────────────────
//
// The gallery is the page, not a card on it. Cards would put a heading and
// a border around each of two lists that are already visually separate, and
// the theme cards themselves are the only surfaces here that need edges.
ThemeProfilePicker {
ThemeModeHero {
visible: root.tab === "themes"
width: parent.width
}
Text {
visible: root.tab === "themes" && ThemeCatalog.lastError !== ""
width: parent.width
text: ThemeCatalog.lastError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
ThemeGallery {
visible: root.tab === "themes"
width: parent.width
scheme: "dark"
heading: "Dark themes"
}
ThemeGallery {
visible: root.tab === "themes"
width: parent.width
scheme: "light"
heading: "Light themes"
}
// Dashed rather than solid, and the width of the page: this is a door out
// of the gallery, not an eleventh theme in it.
Rectangle {
id: buildLink
visible: root.tab === "themes"
width: parent.width
implicitHeight: buildCopy.implicitHeight + 30
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.accent, buildHover.hovered ? 0.12 : 0.07)
border.width: buildLink.activeFocus ? 2 : 1
border.color: buildLink.activeFocus
? Theme.accentSecondary : Theme.alpha(Theme.accent, 0.35)
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: "Build your own theme"
Accessible.description: "Opens the theme editor"
Accessible.focusable: true
Accessible.focused: buildLink.activeFocus
Keys.onReturnPressed: root.tab = "editor"
Keys.onSpacePressed: root.tab = "editor"
Text {
id: buildGlyph
anchors.left: parent.left
anchors.leftMargin: 17
anchors.verticalCenter: parent.verticalCenter
text: "◐"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 20
}
Column {
id: buildCopy
anchors.left: buildGlyph.right
anchors.leftMargin: 13
anchors.right: buildArrow.left
anchors.rightMargin: 13
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: "Build your own theme"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Primary, secondary, background, foreground, effects — every colour yours, saved as a theme that lives in the galleries above"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Text {
id: buildArrow
anchors.right: parent.right
anchors.rightMargin: 17
anchors.verticalCenter: parent.verticalCenter
text: "→"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 17
}
HoverHandler {
id: buildHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.tab = "editor"
}
}
// ── Theme editor ────────────────────────────────────────────────────────
SettingsCard {
id: editorCard
visible: root.tab === "editor"
title: "Your theme"
// The identity of what is being edited, always. Failures get their own
// line below rather than displacing it -- a subtitle that sometimes
// says which theme you are editing and sometimes says something went
// wrong answers neither question reliably.
subtitle: ThemeProfiles.activeProfile.shipped === true
? ThemeProfiles.activeProfile.name
+ " — editing it forks a copy you can name below"
: "Based on " + ThemeProfiles.activeProfile.name
+ " · every change previews live on the real desktop"
Text {
width: parent.width
visible: wells.lastError !== "" || ColorScheme.lastError !== ""
text: wells.lastError !== "" ? wells.lastError : ColorScheme.lastError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
bottomPadding: 10
}
ThemeStartChips {
width: parent.width
bottomPadding: 13
}
SegmentRow {
label: "Scheme"
detail: "Flipping sides returns to the theme you last chose there"
options: [{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]
value: ThemeProfiles.scheme
onSelected: value => ThemeProfiles.setScheme(value)
}
Item { width: 1; height: 13 }
ThemeEditorWells {
id: wells
width: parent.width
}
// Drawn as the gradient each accent produces rather than a flat dot,
// because the gradient is what is being chosen.
AccentPicker {
Item { width: 1; height: 13 }
ThemeSaturationRow {
width: parent.width
}
ActionRow {
label: "Fine-tune"
detail: "Hue, saturation and value for each end of the accent"
action: root.fineTuneOpen ? "Close" : "Open"
onTriggered: root.fineTuneOpen = !root.fineTuneOpen
}
AccentEditor {
visible: root.fineTuneOpen
width: parent.width
}
ThemeSaveRow {
width: parent.width
}
}
// One selector for every font. The two always-open lists and the
// open-one-at-a-time disclosure both retire: a closed dropdown per row
// reads as five choices instead of a wall of samples, and each row names
// what it actually controls — the old tab never said which font was the
// shell's and which was the applications'.
SettingsCard {
visible: root.tab === "type"
title: "Shell typography"
subtitle: Fonts.lastError !== ""
? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name."
title: "Fonts"
subtitle: {
if (Fonts.lastError !== "")
return Fonts.lastError;
if (DesktopStyle.lastError !== "")
return DesktopStyle.lastError;
return "Each row names what it controls. The menu shows every family in its own face — type to filter.";
}
SliderRow { setting: "interfaceFontSize" }
TextRow {
label: "Interface font"
SettingRow {
label: "Interface"
detail: Fonts.interfaceMissing
? "Not installed on this machine — fontconfig is substituting something else"
: "Used for all shell text"
value: Fonts.interfaceFont
? "Not installed — fontconfig is substituting something else"
: "The shell — bar, dock, notifications, this window"
divider: false
controlWidth: 8
}
FontPicker {
@@ -178,12 +404,13 @@ SettingsPage {
onPicked: family => Fonts.setInterface(family)
}
TextRow {
label: "Icon font"
SettingRow {
label: "Icons"
detail: Fonts.iconMissing
? "Not installed — the shell's glyphs will not draw correctly"
: "Draws the shell's glyphs, so only Nerd Fonts are offered"
value: Fonts.iconFont
: "Glyphs only, never text — needs a Nerd Font"
divider: false
controlWidth: 8
}
FontPicker {
@@ -193,77 +420,68 @@ SettingsPage {
emptyText: "No Nerd Fonts installed"
onPicked: family => Fonts.setIcon(family)
}
}
SettingsCard {
visible: root.tab === "type"
title: "Application typography"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
ActionRow {
label: "Application font"
detail: DesktopStyle.applicationFont
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
SettingRow {
label: "Application"
detail: "GTK and libadwaita apps that follow the desktop default"
divider: false
controlWidth: 8
}
FontPicker {
visible: root.expandedPicker === "application-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.applicationFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
onPicked: family => {
if (DesktopStyle.setApplicationFont(family))
root.expandedPicker = "";
}
onPicked: family => DesktopStyle.setApplicationFont(family)
}
SliderRow { setting: "applicationFontSize" }
ActionRow {
label: "Document font"
detail: DesktopStyle.documentFont
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
SettingRow {
label: "Document"
detail: "Long-form reading, where applications ask for it"
divider: false
controlWidth: 8
}
FontPicker {
visible: root.expandedPicker === "document-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.documentFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
onPicked: family => {
if (DesktopStyle.setDocumentFont(family))
root.expandedPicker = "";
}
onPicked: family => DesktopStyle.setDocumentFont(family)
}
SliderRow { setting: "documentFontSize" }
ActionRow {
label: "Monospace font"
detail: DesktopStyle.monospaceFont
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
SettingRow {
label: "Monospace"
detail: "Terminals and code, wherever fixed width is asked for"
divider: false
controlWidth: 8
}
FontPicker {
visible: root.expandedPicker === "monospace-font"
width: parent.width
families: Fonts.monospaceFonts
current: DesktopStyle.monospaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
onPicked: family => {
if (DesktopStyle.setMonospaceFont(family))
root.expandedPicker = "";
}
onPicked: family => DesktopStyle.setMonospaceFont(family)
}
}
SettingsCard {
visible: root.tab === "type"
title: "Sizes"
subtitle: "The interface size moves the shell's whole scale; the rest are per role."
SliderRow { setting: "interfaceFontSize" }
SliderRow { setting: "applicationFontSize" }
SliderRow { setting: "documentFontSize" }
SliderRow { setting: "monospaceFontSize"; divider: false }
}
SettingsCard {
visible: root.tab === "type"
title: "Rendering"
SliderRow { setting: "monospaceFontSize" }
ChoiceRow { setting: "fontHinting" }
ChoiceRow { setting: "fontAntialiasing"; divider: false }
}
@@ -322,11 +540,10 @@ SettingsPage {
SettingsCard {
visible: root.tab === "windows"
title: "Titlebars"
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
subtitle: "One rule for GNOME applications and Panama's own windows. There is no minimize or maximize toggle: Hyprland has no minimize, so the buttons would be lies."
ChoiceRow { setting: "titlebarButtonSide" }
ToggleRow { setting: "titlebarMaximizeButton" }
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
ToggleRow { setting: "panamaTitlebar" }
ChoiceRow { setting: "titlebarButtonSide"; divider: false }
}
SettingsCard {
@@ -344,10 +561,13 @@ SettingsPage {
SliderRow { setting: "fullscreenOpacity"; divider: false }
}
// Effects belong to the theme now: saving snapshots all ten values into the
// record, and applying a theme that carries them puts them back. They sit
// in the editor rather than beside the galleries for exactly that reason.
SettingsCard {
visible: root.tab === "theme"
visible: root.tab === "editor"
title: "Effects"
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
subtitle: "Part of your theme — corners, blur, shadows, glow and motion save with it. Each costs frame time; turning one off is a legitimate way to buy it back while gaming."
ToggleRow { setting: "blurEnabled" }
SliderRow { setting: "blurSize" }
@@ -0,0 +1,221 @@
// One color of a theme, with the three honest ways to change it.
//
// The swatch is the value, large enough to judge against the card it sits on.
// Under it: the hex, typed and committed on Enter or when focus leaves --
// never per keystroke, because a half-typed "#82a" is a real color and would
// repaint the whole desktop on the way to the one you meant. A rejected value
// marks the field and snaps back rather than quietly keeping bad text.
//
// The wheel and the eyedropper are the other two, and all three are keyboard
// reachable: this component is the reason the old accent editor could not be
// operated without a mouse.
import QtQuick
import qs.config
Rectangle {
id: root
property string role: ""
property string swatchColor: "#000000"
property bool picking: false
// A hex the caller accepted. Rejected values never reach here.
signal committed(value: string)
signal wheelRequested
signal pickRequested
// Set while the field holds something that is not a #rrggbb.
property bool invalid: false
implicitHeight: body.implicitHeight + 22
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.045)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
function normalized(value: string): string {
const text = String(value).trim().toLowerCase();
const prefixed = text.startsWith("#") ? text : "#" + text;
return /^#[0-9a-f]{6}$/.test(prefixed) ? prefixed : "";
}
function commit(): void {
const hex = root.normalized(hexInput.text);
if (hex === "") {
root.invalid = true;
return;
}
root.invalid = false;
hexInput.text = hex;
if (hex !== String(root.swatchColor).toLowerCase())
root.committed(hex);
}
// An accepted change elsewhere -- a theme click, the eyedropper, the HSV
// sliders -- replaces what is shown, unless it would yank the field out
// from under someone mid-edit.
onSwatchColorChanged: if (!hexInput.activeFocus) {
hexInput.text = String(root.swatchColor).toLowerCase();
root.invalid = false;
}
// The wheel and eyedropper buttons: square, glyph-only, and each its own
// focus stop with a spoken name, because a 30px square with a ⌖ in it is
// otherwise unidentifiable to anything but sight.
component IconButton: Rectangle {
id: button
property string glyph: ""
property string title: ""
property bool enabled: true
signal triggered
width: 30
height: 30
radius: 8
opacity: button.enabled ? 1 : 0.45
color: hover.hovered && button.enabled
? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
border.width: button.activeFocus ? 2 : 1
border.color: button.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.1)
activeFocusOnTab: button.enabled
Accessible.role: Accessible.Button
Accessible.name: button.title
Accessible.focusable: true
Accessible.focused: button.activeFocus
Keys.onReturnPressed: if (button.enabled) button.triggered()
Keys.onSpacePressed: if (button.enabled) button.triggered()
Text {
anchors.centerIn: parent
text: button.glyph
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
HoverHandler {
id: hover
enabled: button.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: button.enabled
onTapped: button.triggered()
}
}
Column {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
spacing: 7
Text {
text: root.role
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.6
}
Rectangle {
width: parent.width
height: 44
radius: 9
color: root.swatchColor
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.18)
}
Item {
width: parent.width
height: 30
Rectangle {
id: hexField
anchors.left: parent.left
anchors.right: buttons.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.07)
border.width: hexInput.activeFocus || root.invalid ? 2 : 1
border.color: root.invalid
? Theme.danger
: (hexInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.12))
TextInput {
id: hexInput
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
activeFocusOnTab: true
verticalAlignment: TextInput.AlignVCenter
maximumLength: 7
text: String(root.swatchColor).toLowerCase()
color: root.invalid ? Theme.danger : Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.4)
selectedTextColor: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
clip: true
Accessible.role: Accessible.EditableText
Accessible.name: root.role + " colour, hex"
onTextEdited: root.invalid = false
onAccepted: root.commit()
onActiveFocusChanged: {
if (hexInput.activeFocus)
return;
// Leaving with something unusable in the field reverts
// rather than leaving a lie on screen.
if (root.normalized(hexInput.text) === "") {
root.invalid = false;
hexInput.text = String(root.swatchColor).toLowerCase();
return;
}
root.commit();
}
}
}
Row {
id: buttons
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
IconButton {
glyph: "◐"
title: "Open the colour wheel for " + root.role
onTriggered: root.wheelRequested()
}
IconButton {
glyph: "⌖"
title: "Pick " + root.role + " from the screen"
enabled: !root.picking
onTriggered: root.pickRequested()
}
}
}
}
}
@@ -50,13 +50,13 @@ Rectangle {
// Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page.
// Follows the color scheme. A preview that stays dark while the shell
// around it is light does not read as "your desktop" -- it reads as a
// screenshot of someone else's.
// Derived from the active theme's palette rather than fixed hexes, so the
// preview re-grounds itself when a theme is chosen -- a Gruvbox desktop
// whose preview stayed Tokyo-blue would read as someone else's.
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
GradientStop { position: 0.0; color: Theme.mix(Theme.bgHighlight, Theme.accent, 0.12) }
GradientStop { position: 1.0; color: Theme.mix(Theme.bgDark, Theme.accentSecondary, 0.10) }
}
// The bar, so the preview reads as this desktop and not a generic one.
@@ -209,7 +209,8 @@ Rectangle {
shadowEnabled: true
shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha(Theme.dark ? "#15161e" : "#6172b0", Theme.dark ? 0.85 : 0.45)
: Theme.alpha(Theme.dark ? Theme.mix(Theme.bgDark, "#000000", 0.35) : Theme.fgDim,
Theme.dark ? 0.85 : 0.45)
shadowBlur: win.focused && root.glowOn
? Math.min(1.0, root.px(root.glowRange) / 12)
: Math.min(1.0, root.px(root.shadowRange) / 24)
@@ -1,8 +1,13 @@
// Choosing a font family.
//
// Each candidate is rendered IN the font it names. A list of family names set
// in the current font tells you nothing about what you are choosing, and the
// whole point of picking a typeface is seeing it.
// A closed dropdown: the button shows the current family rendered in its own
// face, and opening it reveals a search field and the matches — each drawn IN
// the font it names, because a list of family names set in the current font
// tells you nothing about what you are choosing.
//
// Closed by default on purpose. This used to be an always-open search-and-list
// that put twenty-four sample rows on the Typography tab before you asked for
// any of them.
import QtQuick
import qs.config
@@ -16,6 +21,7 @@ Column {
property var families: []
property string current: ""
property string emptyText: "No fonts found"
property bool open: false
signal picked(string family)
@@ -31,50 +37,122 @@ Column {
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: "Search installed fonts"
function choose(family: string): void {
root.picked(family);
root.open = false;
filter.text = "";
}
Repeater {
model: root.matches
// ── The closed state: one button ────────────────────────────────────────
SettingRow {
id: candidate
Rectangle {
id: closedButton
required property var modelData
required property int index
width: parent.width
height: 38
radius: 10
color: buttonMouse.containsMouse || root.open || activeFocus
? Theme.alpha(Theme.fg, 0.09)
: Theme.alpha(Theme.fg, 0.055)
border.width: root.open || activeFocus ? 2 : 1
border.color: root.open || activeFocus
? Theme.alpha(Theme.accent, 0.55)
: Theme.alpha(Theme.fg, 0.07)
activeFocusOnTab: true
readonly property bool selected: candidate.modelData === root.current
Accessible.role: Accessible.ComboBox
Accessible.name: (root.current !== "" ? root.current : "Choose a font")
Keys.onReturnPressed: root.open = !root.open
Keys.onSpacePressed: root.open = !root.open
label: candidate.modelData
detail: candidate.selected ? "Currently in use" : ""
controlWidth: 210
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData)
Text {
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: chevron.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: root.current !== "" ? root.current : "Choose a font"
elide: Text.ElideRight
// The current family draws itself — the closed state is a sample.
font.family: root.current !== "" ? root.current : Theme.fontFamily
font.pixelSize: Theme.fontSize
color: root.current !== "" ? Theme.fg : Theme.fgMuted
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 200
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
// The sample is drawn in the candidate family, which is the
// entire reason this is a list rather than a text field.
text: "Handgloves 0123"
font.family: candidate.modelData
font.pixelSize: Theme.fontSize + 1
color: candidate.selected ? Theme.accent : Theme.fgDim
}
Text {
id: chevron
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.open ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 11
}
MouseArea {
id: buttonMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.open = !root.open
}
}
SettingRow {
Item { width: 1; height: root.open ? 8 : 0 }
// ── The open state: search plus matches ─────────────────────────────────
Column {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
visible: root.open
spacing: 0
SearchField {
id: filter
width: parent.width
placeholder: "Search installed fonts"
}
Repeater {
model: root.open ? root.matches : []
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData === root.current
label: candidate.modelData
detail: candidate.selected ? "Currently in use" : ""
controlWidth: 210
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.choose(candidate.modelData)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 200
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
// The sample is drawn in the candidate family, which is the
// entire reason this is a list rather than a text field.
text: "Handgloves 0123"
font.family: candidate.modelData
font.pixelSize: Theme.fontSize + 1
color: candidate.selected ? Theme.accent : Theme.fgDim
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
}
@@ -78,6 +78,73 @@ The final card is the ownership boundary. Network configuration and the exact
Users, Sharing, Color profiles, and Digital wellbeing handoffs open GNOME
Settings because Fedora's system services own those areas.
## Appearance
Six tabs, in the order the questions are actually asked: **Themes**, **Theme
editor**, **Background**, **Typography**, **Windows**, **Shell**. Themes leads
because light and dark is the control reached most often, and it used to be the
third section down, under a wallpaper grid and the whole lock screen. The old
`theme` section id still resolves to it, so deep links and IPC calls keep
working.
### Themes
`config/themes.json` is the catalog: ten shipped themes, each a full palette of
nineteen tokens plus sixteen ANSI colors. `services/ThemeCatalog.qml` reads it
with a `FileView` and carries Moon and Day as an embedded fallback, so the
shell renders correctly for the instant before the file loads and forever on a
machine where it is missing.
The tab is two galleries — dark and light — rather than a card each. Selection
is **per mode**: `themeDark` and `themeLight` remember what you chose on each
side, so flipping light and dark lands on your theme for that side rather than
resetting to the shipped defaults.
`services/ThemeProfiles.qml` resolves the active record; `Theme.qml` reads
every one of its nineteen color tokens from `ThemeProfiles.activePalette`.
There are no palette ternaries left in `Theme.qml`, which is what makes a
tenth theme a data change rather than a code change.
### Theme editor
Four wells — primary, secondary, background, foreground — a saturation slider,
an effects card, and the six HSV rows demoted to a fine-tune behind a
disclosure. Background and foreground are never stored alone: the five surfaces
and two text tints are mixed from them, so changing the ground moves the whole
family instead of leaving twelve tokens pointing at the old one.
Every route that writes color — typed hex, the colour wheel, the eyedropper,
the HSV sliders — ends in `ThemeProfiles.commitActive`, which also recomputes
`accentName` as the nearest curated accent. That is what keeps GNOME's accent
enum, kitty's border and the lock screen from going stale after a custom edit.
Editing a shipped theme forks it into a named custom carrying the whole
palette; saving snapshots the palette, the terminal colors, and all ten effect
values into the record.
The HSV rows commit on a **debounce**, not per move. Writing on every move
meant one drag across the hue row spent the whole gesture in apply-and-verify
round trips with the desktop repainting behind the pointer.
### Backgrounds, still and moving
Stills go through hyprpaper. Videos go through mpvpaper, supervised by
`services/VideoWallpaper.qml`, and Panama owns the pause policy rather than the
compositor: the video pauses whenever a game runs, on battery if the preference
says so, and whenever the bar pill is clicked — over mpv's JSON IPC socket, so
resuming does not restart the clip. hyprpaper's service is stopped while a
video plays, because both claim the background layer and stacking within a
layer is creation order. The lock screen gets a cached still frame; hyprlock
cannot play motion, and pretending otherwise would show a black screen.
### The honest titlebar
There is no minimize or maximize control, and no setting for one. Hyprland has
no minimize — it receives the request and does nothing with it — and maximize
is noise in a tiler, so `DesktopStyle` pushes a close-only GNOME button layout
and Panama's own Settings titlebar shows one button. `panamaTitlebar` turns
that bar off entirely, leaving the window pure Hyprland: Super+Q closes,
Super+drag moves, Escape still works.
## Adding a setting
One schema entry. That is the whole job.
@@ -141,13 +208,15 @@ or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract`.
Window border color follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
**accent role**, driven by the chosen `accentName` and also owned by
`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
`ColorScheme.qml` restates the focused border alongside the inactive one on
every scheme change, rather than leaving a scheme flip to erase a
user-selected accent.
neutral contrast role — a **scheme-relative role** in the sense that it exists
to stay legible on either ground — owned by `ColorScheme.qml`, and it is drawn
from the active theme's `gutter` rather than from a hardcoded pair. The two
literals that used to live there were correct for two of the ten shipped themes
and for no custom one. The focused Prism border is the **accent role**, driven
by the chosen `accentName` and also owned by `ColorScheme.qml`: each accent
carries a separate pair for light and dark. Both roles are restated on a scheme
change, an accent change, and a theme change, rather than leaving a flip to
erase a user-selected accent or a theme switch to leave the border behind.
## The rows
@@ -40,18 +40,30 @@ Rectangle {
onPageRequested: page => ShellState.openSettings(page)
}
// The one titlebar Panama draws, so it follows Panama's own titlebar
// rules: the close button sits on the configured side, there is no
// minimize (Hyprland has no minimize — the button this bar used to show
// was a lie), and turning the titlebar off leaves the window pure
// Hyprland: Super+Q closes, Super+drag moves, Escape still works.
Rectangle {
id: titlebar
readonly property bool shown: DesktopPreferences.get("panamaTitlebar") !== false
readonly property bool buttonsLeft: DesktopPreferences.get("titlebarButtonSide") === "left"
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: 48
height: shown ? 48 : 0
visible: shown
color: Theme.alpha(Theme.bgDark, 0.97)
border.width: 0
Text {
anchors.left: parent.left
anchors.left: titlebar.buttonsLeft ? undefined : parent.left
anchors.right: titlebar.buttonsLeft ? parent.right : undefined
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Settings"
color: Theme.fg
@@ -60,24 +72,42 @@ Rectangle {
font.weight: Font.DemiBold
}
Row {
anchors.right: parent.right
Rectangle {
id: closeButton
anchors.left: titlebar.buttonsLeft ? parent.left : undefined
anchors.right: titlebar.buttonsLeft ? undefined : parent.right
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 7
width: 28
height: 28
radius: 9
color: closeMouse.containsMouse || activeFocus
? Theme.alpha(Theme.danger, 0.17) : Theme.alpha(Theme.fg, 0)
border.width: activeFocus ? 2 : 0
border.color: Theme.alpha(Theme.danger, 0.6)
activeFocusOnTab: true
Rectangle {
width: 28; height: 28; radius: 9
color: minMouse.containsMouse ? Theme.alpha(Theme.fg, 0.12) : Theme.alpha(Theme.fg, 0)
Text { anchors.centerIn: parent; text: "—"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
MouseArea { id: minMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (root.hostWindow) root.hostWindow.minimized = true }
Accessible.role: Accessible.Button
Accessible.name: "Close Settings"
Keys.onReturnPressed: ShellState.closeSettings()
Keys.onSpacePressed: ShellState.closeSettings()
Text {
anchors.centerIn: parent
text: "×"
color: closeMouse.containsMouse || closeButton.activeFocus ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 18
}
Rectangle {
width: 28; height: 28; radius: 9
color: closeMouse.containsMouse ? Theme.alpha(Theme.danger, 0.17) : Theme.alpha(Theme.fg, 0)
Text { anchors.centerIn: parent; text: "×"; color: closeMouse.containsMouse ? Theme.danger : Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: 18 }
MouseArea { id: closeMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: ShellState.closeSettings() }
MouseArea {
id: closeMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: ShellState.closeSettings()
}
}
@@ -0,0 +1,207 @@
// One theme, drawn as the desktop it produces.
//
// A row of flat swatches would name a theme's colors without showing what any
// of them does. So the card is a miniature of the real thing: the ground, a
// window sitting on it wearing the accent as a border, the two text weights
// the shell actually reads with, and only then the four remaining colors as
// dots. Clicking applies it immediately -- there is no preview mode here,
// because the whole shell is the preview.
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
// A catalog record or a saved profile -- the same shape either way.
required property var theme
signal chosen
signal removed
// A custom profile saved before palettes existed carries none of its own
// and inherits its scheme's default, exactly as ThemeProfiles resolves it.
readonly property var palette: root.theme.palette
?? ThemeCatalog.defaultPalette(root.theme.scheme)
readonly property bool active: root.theme.id === ThemeProfiles.activeId
readonly property bool shipped: root.theme.shipped === true
readonly property bool isDefault: root.theme.id === (root.theme.scheme === "light"
? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark)
implicitHeight: preview.height + caption.height + 4
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.fg, 0.04)
border.width: 2
border.color: root.activeFocus
? Theme.accentSecondary
: (root.active ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
clip: true
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: root.theme.name
Accessible.description: (root.theme.scheme === "light" ? "Light theme" : "Dark theme")
+ (root.active ? ", selected" : "")
Accessible.focusable: true
Accessible.focused: root.activeFocus
Keys.onReturnPressed: root.chosen()
Keys.onSpacePressed: root.chosen()
Rectangle {
id: preview
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 2
height: 84
color: root.palette.bg
border.width: 0
Rectangle {
anchors.fill: parent
anchors.margins: 11
radius: 7
color: root.palette.bgPanel
border.width: 2
border.color: root.theme.accent
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
spacing: 7
Rectangle {
width: parent.width * 0.64
height: 4
radius: 2
color: root.palette.fg
border.width: 0
}
Row {
spacing: 4
Repeater {
model: [root.theme.accent, root.theme.secondary,
root.palette.fgDim, root.palette.fgMuted]
Rectangle {
required property var modelData
width: 9
height: 9
radius: 5
color: modelData
border.width: 0
}
}
}
}
}
}
Item {
id: caption
anchors.left: parent.left
anchors.right: parent.right
anchors.top: preview.bottom
anchors.leftMargin: 12
anchors.rightMargin: 10
height: 30
Text {
id: name
anchors.left: parent.left
anchors.right: marks.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: root.theme.name + (root.isDefault ? " · today's default" : "")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Row {
id: marks
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.active
text: "✓"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
// Only saved themes can be removed, and the affordance is a
// separate focus stop so Tab never lands on "delete" when the
// person meant "select".
Rectangle {
id: remove
anchors.verticalCenter: parent.verticalCenter
visible: !root.shipped
width: 22
height: 22
radius: 7
color: removeHover.hovered
? Theme.alpha(Theme.danger, 0.22)
: Theme.alpha(Theme.fg, 0.07)
border.width: remove.activeFocus ? 2 : 1
border.color: remove.activeFocus
? Theme.danger : Theme.alpha(Theme.fg, 0.08)
activeFocusOnTab: visible
Accessible.role: Accessible.Button
Accessible.name: "Delete " + root.theme.name
Accessible.focusable: true
Accessible.focused: remove.activeFocus
Keys.onReturnPressed: root.removed()
Keys.onSpacePressed: root.removed()
Text {
anchors.centerIn: parent
text: "✕"
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler {
id: removeHover
cursorShape: Qt.PointingHandCursor
}
// An exclusive grab, so the delete press does not also reach
// the card's own tap handler underneath and select the theme
// on its way out.
TapHandler {
gesturePolicy: TapHandler.ReleaseWithinBounds
onTapped: root.removed()
}
}
}
}
HoverHandler {
id: cardHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.chosen()
}
}
@@ -0,0 +1,119 @@
// The four colors a theme is actually made of.
//
// Primary and Secondary are the two ends of the Prism accent. Background and
// Foreground are the ground and the text, and neither is stored alone: the
// surfaces (bgDark, bgPanel, bgHighlight, bgPopover, gutter) and the two text
// tints (fgDim, fgMuted) are mixed from them, so changing the ground moves the
// whole family with it rather than leaving twelve tokens pointing at the old
// one.
//
// One eyedropper process and one colour wheel are shared by all four wells --
// the target is remembered rather than duplicated four times.
import QtQuick
import QtQuick.Dialogs
import Quickshell
import Quickshell.Io
import qs.config
import qs.services
Grid {
id: root
width: parent ? parent.width : 620
columns: root.width < 520 ? 1 : 2
spacing: 12
// Which well the wheel or the eyedropper is currently answering.
property string target: "primary"
property string lastError: ""
readonly property real cellWidth: root.columns > 1
? (root.width - root.spacing) / 2 : root.width
function currentColor(which: string): string {
if (which === "primary")
return String(ThemeProfiles.activeProfile.accent);
if (which === "secondary")
return String(ThemeProfiles.activeProfile.secondary);
if (which === "background")
return String(ThemeProfiles.activePalette.bg);
return String(ThemeProfiles.activePalette.fg);
}
// Every route through this component ends here, so the wheel, the
// eyedropper and the typed hex cannot drift apart.
function apply(which: string, hex: string): void {
const value = String(hex).trim().toLowerCase();
if (!/^#[0-9a-f]{6}$/.test(value)) {
root.lastError = "That is not a colour this theme can use.";
return;
}
root.lastError = "";
if (which === "primary")
ThemeProfiles.setAccentPair(value, ThemeProfiles.activeProfile.secondary);
else if (which === "secondary")
ThemeProfiles.setAccentPair(ThemeProfiles.activeProfile.accent, value);
else if (which === "background")
ThemeProfiles.setGroundColors(value, ThemeProfiles.activePalette.fg);
else
ThemeProfiles.setGroundColors(ThemeProfiles.activePalette.bg, value);
}
function pick(which: string): void {
if (screenPicker.running)
return;
root.target = which;
root.lastError = "";
screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
}
function openWheel(which: string): void {
root.target = which;
wheel.selectedColor = root.currentColor(which);
wheel.open();
}
Repeater {
model: [
{ key: "primary", role: "Primary" },
{ key: "secondary", role: "Secondary" },
{ key: "background", role: "Background" },
{ key: "foreground", role: "Foreground" },
]
ColorWell {
required property var modelData
width: root.cellWidth
role: modelData.role
swatchColor: root.currentColor(modelData.key)
picking: screenPicker.running
onCommitted: value => root.apply(modelData.key, value)
onWheelRequested: root.openWheel(modelData.key)
onPickRequested: root.pick(modelData.key)
}
}
ColorDialog {
id: wheel
title: "Choose a colour"
// Qt hands back a color value, not a string; toString() on it is
// "#aarrggbb" when there is alpha, so the channels are read directly.
onAccepted: root.apply(root.target, Qt.rgba(
wheel.selectedColor.r, wheel.selectedColor.g,
wheel.selectedColor.b, 1).toString())
}
Process {
id: screenPicker
stdout: StdioCollector {
onStreamFinished: root.apply(root.target, this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Screen colour picking was cancelled or unavailable.";
}
}
}
@@ -0,0 +1,56 @@
// Every theme for one scheme, shipped and saved together.
//
// Saved themes are not a second-class list below the shipped ones: a theme you
// made is a theme, and once it is in the gallery it is chosen the same way. The
// grid drops to two columns on a narrowly tiled window, because a third card at
// 900px is narrower than its own caption.
import QtQuick
import qs.config
import qs.services
Column {
id: root
required property string scheme
property string heading: ""
width: parent ? parent.width : 620
spacing: 10
readonly property var themes: ThemeProfiles.profiles.filter(
profile => profile.scheme === root.scheme)
readonly property int columns: root.width < 640 ? 2 : 3
readonly property real cellWidth: root.columns > 0
? (root.width - (root.columns - 1) * 12) / root.columns : 200
Text {
text: root.heading
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.7
}
Grid {
width: parent.width
columns: root.columns
spacing: 12
Repeater {
model: root.themes
ThemeCard {
required property var modelData
width: root.cellWidth
theme: modelData
onChosen: ThemeProfiles.selectProfile(modelData.id)
onRemoved: ThemeProfiles.deleteProfile(modelData.id)
}
}
}
}
@@ -0,0 +1,186 @@
// Light or dark, as two desktops rather than two words.
//
// This is the control reached most often in the whole of Appearance, so it
// leads the category and it is drawn, not labelled: each card is the scheme's
// remembered theme -- yours, not the shipped default -- rendered as a bar, a
// focused window and the text on it. Choosing a side returns to whatever theme
// you last chose for that side; it never resets you to Moon or Day.
import QtQuick
import qs.config
import qs.services
Row {
id: root
width: parent ? parent.width : 620
spacing: 14
// The record the scheme would land on, which is the honest thing to show.
// themeForScheme already falls back to the catalog default when the
// remembered id no longer exists.
function themeFor(scheme: string): var {
const id = ThemeProfiles.themeForScheme(scheme);
const record = ThemeCatalog.byId(id)
?? ThemeProfiles.customProfiles.find(profile => profile.id === id);
if (record)
return record;
const fallback = ThemeCatalog.byId(scheme === "light"
? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark);
return fallback ?? { scheme: scheme, accent: Theme.accent,
secondary: Theme.accentSecondary, palette: ThemeCatalog.defaultPalette(scheme) };
}
Repeater {
model: [
{ scheme: "dark", label: "Dark" },
{ scheme: "light", label: "Light" },
]
Rectangle {
id: card
required property var modelData
readonly property var theme: root.themeFor(card.modelData.scheme)
readonly property var palette: card.theme.palette
?? ThemeCatalog.defaultPalette(card.modelData.scheme)
readonly property bool selected: ThemeProfiles.scheme === card.modelData.scheme
width: (root.width - root.spacing) / 2
implicitHeight: scene.height + caption.height + 4
radius: Theme.cardRadius + 4
color: Theme.alpha(Theme.fg, 0.04)
border.width: 2
border.color: card.activeFocus
? Theme.accentSecondary
: (card.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.13))
clip: true
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: card.modelData.label + " mode"
Accessible.description: card.theme.name
? card.theme.name + (card.selected ? ", selected" : "")
: ""
Accessible.focusable: true
Accessible.focused: card.activeFocus
function choose(): void {
ThemeProfiles.setScheme(card.modelData.scheme);
}
Keys.onReturnPressed: card.choose()
Keys.onSpacePressed: card.choose()
Rectangle {
id: scene
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 2
height: 110
color: card.palette.bg
border.width: 0
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 15
spacing: 10
// The bar, edge to edge on the real desktop and so drawn
// wide and short here.
Rectangle {
width: parent.width * 0.6
height: 8
radius: 4
color: card.palette.bgDark
border.width: 1
border.color: card.palette.bgHighlight
}
Rectangle {
width: parent.width
height: 56
radius: 8
color: card.palette.bgPanel
border.width: 2
border.color: card.theme.accent
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 9
spacing: 6
Rectangle {
width: parent.width * 0.6
height: 5
radius: 3
color: card.palette.fg
border.width: 0
}
Rectangle {
width: parent.width * 0.42
height: 5
radius: 3
color: card.palette.fgDim
border.width: 0
}
}
}
}
}
Item {
id: caption
anchors.left: parent.left
anchors.right: parent.right
anchors.top: scene.bottom
anchors.leftMargin: 14
anchors.rightMargin: 12
height: 34
Text {
anchors.left: parent.left
anchors.right: tick.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: card.modelData.label
+ (card.theme.name ? " · " + card.theme.name : "")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
id: tick
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: card.selected
text: "✓"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: card.choose()
}
}
}
}
@@ -1,123 +0,0 @@
// Named themes are the first layer of Appearance: shipped profiles stay
// immutable, while saved profiles can be selected or removed in place.
import QtQuick
import qs.config
import qs.services
Column {
id: root
width: parent ? parent.width : 620
spacing: 0
Repeater {
model: ThemeProfiles.profiles
SettingRow {
id: profileRow
required property var modelData
label: profileRow.modelData.name
detail: (profileRow.modelData.scheme === "light" ? "Light" : "Dark")
+ " · " + profileRow.modelData.accent + " → " + profileRow.modelData.secondary
controlWidth: profileRow.modelData.shipped ? 88 : 170
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
SettingsButton {
id: useButton
text: profileRow.modelData.id === ThemeProfiles.activeProfile.id ? "Selected" : "Use"
enabled: profileRow.modelData.id !== ThemeProfiles.activeProfile.id
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: ThemeProfiles.selectProfile(profileRow.modelData.id)
Keys.onReturnPressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
Keys.onSpacePressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
}
SettingsButton {
id: deleteButton
visible: !profileRow.modelData.shipped
text: "Delete"
activeFocusOnTab: visible
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08)
onClicked: ThemeProfiles.deleteProfile(profileRow.modelData.id)
Keys.onReturnPressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
Keys.onSpacePressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
}
}
}
}
SettingRow {
label: "Save current theme"
detail: "Stores this scheme and accent pair under a unique name"
divider: false
controlWidth: 292
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Rectangle {
width: 196
height: 31
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: nameInput.activeFocus ? 2 : 1
border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
activeFocusOnTab: true
verticalAlignment: TextInput.AlignVCenter
maximumLength: 40
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.35)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
Accessible.name: "Theme profile name"
Text {
anchors.verticalCenter: parent.verticalCenter
visible: nameInput.text === "" && !nameInput.activeFocus
text: "Theme name"
color: Theme.fgMuted
font: nameInput.font
}
onAccepted: saveButton.save()
}
}
SettingsButton {
id: saveButton
text: "Save"
tone: "accent"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : "transparent"
function save(): void {
if (ThemeProfiles.saveProfile(nameInput.text))
nameInput.text = "";
}
onClicked: save()
Keys.onReturnPressed: save()
Keys.onSpacePressed: save()
}
}
}
}
@@ -0,0 +1,191 @@
// Saturation, as a nudge rather than a stored number.
//
// There is no saturation preference: `setSaturation` re-saturates the palette
// that is in effect right now, which makes it compounding -- committing 1.5
// twice really is 2.25. So the row keeps the reading it has shown you and
// commits the RATIO between the old reading and the new one, which makes the
// number on screen an honest total rather than a lever that lies the second
// time it is pulled. Selecting a different theme resets the reading to neutral,
// because the new palette is a new ground.
//
// The commit happens on release only. A drag through 0.6 → 0.9 → 1.4 would
// otherwise rewrite all nineteen tokens at every pixel, and each rewrite is
// lossy: HSV saturation cannot be recovered once it has been rounded to a hex.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
label: "Saturation"
detail: "Shifts every derived surface and text tone together"
controlWidth: 280
// 1.0 is the palette as it stands. Range 0 2, shown as 0 200%.
property real shown: 1
property real committed: 1
readonly property real maximum: 2
// A theme change replaces the ground this row was measuring against.
readonly property string activeId: ThemeProfiles.activeId
onActiveIdChanged: {
root.shown = 1;
root.committed = 1;
}
function apply(): void {
if (Math.abs(root.shown - root.committed) < 0.005)
return;
// Dividing by the last reading turns an absolute slider into the
// relative factor the service actually takes.
const factor = root.committed > 0.02 ? root.shown / root.committed : root.maximum;
if (ThemeProfiles.setSaturation(Math.max(0, Math.min(root.maximum, factor))))
root.committed = root.shown;
else
root.shown = root.committed;
}
function moveTo(ratio: real): void {
root.shown = Math.round(Math.max(0, Math.min(1, ratio)) * root.maximum * 100) / 100;
}
function step(direction: int): void {
root.moveTo((root.shown + direction * 0.05) / root.maximum);
root.apply();
}
Item {
id: control
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 270
height: 32
activeFocusOnTab: true
Accessible.role: Accessible.Slider
Accessible.name: root.label
Accessible.description: Math.round(root.shown * 100) + " percent, range 0 to 200"
Accessible.focusable: true
Accessible.focused: control.activeFocus
Accessible.onIncreaseAction: root.step(1)
Accessible.onDecreaseAction: root.step(-1)
Keys.onPressed: event => {
if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) {
root.step(-1);
event.accepted = true;
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) {
root.step(1);
event.accepted = true;
} else if (event.key === Qt.Key_Home) {
root.moveTo(0);
root.apply();
event.accepted = true;
} else if (event.key === Qt.Key_End) {
root.moveTo(1);
root.apply();
event.accepted = true;
}
}
Rectangle {
id: frame
anchors.left: parent.left
anchors.right: readout.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
height: 24
radius: 9
color: "transparent"
border.width: control.activeFocus ? 2 : 1
border.color: control.activeFocus
? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
// Written out rather than reusing ValueSlider: this row needs the
// release, and ValueSlider reports only movement.
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 4
height: 10
radius: 5
color: Theme.alpha(Theme.fg, 0.12)
border.width: 0
Rectangle {
id: fill
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, root.shown / root.maximum))
radius: parent.radius
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.accent }
GradientStop { position: 1.0; color: Theme.accentSecondary }
}
}
Rectangle {
width: 16
height: 16
radius: 8
border.width: 0
color: Theme.fg
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(parent.width - width, fill.width - width / 2))
visible: drag.containsMouse || drag.pressed
}
MouseArea {
id: drag
anchors.fill: parent
anchors.margins: -8
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
function ratioAt(mouseX: real): real {
return (mouseX - 8) / track.width;
}
onPressed: event => root.moveTo(drag.ratioAt(event.x))
onPositionChanged: event => {
if (drag.pressed)
root.moveTo(drag.ratioAt(event.x));
}
onReleased: root.apply()
onWheel: event => {
root.moveTo((root.shown + (event.angleDelta.y > 0 ? 0.05 : -0.05))
/ root.maximum);
root.apply();
}
}
}
}
Text {
id: readout
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 56
horizontalAlignment: Text.AlignRight
text: Math.round(root.shown * 100) + "%"
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
@@ -0,0 +1,105 @@
// Naming what you built.
//
// Saving snapshots the palette, the terminal colours and the ten effect values
// as they stand, so a saved theme restores the whole look rather than two
// accent hexes. Delete appears only for a saved theme, because a shipped one
// cannot be removed -- it is read from the catalog every launch.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
readonly property bool custom: ThemeProfiles.activeProfile.shipped !== true
label: "Save as"
detail: root.custom
? "Saving again under a new name keeps both"
: "Editing a shipped theme forks it; give the fork a name to keep it"
divider: false
controlWidth: root.custom ? 380 : 292
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Rectangle {
width: 196
height: 31
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: nameInput.activeFocus ? 2 : 1
border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
activeFocusOnTab: true
verticalAlignment: TextInput.AlignVCenter
maximumLength: 40
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.35)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
Accessible.role: Accessible.EditableText
Accessible.name: "Theme name"
Text {
anchors.verticalCenter: parent.verticalCenter
visible: nameInput.text === "" && !nameInput.activeFocus
text: "Theme name"
color: Theme.fgMuted
font: nameInput.font
}
onAccepted: saveButton.save()
}
}
SettingsButton {
id: saveButton
text: "Save theme"
tone: "accent"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : "transparent"
function save(): void {
if (ThemeProfiles.saveProfile(nameInput.text))
nameInput.text = "";
}
onClicked: saveButton.save()
Keys.onReturnPressed: saveButton.save()
Keys.onSpacePressed: saveButton.save()
}
SettingsButton {
id: deleteButton
visible: root.custom
text: "Delete"
tone: "danger"
activeFocusOnTab: visible
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.danger, 0.45)
function remove(): void {
ThemeProfiles.deleteProfile(ThemeProfiles.activeId);
}
onClicked: deleteButton.remove()
Keys.onReturnPressed: deleteButton.remove()
Keys.onSpacePressed: deleteButton.remove()
}
}
}
@@ -0,0 +1,98 @@
// Where the theme you are editing came from.
//
// Editing a shipped theme forks it into a saved copy rather than changing what
// ships, so "start from" is the honest name for this row: picking a chip
// abandons the current edits and begins again from that theme. Only themes of
// the current scheme are offered -- a light theme cannot be the starting point
// for a dark one without also flipping the desktop underneath the editor.
import QtQuick
import qs.config
import qs.services
Flow {
id: root
width: parent ? parent.width : 620
spacing: 8
readonly property var themes: ThemeProfiles.profiles.filter(
profile => profile.scheme === ThemeProfiles.scheme)
Repeater {
model: root.themes
Rectangle {
id: chip
required property var modelData
readonly property bool active: chip.modelData.id === ThemeProfiles.activeId
implicitWidth: caption.implicitWidth + dot.width + 26
implicitHeight: 31
radius: Theme.pillRadius
color: chip.active
? Theme.alpha(Theme.accent, 0.12)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.12 : 0.06)
border.width: chip.activeFocus || chip.active ? 2 : 1
border.color: chip.activeFocus
? Theme.accentSecondary
: (chip.active ? Theme.accent : Theme.alpha(Theme.fg, 0.1))
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: "Start from " + chip.modelData.name
Accessible.focusable: true
Accessible.focused: chip.activeFocus
function choose(): void {
ThemeProfiles.selectProfile(chip.modelData.id);
}
Keys.onReturnPressed: chip.choose()
Keys.onSpacePressed: chip.choose()
// The gradient, not a flat dot: the pair is what the chip stands
// for, exactly as on the accent swatches it replaces.
Rectangle {
id: dot
anchors.left: parent.left
anchors.leftMargin: 7
anchors.verticalCenter: parent.verticalCenter
width: 17
height: 17
radius: 9
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: chip.modelData.accent }
GradientStop { position: 1.0; color: chip.modelData.secondary }
}
}
Text {
id: caption
anchors.left: dot.right
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: chip.modelData.name
color: chip.active ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: chip.active ? Font.DemiBold : Font.Medium
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: chip.choose()
}
}
}
}
@@ -34,16 +34,24 @@ Item {
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
readonly property int collapsedRows: 2
readonly property var shown: root.expanded
? Wallpaper.available
: Wallpaper.available.slice(0, root.columns * root.collapsedRows)
// Videos join the grid only in single mode — slideshow and per-display
// are hyprpaper's modes and stills-only.
readonly property var catalog: root.mode === "single"
? Wallpaper.available.concat(VideoWallpaper.candidates)
: Wallpaper.available
readonly property int hidden: Wallpaper.available.length - root.shown.length
readonly property var shown: root.expanded
? root.catalog
: root.catalog.slice(0, root.columns * root.collapsedRows)
readonly property int hidden: root.catalog.length - root.shown.length
readonly property int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
function isCurrent(path: string): bool {
if (VideoWallpaper.active)
return VideoWallpaper.path === path;
return root.activeByOutput[root.selectedOutput] === path;
}
@@ -80,6 +88,7 @@ Item {
required property var modelData
readonly property bool current: root.isCurrent(tile.modelData)
readonly property bool video: VideoWallpaper.isVideo(tile.modelData)
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
width: root.cellWidth
@@ -93,7 +102,8 @@ Item {
id: thumbnail
anchors.fill: parent
source: "file://" + tile.modelData
visible: !tile.video
source: tile.video ? "" : "file://" + tile.modelData
fillMode: Image.PreserveAspectCrop
asynchronous: true
// Decode to roughly the size actually drawn. Without this a
@@ -120,6 +130,48 @@ Item {
}
}
// A video tile draws a play badge instead of a thumbnail —
// decoding a frame per tile is exactly the cost this feature
// is designed to avoid paying casually.
Rectangle {
anchors.fill: parent
visible: tile.video
radius: parent.radius
gradient: Gradient {
GradientStop { position: 0; color: Theme.alpha(Theme.accent, 0.14) }
GradientStop { position: 1; color: Theme.alpha(Theme.bgDark, 0.9) }
}
Text {
anchors.centerIn: parent
text: "\u{F040A}"
color: Theme.alpha(Theme.fg, 0.8)
font.family: Theme.fontMono
font.pixelSize: 26
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 7
width: videoBadge.implicitWidth + 14
height: 20
radius: Theme.pillRadius
color: Theme.alpha(Theme.bgDark, 0.75)
Text {
id: videoBadge
anchors.centerIn: parent
text: "VIDEO"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
font.letterSpacing: 0.5
}
}
}
Text {
anchors.centerIn: parent
visible: thumbnail.status === Image.Loading
@@ -83,7 +83,13 @@ PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
AccentPicker 1.0 AccentPicker.qml
TextFieldRow 1.0 TextFieldRow.qml
AccentEditor 1.0 AccentEditor.qml
ThemeProfilePicker 1.0 ThemeProfilePicker.qml
ThemeCard 1.0 ThemeCard.qml
ThemeGallery 1.0 ThemeGallery.qml
ThemeModeHero 1.0 ThemeModeHero.qml
ColorWell 1.0 ColorWell.qml
ThemeEditorWells 1.0 ThemeEditorWells.qml
ThemeSaturationRow 1.0 ThemeSaturationRow.qml
ThemeStartChips 1.0 ThemeStartChips.qml
ThemeSaveRow 1.0 ThemeSaveRow.qml