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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user