Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5562323eb8 | ||
|
|
52d896b054 | ||
|
|
e4409ed6aa | ||
|
|
f0321432b4 | ||
|
|
607acb0a2d | ||
|
|
9b2fa80ab7 | ||
|
|
816ea68f9a | ||
|
|
3ff414fb1b | ||
|
|
0fd59c59e3 | ||
|
|
b1f9891849 | ||
|
|
6026bf308b | ||
|
|
14529c011f | ||
|
|
7378c18ab9 | ||
|
|
9ce7040b91 | ||
|
|
09e5f1ad6b | ||
|
|
7541a45e77 | ||
|
|
1a91d00f2c | ||
|
|
19063a3e02 |
@@ -13,3 +13,10 @@
|
|||||||
# Python helper bytecode is local runtime state.
|
# Python helper bytecode is local runtime state.
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
||||||
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
|
/config/dot/kitty/current-theme.conf
|
||||||
|
|
||||||
|
# Generated from the colour scheme; machine state, not configuration.
|
||||||
|
/config/dot/gtk-3.0/settings.ini
|
||||||
|
/config/dot/gtk-4.0/settings.ini
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ same Tokyo Night Moon palette.
|
|||||||
| Piece | What it is |
|
| Piece | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
||||||
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Panama Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
|
||||||
| `config/dot/vicinae/` | Raycast-style launcher, themed |
|
| `config/dot/vicinae/` | Raycast-style launcher, themed |
|
||||||
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
|
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
|
||||||
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
|
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
# GENERATED FILE -- edit settings.ini.template instead.
|
||||||
|
#
|
||||||
|
# The theme name and dark preference below follow Panama's colour
|
||||||
|
# scheme, so this file is regenerated on every switch and is not
|
||||||
|
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||||
|
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||||
|
# settings daemon, so for GTK3 it is authoritative -- which is why it
|
||||||
|
# has to change with the scheme rather than being pinned to dark.
|
||||||
|
#
|
||||||
[Settings]
|
[Settings]
|
||||||
# These values must match `gsettings get org.gnome.desktop.interface ...`.
|
# These values must match `gsettings get org.gnome.desktop.interface ...`.
|
||||||
#
|
#
|
||||||
@@ -6,12 +15,12 @@
|
|||||||
# this file becomes authoritative for GTK3 — which is why it previously named
|
# this file becomes authoritative for GTK3 — which is why it previously named
|
||||||
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
# themes that aren't installed (Tahoe-Dark, WhiteSur-cursors) without anything
|
||||||
# appearing broken.
|
# appearing broken.
|
||||||
gtk-theme-name=adw-gtk3-dark
|
gtk-theme-name=@GTK_THEME@
|
||||||
gtk-icon-theme-name=Adwaita
|
gtk-icon-theme-name=Adwaita
|
||||||
gtk-font-name=Adwaita Sans 11
|
gtk-font-name=Adwaita Sans 11
|
||||||
gtk-cursor-theme-name=oreo_blue_cursors
|
gtk-cursor-theme-name=oreo_blue_cursors
|
||||||
gtk-cursor-theme-size=24
|
gtk-cursor-theme-size=24
|
||||||
gtk-application-prefer-dark-theme=1
|
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||||
|
|
||||||
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
gtk-toolbar-style=GTK_TOOLBAR_ICONS
|
||||||
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
|
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[Settings]
|
|
||||||
# libadwaita apps normally take their dark preference from the
|
|
||||||
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
|
|
||||||
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
|
|
||||||
# window before the portal answers.
|
|
||||||
gtk-application-prefer-dark-theme=1
|
|
||||||
gtk-theme-name=adw-gtk3-dark
|
|
||||||
gtk-icon-theme-name=Adwaita
|
|
||||||
gtk-font-name=Adwaita Sans 11
|
|
||||||
gtk-cursor-theme-name=oreo_blue_cursors
|
|
||||||
gtk-cursor-theme-size=24
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# GENERATED FILE -- edit settings.ini.template instead.
|
||||||
|
#
|
||||||
|
# The theme name and dark preference below follow Panama's colour
|
||||||
|
# scheme, so this file is regenerated on every switch and is not
|
||||||
|
# committed. Under GNOME, gnome-settings-daemon publishes these over
|
||||||
|
# XSETTINGS and this file is ignored; under Hyprland there is no
|
||||||
|
# settings daemon, so for GTK3 it is authoritative -- which is why it
|
||||||
|
# has to change with the scheme rather than being pinned to dark.
|
||||||
|
#
|
||||||
|
[Settings]
|
||||||
|
# libadwaita apps normally take their dark preference from the
|
||||||
|
# org.freedesktop.appearance portal (served by xdg-desktop-portal-gtk, which
|
||||||
|
# reads gsettings). This file is the fallback for plain GTK4 apps and for the
|
||||||
|
# window before the portal answers.
|
||||||
|
gtk-application-prefer-dark-theme=@PREFER_DARK@
|
||||||
|
gtk-theme-name=@GTK_THEME@
|
||||||
|
gtk-icon-theme-name=Adwaita
|
||||||
|
gtk-font-name=Adwaita Sans 11
|
||||||
|
gtk-cursor-theme-name=oreo_blue_cursors
|
||||||
|
gtk-cursor-theme-size=24
|
||||||
@@ -36,7 +36,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
|||||||
| Removable media | udiskie plus udisks notifications | Live |
|
| Removable media | udiskie plus udisks notifications | Live |
|
||||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||||
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||||
|
|
||||||
## GNOME extension migration
|
## GNOME extension migration
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Don't "fix" them.
|
|||||||
| File | Contents |
|
| File | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
|
||||||
| `prefs.lua` | Reads the settings file Panama Settings writes. See below |
|
| `prefs.lua` | Reads the settings file the Settings app writes. See below |
|
||||||
| `env.lua` | Environment. Note the uwsm caveat below |
|
| `env.lua` | Environment. Note the uwsm caveat below |
|
||||||
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
|
||||||
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
|
||||||
@@ -59,7 +59,7 @@ generated elsewhere:
|
|||||||
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
|
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
|
||||||
settings store and restarts the daemon. `hypridle.conf` in this directory
|
settings store and restarts the daemon. `hypridle.conf` in this directory
|
||||||
remains the shipped default and is what runs when the drop-in is not installed;
|
remains the shipped default and is what runs when the drop-in is not installed;
|
||||||
Panama Settings shows which of the two states you are in rather than presenting
|
The Settings app shows which of the two states you are in rather than presenting
|
||||||
controls that quietly do nothing.
|
controls that quietly do nothing.
|
||||||
|
|
||||||
Remove the drop-in and go back to the shipped config with:
|
Remove the drop-in and go back to the shipped config with:
|
||||||
@@ -77,7 +77,7 @@ relationship is:
|
|||||||
`prefs.get("key", <shipped value>)`, so the config still works standalone with
|
`prefs.get("key", <shipped value>)`, so the config still works standalone with
|
||||||
no settings file at all.
|
no settings file at all.
|
||||||
- **The JSON is the truth.** Hyprland and Quickshell both read it.
|
- **The JSON is the truth.** Hyprland and Quickshell both read it.
|
||||||
- **Panama Settings is the editor.** It writes the file *and* applies the change
|
- **The Settings app is the editor.** It writes the file *and* applies the change
|
||||||
live, so nothing needs a reload and the two sides cannot drift apart.
|
live, so nothing needs a reload and the two sides cannot drift apart.
|
||||||
|
|
||||||
To add an adjustable setting: add an entry to
|
To add an adjustable setting: add an entry to
|
||||||
@@ -95,7 +95,7 @@ accepts the config in each of those states.
|
|||||||
|
|
||||||
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
|
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
|
||||||
substitutes the chord from a stored override, so shortcuts can be moved from
|
substitutes the chord from a stored override, so shortcuts can be moved from
|
||||||
Panama Settings without editing this file.
|
the Settings app without editing this file.
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||||
@@ -118,7 +118,7 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
|
|||||||
|
|
||||||
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
||||||
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
|
||||||
one has nothing readable beside its chord, and Panama Settings drops it from the
|
one has nothing readable beside its chord, and the Settings app drops it from the
|
||||||
Input & Shortcuts page rather than showing a mystery row.
|
Input & Shortcuts page rather than showing a mystery row.
|
||||||
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
|
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
|
||||||
this cannot regress silently.
|
this cannot regress silently.
|
||||||
@@ -275,7 +275,7 @@ The mental model is unchanged from Forge:
|
|||||||
| `SUPER + V` | Clipboard history |
|
| `SUPER + V` | Clipboard history |
|
||||||
| `SUPER + .` | Emoji picker |
|
| `SUPER + .` | Emoji picker |
|
||||||
| `SUPER + S` | Quick settings |
|
| `SUPER + S` | Quick settings |
|
||||||
| `SUPER + I` | Panama Settings |
|
| `SUPER + I` | Settings |
|
||||||
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
| `SUPER + SHIFT + F` | Start or reveal focus session |
|
||||||
| `SUPER + B` | Notification centre |
|
| `SUPER + B` | Notification centre |
|
||||||
| `SUPER + grave` | Workspace overview |
|
| `SUPER + grave` | Workspace overview |
|
||||||
@@ -288,7 +288,7 @@ The mental model is unchanged from Forge:
|
|||||||
|
|
||||||
### Apps
|
### Apps
|
||||||
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
|
`SUPER + T` terminal · `N` neovim · `W` browser · `F` files · `C` calculator ·
|
||||||
`E` mail · `I` Panama Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
|
`E` mail · `I` Settings · `CTRL + SHIFT + Esc` system monitor. GNOME
|
||||||
Settings remains searchable in Vicinae for hardware and account panels.
|
Settings remains searchable in Vicinae for hardware and account panels.
|
||||||
|
|
||||||
## Calendar and notifications
|
## Calendar and notifications
|
||||||
@@ -326,7 +326,7 @@ one place. Home and Phone continue the same surface rather than opening extra
|
|||||||
dashboard windows.
|
dashboard windows.
|
||||||
|
|
||||||
Home shows the first four selected favourites at rest and every selected light
|
Home shows the first four selected favourites at rest and every selected light
|
||||||
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
|
when expanded. Use **Settings → Home & Phone** to choose favourites,
|
||||||
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
||||||
only previews the value; releasing it sends one brightness request. A normal
|
only previews the value; releasing it sends one brightness request. A normal
|
||||||
power toggle leaves Home Assistant responsible for restoring its previous
|
power toggle leaves Home Assistant responsible for restoring its previous
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
|
|||||||
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
|
||||||
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
|
||||||
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
|
||||||
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Panama Settings" })
|
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||||
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
|
||||||
|
|
||||||
-- ── Launcher ────────────────────────────────────────────────────────────────
|
-- ── Launcher ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ font_size 14.0
|
|||||||
|
|
||||||
#: Cursor customization {{{
|
#: Cursor customization {{{
|
||||||
|
|
||||||
cursor #c8d3f5
|
|
||||||
|
|
||||||
#: Default cursor color. If set to the special value none the cursor
|
#: Default cursor color. If set to the special value none the cursor
|
||||||
#: will be rendered with a "reverse video" effect. It's color will be
|
#: will be rendered with a "reverse video" effect. It's color will be
|
||||||
@@ -193,7 +192,6 @@ cursor #c8d3f5
|
|||||||
#: precedence. Also, the cursor colors are modified if the cell
|
#: precedence. Also, the cursor colors are modified if the cell
|
||||||
#: background and foreground colors have very low contrast.
|
#: background and foreground colors have very low contrast.
|
||||||
|
|
||||||
cursor_text_color #111325
|
|
||||||
|
|
||||||
#: The color of text under the cursor. If you want it rendered with
|
#: The color of text under the cursor. If you want it rendered with
|
||||||
#: the background color of the cell underneath instead, use the
|
#: the background color of the cell underneath instead, use the
|
||||||
@@ -312,7 +310,6 @@ cursor_shape block
|
|||||||
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
||||||
#: much effort.
|
#: much effort.
|
||||||
|
|
||||||
url_color #4fd6be
|
|
||||||
# url_style curly
|
# url_style curly
|
||||||
|
|
||||||
#: The color and style for highlighting URLs on mouse-over. url_style
|
#: The color and style for highlighting URLs on mouse-over. url_style
|
||||||
@@ -734,12 +731,10 @@ window_border_width 1pt
|
|||||||
#: placed centrally. A value of top-left means the padding will be
|
#: placed centrally. A value of top-left means the padding will be
|
||||||
#: only at the bottom and right edges.
|
#: only at the bottom and right edges.
|
||||||
|
|
||||||
active_border_color #82aaff
|
|
||||||
|
|
||||||
#: The color for the border of the active window. Set this to none to
|
#: The color for the border of the active window. Set this to none to
|
||||||
#: not draw borders around the active window.
|
#: not draw borders around the active window.
|
||||||
|
|
||||||
inactive_border_color #2f334d
|
|
||||||
|
|
||||||
#: The color for the border of inactive windows.
|
#: The color for the border of inactive windows.
|
||||||
|
|
||||||
@@ -960,16 +955,11 @@ tab_powerline_style round
|
|||||||
#: Template to use for active tabs. If not specified falls back to
|
#: Template to use for active tabs. If not specified falls back to
|
||||||
#: tab_title_template.
|
#: tab_title_template.
|
||||||
|
|
||||||
active_tab_foreground #1e2030
|
|
||||||
active_tab_background #82aaff
|
|
||||||
# active_tab_font_style bold-italic
|
# active_tab_font_style bold-italic
|
||||||
inactive_tab_foreground #c8d3f5
|
|
||||||
inactive_tab_background #42465a
|
|
||||||
# inactive_tab_font_style normal
|
# inactive_tab_font_style normal
|
||||||
|
|
||||||
#: Tab bar colors and styles.
|
#: Tab bar colors and styles.
|
||||||
|
|
||||||
tab_bar_background #222436
|
|
||||||
|
|
||||||
#: Background color for the tab bar. Defaults to using the terminal
|
#: Background color for the tab bar. Defaults to using the terminal
|
||||||
#: background color.
|
#: background color.
|
||||||
@@ -983,8 +973,8 @@ tab_bar_margin_color none
|
|||||||
|
|
||||||
#: Color scheme {{{
|
#: Color scheme {{{
|
||||||
|
|
||||||
foreground #c8d3f5
|
|
||||||
background #222436
|
include current-theme.conf
|
||||||
|
|
||||||
#: The foreground and background colors.
|
#: The foreground and background colors.
|
||||||
|
|
||||||
@@ -1043,8 +1033,6 @@ dim_opacity 1
|
|||||||
#: How much to dim text that has the DIM/FAINT attribute set. One
|
#: How much to dim text that has the DIM/FAINT attribute set. One
|
||||||
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
||||||
|
|
||||||
selection_foreground #2d3f76
|
|
||||||
selection_background #c8d3f5
|
|
||||||
|
|
||||||
#: The foreground and background colors for text selected with the
|
#: The foreground and background colors for text selected with the
|
||||||
#: mouse. Setting both of these to none will cause a "reverse video"
|
#: mouse. Setting both of these to none will cause a "reverse video"
|
||||||
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
|
|||||||
#: dull and bright version, for the first 16 colors. You can set the
|
#: dull and bright version, for the first 16 colors. You can set the
|
||||||
#: remaining 240 colors as color16 to color255.
|
#: remaining 240 colors as color16 to color255.
|
||||||
|
|
||||||
color0 #1b1d2b
|
|
||||||
color8 #444a73
|
|
||||||
|
|
||||||
#: black
|
#: black
|
||||||
|
|
||||||
color1 #ff757f
|
|
||||||
color9 #ff757f
|
|
||||||
|
|
||||||
#: red
|
#: red
|
||||||
|
|
||||||
color2 #a5e8b5
|
|
||||||
color10 #a5e8b5
|
|
||||||
|
|
||||||
#: green
|
#: green
|
||||||
color3 #ffc777
|
|
||||||
color11 #ffc777
|
|
||||||
|
|
||||||
#: yellow
|
#: yellow
|
||||||
|
|
||||||
color4 #82aaff
|
|
||||||
color12 #82aaff
|
|
||||||
|
|
||||||
#: blue
|
#: blue
|
||||||
|
|
||||||
color5 #c099ff
|
|
||||||
color13 #c099ff
|
|
||||||
|
|
||||||
#: magenta
|
#: magenta
|
||||||
|
|
||||||
color6 #86e1fc
|
|
||||||
color14 #86e1fc
|
|
||||||
|
|
||||||
#: cyan
|
#: cyan
|
||||||
|
|
||||||
color7 #828bb8
|
|
||||||
color15 #c8d3f5
|
|
||||||
|
|
||||||
#: white
|
#: white
|
||||||
|
|
||||||
color16 #ff966c
|
|
||||||
color17 #c53b53
|
|
||||||
|
|
||||||
# mark1_foreground black
|
# mark1_foreground black
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Tokyo Night Day — the palette's own light variant.
|
||||||
|
#
|
||||||
|
# The same hues as Moon at a different lightness, so a terminal in light mode
|
||||||
|
# still belongs to this desktop rather than looking like a different machine.
|
||||||
|
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
|
||||||
|
|
||||||
|
cursor #3760bf
|
||||||
|
cursor_text_color #e1e2e7
|
||||||
|
url_color #118c74
|
||||||
|
active_border_color #2e7de9
|
||||||
|
inactive_border_color #c4c8da
|
||||||
|
active_tab_foreground #e1e2e7
|
||||||
|
active_tab_background #2e7de9
|
||||||
|
inactive_tab_foreground #3760bf
|
||||||
|
inactive_tab_background #c4c8da
|
||||||
|
tab_bar_background #e1e2e7
|
||||||
|
foreground #3760bf
|
||||||
|
background #e1e2e7
|
||||||
|
selection_foreground #e1e2e7
|
||||||
|
selection_background #99a7df
|
||||||
|
color0 #e9e9ed
|
||||||
|
color8 #a1a6c5
|
||||||
|
color1 #f52a65
|
||||||
|
color9 #f52a65
|
||||||
|
color2 #587539
|
||||||
|
color10 #587539
|
||||||
|
color3 #8c6c3e
|
||||||
|
color11 #8c6c3e
|
||||||
|
color4 #2e7de9
|
||||||
|
color12 #2e7de9
|
||||||
|
color5 #9854f1
|
||||||
|
color13 #9854f1
|
||||||
|
color6 #007197
|
||||||
|
color14 #007197
|
||||||
|
color7 #6172b0
|
||||||
|
color15 #3760bf
|
||||||
|
color16 #b15c00
|
||||||
|
color17 #c64343
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Tokyo Night Moon — the dark theme Panama ships.
|
||||||
|
#
|
||||||
|
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||||
|
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||||
|
# setting; that generated file is gitignored because it is machine state.
|
||||||
|
#
|
||||||
|
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||||
|
# remote control. Without it a scheme change would only reach terminals opened
|
||||||
|
# afterwards.
|
||||||
|
|
||||||
|
cursor #c8d3f5
|
||||||
|
cursor_text_color #111325
|
||||||
|
url_color #4fd6be
|
||||||
|
active_border_color #82aaff
|
||||||
|
inactive_border_color #2f334d
|
||||||
|
active_tab_foreground #1e2030
|
||||||
|
active_tab_background #82aaff
|
||||||
|
inactive_tab_foreground #c8d3f5
|
||||||
|
inactive_tab_background #42465a
|
||||||
|
tab_bar_background #222436
|
||||||
|
foreground #c8d3f5
|
||||||
|
background #222436
|
||||||
|
selection_foreground #2d3f76
|
||||||
|
selection_background #c8d3f5
|
||||||
|
color0 #1b1d2b
|
||||||
|
color8 #444a73
|
||||||
|
color1 #ff757f
|
||||||
|
color9 #ff757f
|
||||||
|
color2 #a5e8b5
|
||||||
|
color10 #a5e8b5
|
||||||
|
color3 #ffc777
|
||||||
|
color11 #ffc777
|
||||||
|
color4 #82aaff
|
||||||
|
color12 #82aaff
|
||||||
|
color5 #c099ff
|
||||||
|
color13 #c099ff
|
||||||
|
color6 #86e1fc
|
||||||
|
color14 #86e1fc
|
||||||
|
color7 #828bb8
|
||||||
|
color15 #c8d3f5
|
||||||
|
color16 #ff966c
|
||||||
|
color17 #c53b53
|
||||||
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
|
|||||||
vim.fn.winrestview(view)
|
vim.fn.winrestview(view)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Follow the desktop's light/dark setting while running, not only at startup.
|
||||||
|
require("config.panama").watch()
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- Panama desktop integration.
|
||||||
|
--
|
||||||
|
-- Neovim is the one application here that neither reads the desktop portal nor
|
||||||
|
-- has a control socket open by default, so it reads the shared settings file
|
||||||
|
-- directly -- the same ~/.config/panama/settings.json that the shell and the
|
||||||
|
-- Hyprland config read.
|
||||||
|
--
|
||||||
|
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||||
|
-- user their colour scheme preference and nothing else; editing text is more
|
||||||
|
-- important than matching the desktop.
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local function settings_path()
|
||||||
|
local config_home = os.getenv("XDG_CONFIG_HOME")
|
||||||
|
if config_home == nil or config_home == "" then
|
||||||
|
local home = os.getenv("HOME")
|
||||||
|
if home == nil or home == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
config_home = home .. "/.config"
|
||||||
|
end
|
||||||
|
return config_home .. "/panama/settings.json"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "dark" or "light". Defaults to dark, which is what Panama ships.
|
||||||
|
function M.color_scheme()
|
||||||
|
local path = settings_path()
|
||||||
|
if not path then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, contents = pcall(function()
|
||||||
|
local file = io.open(path, "r")
|
||||||
|
if not file then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local text = file:read("*a")
|
||||||
|
file:close()
|
||||||
|
return text
|
||||||
|
end)
|
||||||
|
if not ok or not contents or contents == "" then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
local decoded_ok, decoded = pcall(vim.json.decode, contents)
|
||||||
|
if not decoded_ok or type(decoded) ~= "table" then
|
||||||
|
return "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
return decoded.colorScheme == "light" and "light" or "dark"
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.is_light()
|
||||||
|
return M.color_scheme() == "light"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- tokyonight ships a light variant in the same family, so light mode stays the
|
||||||
|
-- same identity rather than becoming a different editor theme.
|
||||||
|
function M.tokyonight_style()
|
||||||
|
return M.is_light() and "day" or "moon"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Re-apply the scheme when the window regains focus.
|
||||||
|
--
|
||||||
|
-- Neovim reads the setting once at startup and has no control socket open by
|
||||||
|
-- default, so an editor already running when the desktop scheme flips would
|
||||||
|
-- otherwise stay on the old palette until it was restarted. FocusGained is
|
||||||
|
-- cheap, happens exactly when you would notice the mismatch, and does nothing
|
||||||
|
-- at all unless the scheme actually changed.
|
||||||
|
function M.watch()
|
||||||
|
local applied = M.color_scheme()
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd("FocusGained", {
|
||||||
|
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
|
||||||
|
callback = function()
|
||||||
|
local current = M.color_scheme()
|
||||||
|
if current == applied then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
applied = current
|
||||||
|
vim.o.background = current
|
||||||
|
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -1,31 +1,68 @@
|
|||||||
|
-- Tokyo Night, following the desktop's colour scheme.
|
||||||
|
--
|
||||||
|
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||||
|
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||||
|
-- becoming a different-looking application when the scheme flips.
|
||||||
|
--
|
||||||
|
-- The readability fixes below are deliberately dark-only. They were written
|
||||||
|
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||||
|
-- applying them to Day would put light grey text on a light background, which
|
||||||
|
-- is exactly the legibility problem they exist to solve, inverted.
|
||||||
|
|
||||||
|
local panama = require("config.panama")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
{
|
{
|
||||||
"folke/tokyonight.nvim",
|
"folke/tokyonight.nvim",
|
||||||
opts = {
|
opts = function()
|
||||||
style = "moon",
|
local light = panama.is_light()
|
||||||
|
|
||||||
|
return {
|
||||||
|
style = panama.tokyonight_style(),
|
||||||
|
light_style = "day",
|
||||||
transparent = true,
|
transparent = true,
|
||||||
|
|
||||||
on_colors = function(colors)
|
on_colors = function(colors)
|
||||||
|
if light then
|
||||||
|
-- Day's defaults are tuned for a light ground and mostly need no
|
||||||
|
-- help. Comments are the exception: the shipped #848cb5 measures
|
||||||
|
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
|
||||||
|
-- for secondary text. This is 3.25:1 -- readable, and still clearly
|
||||||
|
-- dimmer than Normal's 4.52:1 so it does not compete with code.
|
||||||
|
colors.comment = "#7079a8"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
colors.comment = "#a0a7c5"
|
colors.comment = "#a0a7c5"
|
||||||
colors.fg_gutter = "#787f93"
|
colors.fg_gutter = "#787f93"
|
||||||
colors.terminal_black = "#828bb8"
|
colors.terminal_black = "#828bb8"
|
||||||
end,
|
end,
|
||||||
|
|
||||||
on_highlights = function(highlights, colors)
|
on_highlights = function(highlights, colors)
|
||||||
-- Fix inline code visibility in markdown
|
-- Inline code in markdown is invisible at both lightnesses without
|
||||||
|
-- an explicit background, because the theme leaves it unset.
|
||||||
highlights["@markup.raw.markdown_inline"] = {
|
highlights["@markup.raw.markdown_inline"] = {
|
||||||
bg = colors.terminal_black,
|
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||||
fg = colors.fg,
|
fg = colors.fg,
|
||||||
}
|
}
|
||||||
highlights["RenderMarkdownCodeInline"] = {
|
highlights["RenderMarkdownCodeInline"] = {
|
||||||
bg = colors.terminal_black,
|
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||||
fg = colors.fg,
|
fg = colors.fg,
|
||||||
}
|
}
|
||||||
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
|
||||||
-- imports, making text nearly invisible against LspReferenceText's background
|
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||||
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
|
-- imports, making text nearly invisible against LspReferenceText's
|
||||||
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
|
-- background.
|
||||||
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
|
local reference = {
|
||||||
|
bg = light and colors.bg_visual or colors.fg_gutter,
|
||||||
|
fg = colors.fg,
|
||||||
|
}
|
||||||
|
highlights["LspReferenceText"] = reference
|
||||||
|
highlights["LspReferenceRead"] = reference
|
||||||
|
highlights["LspReferenceWrite"] = reference
|
||||||
|
end,
|
||||||
|
}
|
||||||
end,
|
end,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"LazyVim/LazyVim",
|
"LazyVim/LazyVim",
|
||||||
|
|||||||
@@ -257,6 +257,24 @@ Singleton {
|
|||||||
detail: "XKB layout name, or a comma-separated list to switch between",
|
detail: "XKB layout name, or a comma-separated list to switch between",
|
||||||
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
|
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "keyboardVariant", type: "string", def: "", group: "input",
|
||||||
|
// Same shape as the layout list and for the same reason: this is
|
||||||
|
// serialised into an hl.config string.
|
||||||
|
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
|
||||||
|
label: "Layout variant",
|
||||||
|
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
|
||||||
|
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "keyboardOptions", type: "string", def: "", group: "input",
|
||||||
|
// XKB option names are colon-separated pairs in a comma-separated
|
||||||
|
// list, e.g. "compose:ralt,caps:escape".
|
||||||
|
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
||||||
|
label: "Keyboard options",
|
||||||
|
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
|
||||||
|
hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "numlockByDefault", type: "bool", def: true, group: "input",
|
key: "numlockByDefault", type: "bool", def: true, group: "input",
|
||||||
label: "Num Lock on login",
|
label: "Num Lock on login",
|
||||||
@@ -310,6 +328,167 @@ Singleton {
|
|||||||
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
|
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Pointer ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every `readAs` below was read back off the running compositor rather
|
||||||
|
// than guessed. Two are not what they look like: touchpad drag lock is
|
||||||
|
// an int with three states, not a switch, and scroll factors are floats
|
||||||
|
// even at their default of exactly 1.
|
||||||
|
{
|
||||||
|
key: "naturalScroll", type: "bool", def: false, group: "pointer",
|
||||||
|
label: "Natural scrolling",
|
||||||
|
detail: "Content follows the direction of your fingers, as on a phone",
|
||||||
|
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
|
||||||
|
label: "Acceleration",
|
||||||
|
detail: "Flat moves the pointer the same distance however fast you move",
|
||||||
|
options: [
|
||||||
|
{ value: "flat", label: "Flat" },
|
||||||
|
{ value: "adaptive", label: "Adaptive" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
|
||||||
|
group: "pointer",
|
||||||
|
label: "Scroll speed",
|
||||||
|
detail: "Multiplies how far one notch of the wheel scrolls",
|
||||||
|
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "leftHanded", type: "bool", def: false, group: "pointer",
|
||||||
|
label: "Left-handed",
|
||||||
|
detail: "Swap the primary and secondary buttons",
|
||||||
|
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Touchpad ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Shown only on machines that have one. These are separate from the
|
||||||
|
// pointer settings above because libinput keeps them separate: a mouse
|
||||||
|
// and a touchpad on the same machine can scroll in opposite directions,
|
||||||
|
// and usually should.
|
||||||
|
{
|
||||||
|
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Tap to click",
|
||||||
|
detail: "A tap counts as a click without pressing down",
|
||||||
|
// The Lua config key and the hyprctl option name genuinely differ
|
||||||
|
// here: hl.config wants input.touchpad.tap_to_click, getoption
|
||||||
|
// answers to input:touchpad:tap-to-click. Using either spelling for
|
||||||
|
// both fails -- a hyphen is not a Lua identifier, and the
|
||||||
|
// underscored name is not a known option to getoption. This is what
|
||||||
|
// the two separate fields are for.
|
||||||
|
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Natural scrolling",
|
||||||
|
detail: "Content follows the direction of your fingers",
|
||||||
|
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Disable while typing",
|
||||||
|
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
|
||||||
|
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
|
||||||
|
group: "touchpad",
|
||||||
|
label: "Scroll speed",
|
||||||
|
detail: "Multiplies how far a two-finger scroll travels",
|
||||||
|
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
|
||||||
|
label: "Drag lock",
|
||||||
|
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
|
||||||
|
// An int with three states rather than a switch, which is why this
|
||||||
|
// is an enum: reported as `int` by getoption, not `bool`.
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 1, label: "On" },
|
||||||
|
{ value: 2, label: "On, until you tap again" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
|
||||||
|
label: "Middle-click by pressing both buttons",
|
||||||
|
detail: "Pressing left and right together acts as a middle click",
|
||||||
|
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Multitasking ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
|
||||||
|
// described the first two of these as read-only facts ("Layout:
|
||||||
|
// Tiling"), which was never true -- they are ordinary settings that
|
||||||
|
// happened not to have controls.
|
||||||
|
//
|
||||||
|
// Defaults here are Panama's shipped values from hypr/looks.lua, not
|
||||||
|
// Hyprland's own, so restoring defaults returns the desktop to how it
|
||||||
|
// ships rather than to how Hyprland would behave with no config.
|
||||||
|
{
|
||||||
|
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
|
||||||
|
label: "Tiling layout",
|
||||||
|
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
|
||||||
|
options: [
|
||||||
|
{ value: "dwindle", label: "Dwindle" },
|
||||||
|
{ value: "master", label: "Master and stack" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Keep split direction",
|
||||||
|
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
|
||||||
|
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
|
||||||
|
label: "New windows open",
|
||||||
|
detail: "Where a new window lands relative to the one that was focused",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Where the pointer is" },
|
||||||
|
{ value: 1, label: "Always left or above" },
|
||||||
|
{ value: 2, label: "Always right or below" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Snap floating windows",
|
||||||
|
detail: "Floating windows stick to screen edges and to each other as you drag them",
|
||||||
|
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Switch back and forth",
|
||||||
|
detail: "Selecting the workspace you are already on returns you to the previous one",
|
||||||
|
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Wrap around at the ends",
|
||||||
|
detail: "Moving past the last workspace continues from the first",
|
||||||
|
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Let applications take focus",
|
||||||
|
detail: "An application asking for attention is switched to, rather than only highlighted",
|
||||||
|
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Pointer changes active display",
|
||||||
|
detail: "Moving the pointer to another display makes it the active one",
|
||||||
|
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Night light ─────────────────────────────────────────────────────
|
// ── Night light ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||||
|
|||||||
@@ -1,24 +1,44 @@
|
|||||||
// Backlight slider, via brightnessctl.
|
// Brightness, from whichever source this machine actually has.
|
||||||
//
|
//
|
||||||
// This machine drives an external DisplayPort monitor and has no backlight
|
// Two exist and they are not interchangeable:
|
||||||
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
|
//
|
||||||
// row removes itself rather than sitting there as a dead control. Probed once
|
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||||
// at startup — backlight devices do not appear and disappear.
|
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
|
||||||
|
//
|
||||||
|
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
|
||||||
|
// only brightness an external display has, and it is per-monitor.
|
||||||
|
//
|
||||||
|
// A machine may have neither, either, or both, so this renders a row per source
|
||||||
|
// found and removes itself entirely when there are none, rather than sitting
|
||||||
|
// there as a dead control.
|
||||||
|
//
|
||||||
|
// Connector labels appear only when there is more than one row. A single
|
||||||
|
// slider needs no explanation of which screen it dims.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool available: false
|
property bool backlightAvailable: false
|
||||||
property real value: 0
|
property real backlightValue: 0
|
||||||
|
|
||||||
visible: root.available
|
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||||
implicitHeight: root.available ? 32 : 0
|
readonly property bool labelled: root.rowCount > 1
|
||||||
|
|
||||||
|
visible: root.rowCount > 0
|
||||||
|
implicitHeight: rows.implicitHeight
|
||||||
|
|
||||||
|
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||||
|
// is actually on screen rather than running at shell startup. Monitors do
|
||||||
|
// not come and go, so once is enough.
|
||||||
|
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||||
|
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||||
|
|
||||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||||
Process {
|
Process {
|
||||||
@@ -34,18 +54,85 @@ Item {
|
|||||||
const fields = line.split(",");
|
const fields = line.split(",");
|
||||||
if (fields.length < 5 || fields[1] !== "backlight")
|
if (fields.length < 5 || fields[1] !== "backlight")
|
||||||
continue;
|
continue;
|
||||||
root.available = true;
|
root.backlightAvailable = true;
|
||||||
root.value = parseInt(fields[3]) / 100;
|
root.backlightValue = parseInt(fields[3]) / 100;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(v: real): void {
|
function applyBacklight(v: real): void {
|
||||||
root.value = v;
|
root.backlightValue = v;
|
||||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
// Never go fully dark: a 0% backlight looks like a broken shell.
|
||||||
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: rows
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
BrightnessRow {
|
||||||
|
width: rows.width
|
||||||
|
visible: root.backlightAvailable
|
||||||
|
label: "Built-in"
|
||||||
|
value: root.backlightValue
|
||||||
|
onMoved: v => root.applyBacklight(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Brightness.displays
|
||||||
|
|
||||||
|
BrightnessRow {
|
||||||
|
required property var modelData
|
||||||
|
width: rows.width
|
||||||
|
// Hyprland already knows what each output is called, so the
|
||||||
|
// name comes from there rather than from a second source that
|
||||||
|
// could disagree with the Displays page. The connector is the
|
||||||
|
// fallback, so a display is never an unlabelled slider.
|
||||||
|
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||||
|
value: modelData.value / 100
|
||||||
|
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component BrightnessRow: Item {
|
||||||
|
id: row
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property real value: 0
|
||||||
|
signal moved(real value)
|
||||||
|
|
||||||
|
implicitHeight: caption.height + control.height
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
anchors.top: parent.top
|
||||||
|
visible: root.labelled
|
||||||
|
height: visible ? implicitHeight + 2 : 0
|
||||||
|
text: row.label
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slider and its glyph share one strip so the two stay aligned
|
||||||
|
// whether or not a caption sits above them. Anchoring the glyph to
|
||||||
|
// both a caption and a centre line instead would conflict, and an
|
||||||
|
// anchor set to undefined is not released.
|
||||||
|
Item {
|
||||||
|
id: control
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: caption.bottom
|
||||||
|
height: 32
|
||||||
|
|
||||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||||
// recolouring to be visible — see ThemedIcon.
|
// recolouring to be visible — see ThemedIcon.
|
||||||
ThemedIcon {
|
ThemedIcon {
|
||||||
@@ -63,7 +150,9 @@ Item {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.rightMargin: 32
|
anchors.rightMargin: 32
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
value: root.value
|
value: row.value
|
||||||
onMoved: v => root.apply(v)
|
onMoved: v => row.moved(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
title: "About Panama"
|
id: root
|
||||||
|
|
||||||
|
title: "About"
|
||||||
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (!MachineInfo.scanned)
|
||||||
|
MachineInfo.refresh();
|
||||||
|
if (GraphicsDevices.devices.length === 0)
|
||||||
|
GraphicsDevices.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Panama Desktop"
|
title: "Desktop"
|
||||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
@@ -31,6 +40,59 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What GNOME's About panel answers and this page did not: what am I running
|
||||||
|
// on. Rows come from MachineInfo, except graphics, which is joined from
|
||||||
|
// GraphicsDevices rather than read a second time -- two readouts of the same
|
||||||
|
// hardware are two things that can disagree.
|
||||||
|
//
|
||||||
|
// The join splices the GPU rows in directly after Processor rather than
|
||||||
|
// appending them, so the card reads the way fastfetch does: what the system
|
||||||
|
// is, then what is installed on it, then the hardware underneath. A GPU
|
||||||
|
// listed after "Disk" reads as an afterthought.
|
||||||
|
readonly property var machineRows: {
|
||||||
|
const rows = (MachineInfo.facts ?? []).slice();
|
||||||
|
const gpus = GraphicsDevices.devices ?? [];
|
||||||
|
if (gpus.length === 0)
|
||||||
|
return rows;
|
||||||
|
|
||||||
|
const graphics = gpus.map((device, index) => ({
|
||||||
|
label: gpus.length > 1 ? "Graphics " + (index + 1) : "Graphics",
|
||||||
|
value: device.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
const after = rows.findIndex(row => row.label === "Processor");
|
||||||
|
if (after < 0)
|
||||||
|
return rows.concat(graphics);
|
||||||
|
return rows.slice(0, after + 1).concat(graphics, rows.slice(after + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "This machine"
|
||||||
|
subtitle: "Hardware and system, as the kernel reports it."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.machineRows
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
id: machineRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: machineRow.modelData.label
|
||||||
|
value: machineRow.modelData.value
|
||||||
|
divider: machineRow.index < root.machineRows.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: MachineInfo.scanned && root.machineRows.length === 0
|
||||||
|
label: "Hardware"
|
||||||
|
detail: "The system did not report anything readable"
|
||||||
|
value: "Unavailable"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Design principles"
|
title: "Design principles"
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Text"
|
title: "Text"
|
||||||
subtitle: "Scales text in applications. Panama's own panels are drawn at their design size, so the shell is unaffected."
|
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
|
||||||
|
|
||||||
SliderRow { setting: "textScale"; divider: false }
|
SliderRow { setting: "textScale"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Motion"
|
title: "Motion"
|
||||||
subtitle: "Panama never animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
|
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
|
||||||
|
|
||||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ SettingsPage {
|
|||||||
title: "Background"
|
title: "Background"
|
||||||
subtitle: Wallpaper.lastError !== ""
|
subtitle: Wallpaper.lastError !== ""
|
||||||
? Wallpaper.lastError
|
? Wallpaper.lastError
|
||||||
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
: "Applied to every display. Looked for in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||||
|
|
||||||
WallpaperPicker {
|
WallpaperPicker {
|
||||||
id: wallpapers
|
id: wallpapers
|
||||||
@@ -184,7 +184,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Theme"
|
title: "Theme"
|
||||||
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
||||||
|
|
||||||
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
||||||
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
||||||
|
|||||||
@@ -142,6 +142,40 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GNOME's Search panel, answered honestly.
|
||||||
|
//
|
||||||
|
// It configures which applications provide results in gnome-shell's
|
||||||
|
// overview and which folders are indexed. gnome-shell does not run here,
|
||||||
|
// so those settings would do nothing. Under Panama searching is the
|
||||||
|
// launcher's job, and Vicinae carries its own preferences -- reimplementing
|
||||||
|
// them here would give two places to change one thing.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Search"
|
||||||
|
subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Launcher"
|
||||||
|
detail: SystemSettings.vicinaeActive
|
||||||
|
? "Running as a user service"
|
||||||
|
: "Not running — Super+Shift+R opens the fallback launcher"
|
||||||
|
value: "Vicinae"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Open search"
|
||||||
|
detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog"
|
||||||
|
value: "Super+Space"
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Change these shortcuts"
|
||||||
|
detail: "Every launcher chord is rebindable, including clipboard history and emoji"
|
||||||
|
action: "Open keyboard"
|
||||||
|
divider: false
|
||||||
|
onTriggered: ShellState.openSettings("shortcuts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "User autostart"
|
title: "User autostart"
|
||||||
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
|
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
|
||||||
@@ -175,7 +209,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Compositor autostart"
|
title: "Compositor autostart"
|
||||||
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
|
subtitle: "These are started from the Hyprland configuration. They are read-only here."
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
|
||||||
|
|||||||
@@ -47,18 +47,16 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Window layout"
|
title: "Window layout"
|
||||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
|
||||||
|
|
||||||
TextRow {
|
// These were two read-only rows reporting "Tiling" and "Dynamic", which
|
||||||
label: "Layout"
|
// described settings rather than facts -- both are ordinary Hyprland
|
||||||
detail: "Dwindle with preserved split direction"
|
// options that simply had no controls. TextRow's own documentation says
|
||||||
value: "Tiling"
|
// a setting the user could reasonably change does not belong in it.
|
||||||
}
|
ChoiceRow { setting: "windowLayout" }
|
||||||
TextRow {
|
ToggleRow { setting: "preserveSplit" }
|
||||||
label: "Workspace movement"
|
ChoiceRow { setting: "forceSplit" }
|
||||||
detail: "Alt+H / Alt+L, add Shift to move a window"
|
ToggleRow { setting: "windowSnapping" }
|
||||||
value: "Dynamic"
|
|
||||||
}
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Gaps, corners, and effects"
|
label: "Gaps, corners, and effects"
|
||||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||||
@@ -68,6 +66,18 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Workspaces & focus"
|
||||||
|
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||||
|
|
||||||
|
ToggleRow { setting: "workspaceBackAndForth" }
|
||||||
|
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||||
|
ToggleRow { setting: "focusOnActivate" }
|
||||||
|
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
||||||
|
ChoiceRow { setting: "followMouse"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Focus"
|
title: "Focus"
|
||||||
|
|
||||||
@@ -111,10 +121,10 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Reset"
|
title: "Reset"
|
||||||
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
subtitle: "Restores the appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Restore Panama defaults"
|
label: "Restore defaults"
|
||||||
detail: "Applies immediately, including to the compositor"
|
detail: "Applies immediately, including to the compositor"
|
||||||
action: "Restore defaults"
|
action: "Restore defaults"
|
||||||
divider: false
|
divider: false
|
||||||
|
|||||||
@@ -89,9 +89,12 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The real bar carries a clock here, not a name. Standing in with the
|
||||||
|
// actual time keeps the preview a picture of this desktop rather than
|
||||||
|
// a picture of a product.
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "Panama"
|
text: Qt.formatTime(new Date(), "h:mm")
|
||||||
color: Theme.alpha(Theme.fg, 0.45)
|
color: Theme.alpha(Theme.fg, 0.45)
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: 9
|
font.pixelSize: 9
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Displays.
|
// Displays.
|
||||||
//
|
//
|
||||||
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
||||||
// policy that was already here.
|
// gaming display policy that was already here.
|
||||||
//
|
//
|
||||||
// Every geometry change goes through an apply-then-confirm countdown. This is
|
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||||
// the one page where a wrong value can leave the screen unreadable or blank,
|
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||||
@@ -32,7 +32,14 @@ SettingsPage {
|
|||||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: root.syncSelectedOutput()
|
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||||||
|
// it runs when this page is opened rather than at shell startup. Monitors
|
||||||
|
// do not appear while you are looking at a settings page, so once is enough.
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.syncSelectedOutput();
|
||||||
|
if (!Brightness.scanned)
|
||||||
|
Brightness.refresh();
|
||||||
|
}
|
||||||
Connections {
|
Connections {
|
||||||
target: Displays
|
target: Displays
|
||||||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||||
@@ -142,7 +149,7 @@ SettingsPage {
|
|||||||
ActionRow {
|
ActionRow {
|
||||||
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||||
label: "Using a custom display setting"
|
label: "Using a custom display setting"
|
||||||
detail: "Forget it to go back to the resolution and scale Panama ships"
|
detail: "Forget it to go back to the shipped resolution and scale"
|
||||||
action: "Forget"
|
action: "Forget"
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: Displays.forget(root.monitor.name)
|
onTriggered: Displays.forget(root.monitor.name)
|
||||||
@@ -200,9 +207,48 @@ SettingsPage {
|
|||||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
SliderRow { setting: "nightLightTemperature"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panel brightness, over DDC/CI.
|
||||||
|
//
|
||||||
|
// This is hardware state rather than a stored preference: the monitor
|
||||||
|
// remembers it, the bezel buttons change it behind Panama's back, and
|
||||||
|
// writing it into settings.json would mean restoring a value the panel had
|
||||||
|
// already moved on from. So there is no schema key here and no SliderRow --
|
||||||
|
// the rows read and write the display directly.
|
||||||
|
SettingsCard {
|
||||||
|
visible: Brightness.available || Brightness.lastError !== ""
|
||||||
|
title: "Brightness"
|
||||||
|
subtitle: Brightness.available
|
||||||
|
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
|
||||||
|
: Brightness.lastError
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: Brightness.displays
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: brightnessRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||||
|
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
|
||||||
|
: modelData.value + "%"
|
||||||
|
divider: brightnessRow.index < Brightness.displays.length - 1
|
||||||
|
controlWidth: 190
|
||||||
|
|
||||||
|
ValueSlider {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.right: parent.right
|
||||||
|
width: parent.width
|
||||||
|
value: brightnessRow.modelData.value / 100
|
||||||
|
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Gaming display policy"
|
title: "Gaming display policy"
|
||||||
subtitle: "Applied immediately and restored when Panama starts."
|
subtitle: "Applied immediately and restored when the session starts."
|
||||||
|
|
||||||
ToggleRow { setting: "autoHdr" }
|
ToggleRow { setting: "autoHdr" }
|
||||||
ChoiceRow { setting: "vrrPolicy" }
|
ChoiceRow { setting: "vrrPolicy" }
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ SettingsPage {
|
|||||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||||
|
|
||||||
title: `${root.greeting}, Gabriel`
|
title: `${root.greeting}, Gabriel`
|
||||||
lede: "Your Panama desktop is configured and ready."
|
lede: "Your desktop is configured and ready."
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: SystemSettings.monitorDescription || "Active display"
|
title: SystemSettings.monitorDescription || "Active display"
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Private configuration"
|
label: "Private configuration"
|
||||||
detail: "Saved with owner-only permissions in Panama's private environment file"
|
detail: "Saved with owner-only permissions in a private environment file"
|
||||||
controlWidth: 216
|
controlWidth: 216
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
@@ -239,7 +239,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Light catalog"
|
label: "Light catalog"
|
||||||
detail: "Panama reads light state through the Home Assistant helper."
|
detail: "Light state is read through the Home Assistant helper."
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 176
|
controlWidth: 176
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Mouse & Touchpad.
|
||||||
|
//
|
||||||
|
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
|
||||||
|
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
|
||||||
|
// where it was before. These all reach Hyprland's input section, which until now
|
||||||
|
// could only be changed by editing hypr/input.lua by hand.
|
||||||
|
//
|
||||||
|
// The touchpad card renders only when a touchpad is attached. On a desktop it
|
||||||
|
// would be worse than useless -- every switch on it appears to work, because the
|
||||||
|
// preference is stored and the compositor accepts an option for a device class
|
||||||
|
// with no members, so the user would be toggling settings that can never affect
|
||||||
|
// anything with nothing to say so.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Mouse & Touchpad"
|
||||||
|
lede: InputDevices.hasTouchpad
|
||||||
|
? "Pointer behaviour for your mouse and touchpad."
|
||||||
|
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Mouse"
|
||||||
|
subtitle: "Applied to every pointing device that is not a touchpad."
|
||||||
|
|
||||||
|
SliderRow { setting: "pointerSensitivity" }
|
||||||
|
ChoiceRow { setting: "accelProfile" }
|
||||||
|
ToggleRow { setting: "naturalScroll" }
|
||||||
|
SliderRow { setting: "scrollFactor" }
|
||||||
|
ToggleRow { setting: "leftHanded"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: InputDevices.hasTouchpad
|
||||||
|
title: "Touchpad"
|
||||||
|
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
|
||||||
|
|
||||||
|
ToggleRow { setting: "touchpadTapToClick" }
|
||||||
|
ToggleRow { setting: "touchpadNaturalScroll" }
|
||||||
|
ToggleRow { setting: "touchpadDisableWhileTyping" }
|
||||||
|
SliderRow { setting: "touchpadScrollFactor" }
|
||||||
|
ChoiceRow { setting: "touchpadDragLock" }
|
||||||
|
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pointer"
|
||||||
|
|
||||||
|
ChoiceRow { setting: "followMouse" }
|
||||||
|
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
|
||||||
|
SliderRow { setting: "cursorSize"; divider: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ SettingsPage {
|
|||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Notification history"
|
label: "Notification history"
|
||||||
detail: "Live notifications retained by Panama"
|
detail: "Live notifications retained by the shell"
|
||||||
value: `${Notifs.history.length} items`
|
value: `${Notifs.history.length} items`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Online Accounts.
|
||||||
|
//
|
||||||
|
// GNOME's panel of the same name, except for one step. The accounts are GNOME
|
||||||
|
// Online Accounts objects on D-Bus, the daemon already runs here, and listing,
|
||||||
|
// per-service toggles and removal are all done natively on this page.
|
||||||
|
//
|
||||||
|
// Signing in to an OAuth provider is handed to GNOME's panel, because the
|
||||||
|
// credentials are OAuth tokens and the code that obtains them ships without a
|
||||||
|
// scriptable binding. That is stated on the page rather than hidden behind a
|
||||||
|
// button that looks native, because a hand-off the user does not expect reads
|
||||||
|
// as a bug.
|
||||||
|
//
|
||||||
|
// Accounts needing attention are surfaced first. GOA knows when a token has
|
||||||
|
// expired and nothing outside its own panel says so, which is how an account
|
||||||
|
// quietly stops syncing for weeks.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Online Accounts"
|
||||||
|
lede: OnlineAccounts.attentionCount > 0
|
||||||
|
? OnlineAccounts.attentionCount + (OnlineAccounts.attentionCount === 1
|
||||||
|
? " account needs you to sign in again."
|
||||||
|
: " accounts need you to sign in again.")
|
||||||
|
: "Accounts your mail, calendar, contacts, and files come from."
|
||||||
|
|
||||||
|
Component.onCompleted: if (!OnlineAccounts.scanned) OnlineAccounts.refresh()
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: !OnlineAccounts.available
|
||||||
|
title: "Accounts unavailable"
|
||||||
|
subtitle: OnlineAccounts.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: OnlineAccounts.scanned && OnlineAccounts.available && OnlineAccounts.accounts.length === 0
|
||||||
|
title: "No accounts yet"
|
||||||
|
subtitle: "Adding one lets mail, calendar, contacts, and file managers share a single sign-in."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Add an account"
|
||||||
|
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||||
|
action: "Add account"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: OnlineAccounts.accounts
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
id: accountCard
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
title: accountCard.modelData.identity || accountCard.modelData.providerName
|
||||||
|
subtitle: accountCard.modelData.needsAttention
|
||||||
|
? accountCard.modelData.providerName + " · sign-in expired, so this account has stopped syncing"
|
||||||
|
: accountCard.modelData.providerName
|
||||||
|
|
||||||
|
// Only when it is true, because it is the one thing on this page
|
||||||
|
// that needs acting on.
|
||||||
|
ActionRow {
|
||||||
|
visible: accountCard.modelData.needsAttention
|
||||||
|
label: "Sign in again"
|
||||||
|
detail: "Re-authorising uses the provider's own sign-in page, which GNOME's panel hosts"
|
||||||
|
action: "Sign in"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: accountCard.modelData.services
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: serviceRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: serviceRow.modelData.label
|
||||||
|
detail: serviceRow.modelData.enabled
|
||||||
|
? "Applications using " + serviceRow.modelData.label.toLowerCase() + " can see this account"
|
||||||
|
: "Hidden from applications"
|
||||||
|
controlWidth: 48
|
||||||
|
|
||||||
|
SettingsToggle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: serviceRow.modelData.enabled
|
||||||
|
onToggled: value => OnlineAccounts.setService(
|
||||||
|
accountCard.modelData.path, serviceRow.modelData.key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Remove this account"
|
||||||
|
detail: "Signs out and removes it from every application that was using it"
|
||||||
|
action: "Remove"
|
||||||
|
divider: false
|
||||||
|
onTriggered: OnlineAccounts.remove(accountCard.modelData.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: OnlineAccounts.available && OnlineAccounts.accounts.length > 0
|
||||||
|
title: "Add another account"
|
||||||
|
subtitle: "Signing in happens on the provider's own page. GNOME's panel hosts that step; everything after it is managed here."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Add an account"
|
||||||
|
detail: "Google, Nextcloud, Microsoft Exchange, IMAP, WebDAV, and Kerberos"
|
||||||
|
action: "Add account"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ SettingsPage {
|
|||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Idle behaviour"
|
title: "Idle behaviour"
|
||||||
subtitle: IdleLock.managed
|
subtitle: IdleLock.managed
|
||||||
? "Panama is managing hypridle. Changes take effect immediately."
|
? "Idle timings are managed here. Changes take effect immediately."
|
||||||
: "hypridle is running Panama's shipped configuration. Turn on management below to make these adjustable."
|
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
|
||||||
|
|
||||||
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
|
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
|
||||||
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||||
@@ -41,10 +41,10 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Management"
|
title: "Management"
|
||||||
subtitle: "Panama generates hypridle's configuration into your state directory and points the service at it with a systemd drop-in. ~/.config/hypr is a symlink into the Panama repository, so the shipped configuration cannot be rewritten in place."
|
subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
label: "Let Panama manage idle timings"
|
label: "Manage idle timings here"
|
||||||
detail: IdleLock.serviceState === "active"
|
detail: IdleLock.serviceState === "active"
|
||||||
? "hypridle is running"
|
? "hypridle is running"
|
||||||
: "hypridle is " + IdleLock.serviceState
|
: "hypridle is " + IdleLock.serviceState
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Privacy & Security.
|
||||||
|
//
|
||||||
|
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
|
||||||
|
// history, trash, and device security. Panama covers the parts it genuinely
|
||||||
|
// owns and is explicit about the parts it does not.
|
||||||
|
//
|
||||||
|
// The file-history and trash settings are the notable omission, and the reason
|
||||||
|
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
|
||||||
|
// which is not running in a Hyprland session. Offering switches for them would
|
||||||
|
// store a preference, change nothing, and give no sign of it -- the exact
|
||||||
|
// failure this codebase keeps designing against. So they are delegated by name
|
||||||
|
// rather than reimplemented as controls that lie.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Privacy & Security"
|
||||||
|
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
|
||||||
|
? "Screen lock, device access, and a machine whose security settings all check out."
|
||||||
|
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (!DeviceSecurity.scanned)
|
||||||
|
DeviceSecurity.refresh();
|
||||||
|
if (!Keyring.scanned)
|
||||||
|
Keyring.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Screen lock"
|
||||||
|
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
|
||||||
|
|
||||||
|
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||||
|
ToggleRow { setting: "lockOnSleep"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The login keyring, which nothing else surfaces.
|
||||||
|
//
|
||||||
|
// It is unlocked at sign-in by PAM, so this card normally just confirms
|
||||||
|
// that. It earns its place on the rare occasion it is not: a locked keyring
|
||||||
|
// breaks saved passwords everywhere at once, and does it without ever
|
||||||
|
// saying the word "keyring" -- you get a mail account that will not
|
||||||
|
// authenticate and a git push that cannot find its key.
|
||||||
|
SettingsCard {
|
||||||
|
visible: Keyring.scanned
|
||||||
|
title: "Saved passwords"
|
||||||
|
subtitle: !Keyring.available
|
||||||
|
? "No secret service is answering, so saved passwords are unavailable."
|
||||||
|
: Keyring.locked
|
||||||
|
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
|
||||||
|
: "The login keyring is unlocked, as it is after every normal sign-in."
|
||||||
|
|
||||||
|
// Two rows rather than one with a conditional button: a locked keyring
|
||||||
|
// needs an action, an unlocked one is a statement of fact, and ActionRow
|
||||||
|
// and TextRow already say exactly those two things.
|
||||||
|
ActionRow {
|
||||||
|
visible: Keyring.available && Keyring.locked
|
||||||
|
label: "Login keyring"
|
||||||
|
detail: "Unlock to restore access to stored passwords and keys"
|
||||||
|
action: Keyring.unlocking ? "Waiting…" : "Unlock"
|
||||||
|
enabled: !Keyring.unlocking
|
||||||
|
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
||||||
|
onTriggered: Keyring.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: !(Keyring.available && Keyring.locked)
|
||||||
|
label: "Login keyring"
|
||||||
|
detail: Keyring.available
|
||||||
|
? "Unlocked at sign-in by PAM, the same way GNOME does it"
|
||||||
|
: "No secret service is answering on this session"
|
||||||
|
value: Keyring.available ? "Unlocked" : "Unavailable"
|
||||||
|
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only shown when it is true, because it is a diagnostic rather than a
|
||||||
|
// setting: it means the daemon holding your secrets is not the one PAM
|
||||||
|
// started, so whatever unlocked it will not survive a restart.
|
||||||
|
SettingRow {
|
||||||
|
visible: Keyring.replacementDaemon
|
||||||
|
label: "Keyring service"
|
||||||
|
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
|
||||||
|
value: "Replaced"
|
||||||
|
divider: Keyring.lastError !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
visible: Keyring.lastError !== ""
|
||||||
|
label: "Keyring problem"
|
||||||
|
detail: Keyring.lastError
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Camera & microphone"
|
||||||
|
subtitle: PrivacyState.anyActive
|
||||||
|
? "In use right now — the bar shows an indicator whenever this is true."
|
||||||
|
: "Nothing is using your camera or microphone."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Camera"
|
||||||
|
detail: PrivacyState.cameraActive
|
||||||
|
? "In use by " + (PrivacyState.cameraApp || "an application")
|
||||||
|
: "Not in use"
|
||||||
|
value: PrivacyState.cameraActive ? "Active" : "Idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Microphone"
|
||||||
|
detail: PrivacyState.microphoneActive
|
||||||
|
? "In use by " + (PrivacyState.microphoneApp || "an application")
|
||||||
|
: "Not in use"
|
||||||
|
value: PrivacyState.microphoneActive ? "Active" : "Idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Screen sharing"
|
||||||
|
detail: PrivacyState.screenSharingActive
|
||||||
|
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
|
||||||
|
: "Not being shared"
|
||||||
|
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Device security"
|
||||||
|
subtitle: DeviceSecurity.attentionCount === 0
|
||||||
|
? "Everything below is in its recommended state."
|
||||||
|
: DeviceSecurity.attentionCount + " item"
|
||||||
|
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: DeviceSecurity.facts
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
id: factRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: factRow.modelData.label
|
||||||
|
detail: factRow.modelData.detail
|
||||||
|
value: factRow.modelData.value
|
||||||
|
divider: factRow.index < DeviceSecurity.facts.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Owned by Fedora"
|
||||||
|
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "File history & trash"
|
||||||
|
detail: "Opens GNOME Settings, which owns these"
|
||||||
|
action: "Open privacy"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("privacy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Panama Settings
|
# Settings
|
||||||
|
|
||||||
The control centre for everything Panama owns. Anything the system owns —
|
The control centre for everything Panama owns. Anything the system owns —
|
||||||
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Region & Language.
|
||||||
|
//
|
||||||
|
// GNOME keeps language and formats under System; the setting itself is the
|
||||||
|
// machine's locale, which localectl owns. Panama does not store a copy of it --
|
||||||
|
// there is exactly one system locale and localectl is where it lives, so a
|
||||||
|
// preference here would be a second source of truth that drifts the moment
|
||||||
|
// anything else changes it.
|
||||||
|
//
|
||||||
|
// Keyboard layout is deliberately not repeated here even though GNOME groups it
|
||||||
|
// with region. It is a compositor setting that applies instantly, it lives on
|
||||||
|
// the Keyboard page with the rest of the typing settings, and showing it twice
|
||||||
|
// invites the two views to disagree.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Region & Language"
|
||||||
|
lede: SystemLocale.pendingRestart
|
||||||
|
? "Your new language applies to programs started after you sign out and back in."
|
||||||
|
: "The language and regional formats this machine uses."
|
||||||
|
|
||||||
|
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Language"
|
||||||
|
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Current language"
|
||||||
|
detail: SystemLocale.pendingRestart
|
||||||
|
? "Chosen, but not in use until you sign out and back in"
|
||||||
|
: "Used by programs that ask the system what language to speak"
|
||||||
|
value: SystemLocale.currentLabel || "Reading…"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
width: parent.width
|
||||||
|
items: SystemLocale.locales
|
||||||
|
current: SystemLocale.current
|
||||||
|
placeholder: "Search languages and regions"
|
||||||
|
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
|
||||||
|
onPicked: value => SystemLocale.set(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: SystemLocale.lastError !== ""
|
||||||
|
title: "Language problem"
|
||||||
|
subtitle: SystemLocale.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Formats"
|
||||||
|
subtitle: "Dates, times, and numbers follow the language above. The desktop's own clock formatting is on the Appearance page."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Clock and date display"
|
||||||
|
detail: "How the desktop itself shows the time"
|
||||||
|
action: "Open appearance"
|
||||||
|
onTriggered: ShellState.openSettings("appearance")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Regional formats"
|
||||||
|
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
|
||||||
|
action: "Open system"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("system", "region")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Read anything on screen"
|
title: "Read anything on screen"
|
||||||
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
|
subtitle: "Select a region, window, or display. It is recognized locally, with clean follow-up actions."
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
icon: ""
|
icon: ""
|
||||||
@@ -52,7 +52,7 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Local recognition"
|
title: "Local recognition"
|
||||||
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
|
subtitle: "The screen image stays in a local cache and is deleted when you dismiss the result."
|
||||||
|
|
||||||
TextRow {
|
TextRow {
|
||||||
label: "Text recognition"
|
label: "Text recognition"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// A searchable list of choices, for settings with far too many values to put in
|
||||||
|
// a dropdown and no useful preview to show.
|
||||||
|
//
|
||||||
|
// SearchPicker {
|
||||||
|
// items: Locale.locales // [{ value, label, detail }]
|
||||||
|
// current: Locale.current
|
||||||
|
// placeholder: "Search languages"
|
||||||
|
// onPicked: value => Locale.set(value)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// FontPicker is the same idea specialised: it draws each candidate in its own
|
||||||
|
// family, which is the whole reason it is a list rather than a text field. This
|
||||||
|
// one is for choices whose only useful preview is their name.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
// SearchField lives with the clipboard module, which is where it was first
|
||||||
|
// needed; FontPicker imports it from the same place.
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// [{ value, label, detail }]
|
||||||
|
property var items: []
|
||||||
|
property string current: ""
|
||||||
|
property string emptyText: "Nothing to choose from"
|
||||||
|
property string placeholder: "Search"
|
||||||
|
|
||||||
|
signal picked(string value)
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// Capped and filtered rather than listing everything: 327 locales inside a
|
||||||
|
// page that is already scrolling is worse than a search box.
|
||||||
|
readonly property var matches: {
|
||||||
|
const needle = filter.text.trim().toLowerCase();
|
||||||
|
const list = root.items.filter(item =>
|
||||||
|
needle === ""
|
||||||
|
? true
|
||||||
|
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|
||||||
|
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
|
||||||
|
|
||||||
|
// The current choice stays visible while browsing, so it is always
|
||||||
|
// clear what would be replaced.
|
||||||
|
const selected = list.find(item => item.value === root.current);
|
||||||
|
if (needle === "" && selected !== undefined)
|
||||||
|
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
|
||||||
|
return list.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: filter
|
||||||
|
width: parent.width
|
||||||
|
placeholder: root.placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.matches
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: candidate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool selected: candidate.modelData.value === root.current
|
||||||
|
|
||||||
|
label: candidate.modelData.label
|
||||||
|
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
|
||||||
|
controlWidth: 120
|
||||||
|
divider: candidate.index < root.matches.length - 1
|
||||||
|
activatable: !candidate.selected
|
||||||
|
onActivated: root.picked(candidate.modelData.value)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: candidate.selected
|
||||||
|
text: "✓"
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize + 2
|
||||||
|
color: Theme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.matches.length === 0
|
||||||
|
label: root.emptyText
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,14 +120,40 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Fedora system settings"
|
title: "Fedora system settings"
|
||||||
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
subtitle: "Panels this app does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices."
|
||||||
|
|
||||||
|
// This was one row listing five subjects and opening the network panel
|
||||||
|
// regardless. Naming a panel and then not opening it is worse than not
|
||||||
|
// offering it: it looks like a broken button rather than a deliberate
|
||||||
|
// hand-off, and someone looking for printers had to know to navigate
|
||||||
|
// once GNOME Settings appeared on the wrong page.
|
||||||
|
ActionRow {
|
||||||
|
label: "Users"
|
||||||
|
detail: "Accounts, passwords, and automatic login"
|
||||||
|
action: "Open users"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||||
|
}
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Network, Bluetooth, printers, users, and accounts"
|
label: "Sharing"
|
||||||
detail: "GNOME Settings remains searchable from the launcher too"
|
detail: "Remote desktop, media sharing, and remote login"
|
||||||
|
action: "Open sharing"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Colour profiles"
|
||||||
|
detail: "ICC profiles for displays, printers, and scanners"
|
||||||
|
action: "Open colour"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("color")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Digital wellbeing"
|
||||||
|
detail: "Screen time and break reminders"
|
||||||
|
action: "Open wellbeing"
|
||||||
divider: false
|
divider: false
|
||||||
action: "Open network"
|
onTriggered: SystemSettings.openGnomePanel("wellbeing")
|
||||||
onTriggered: SystemSettings.openGnomePanel("network")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Rectangle {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 18
|
anchors.leftMargin: 18
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Panama Settings"
|
text: "Settings"
|
||||||
color: Theme.fg
|
color: Theme.fg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
@@ -99,6 +99,10 @@ Rectangle {
|
|||||||
case "notifications": return notificationsPage;
|
case "notifications": return notificationsPage;
|
||||||
case "screen-intelligence": return screenIntelligencePage;
|
case "screen-intelligence": return screenIntelligencePage;
|
||||||
case "shortcuts": return shortcutsPage;
|
case "shortcuts": return shortcutsPage;
|
||||||
|
case "mouse": return mousePage;
|
||||||
|
case "privacy": return privacyPage;
|
||||||
|
case "region": return regionPage;
|
||||||
|
case "accounts": return onlineAccountsPage;
|
||||||
case "accessibility": return accessibilityPage;
|
case "accessibility": return accessibilityPage;
|
||||||
case "power": return powerPage;
|
case "power": return powerPage;
|
||||||
case "datetime": return dateTimePage;
|
case "datetime": return dateTimePage;
|
||||||
@@ -153,6 +157,10 @@ Rectangle {
|
|||||||
Component { id: notificationsPage; NotificationsPage {} }
|
Component { id: notificationsPage; NotificationsPage {} }
|
||||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||||
|
Component { id: mousePage; MousePage {} }
|
||||||
|
Component { id: privacyPage; PrivacyPage {} }
|
||||||
|
Component { id: regionPage; RegionPage {} }
|
||||||
|
Component { id: onlineAccountsPage; OnlineAccountsPage {} }
|
||||||
Component { id: servicesPage; ServicesPage {} }
|
Component { id: servicesPage; ServicesPage {} }
|
||||||
Component { id: aboutPage; AboutPage {} }
|
Component { id: aboutPage; AboutPage {} }
|
||||||
|
|
||||||
|
|||||||
@@ -31,13 +31,17 @@ Rectangle {
|
|||||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||||
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
|
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
|
||||||
|
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
|
||||||
|
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
|
||||||
|
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
|
||||||
|
{ page: "accounts", label: "Online Accounts", icon: "\u{F0004}" },
|
||||||
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
||||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||||
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
{ page: "applications", label: "Applications", icon: "\u{F003B}" },
|
||||||
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
|
{ page: "services", label: "Startup & Services", icon: "\u{F0493}" },
|
||||||
{ page: "about", label: "About Panama", icon: "\u{F02FD}" }
|
{ page: "about", label: "About", icon: "\u{F02FD}" }
|
||||||
]
|
]
|
||||||
|
|
||||||
width: 272
|
width: 272
|
||||||
@@ -56,15 +60,6 @@ Rectangle {
|
|||||||
height: implicitHeight
|
height: implicitHeight
|
||||||
spacing: 12
|
spacing: 12
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "PANAMA"
|
|
||||||
color: Theme.fg
|
|
||||||
font.family: Theme.fontFamily
|
|
||||||
font.pixelSize: Theme.fontSizeLarge
|
|
||||||
font.weight: Font.DemiBold
|
|
||||||
font.letterSpacing: 2.2
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 36
|
height: 36
|
||||||
@@ -314,7 +309,7 @@ Rectangle {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 36
|
anchors.leftMargin: 36
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Panama desktop is healthy"
|
text: "Desktop is healthy"
|
||||||
color: Theme.fgDim
|
color: Theme.fgDim
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ FloatingWindow {
|
|||||||
|
|
||||||
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
||||||
|
|
||||||
title: "Panama Settings"
|
title: "Settings"
|
||||||
visible: ShellState.settingsOpen
|
visible: ShellState.settingsOpen
|
||||||
implicitWidth: 1120
|
implicitWidth: 1120
|
||||||
implicitHeight: 760
|
implicitHeight: 760
|
||||||
|
|||||||
@@ -28,24 +28,19 @@ SettingsPage {
|
|||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Keyboard"
|
title: "Keyboard"
|
||||||
|
|
||||||
TextRow {
|
// These were read-only text, on the grounds that a layout change needed
|
||||||
label: "Keyboard layout"
|
// a compositor reload. It does not: setting input:kb_variant through
|
||||||
detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly."
|
// hl.config re-keymaps attached keyboards immediately -- verified by
|
||||||
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us"
|
// watching active_keymap on a real keyboard change and change back. So
|
||||||
}
|
// they are real controls.
|
||||||
|
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||||
|
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
||||||
|
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||||
SliderRow { setting: "keyRepeatDelay" }
|
SliderRow { setting: "keyRepeatDelay" }
|
||||||
SliderRow { setting: "keyRepeatRate" }
|
SliderRow { setting: "keyRepeatRate" }
|
||||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Pointer"
|
|
||||||
|
|
||||||
ChoiceRow { setting: "followMouse" }
|
|
||||||
SliderRow { setting: "pointerSensitivity" }
|
|
||||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Hardware input"
|
title: "Hardware input"
|
||||||
|
|
||||||
@@ -155,7 +150,7 @@ SettingsPage {
|
|||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: Object.keys(Keybinds.overrides).length > 0
|
visible: Object.keys(Keybinds.overrides).length > 0
|
||||||
title: "Changed shortcuts"
|
title: "Changed shortcuts"
|
||||||
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from Panama's configuration."
|
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Restore every shipped shortcut"
|
label: "Restore every shipped shortcut"
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// A free-text setting, bound to a schema key by name.
|
||||||
|
//
|
||||||
|
// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||||
|
//
|
||||||
|
// For the settings whose value is a short string with too many legal values to
|
||||||
|
// enumerate -- XKB layouts and options, chiefly. Label, explanation, and
|
||||||
|
// validation all come from PreferenceSchema, so a row cannot drift from the
|
||||||
|
// setting it edits.
|
||||||
|
//
|
||||||
|
// Rejected input is shown as rejected rather than silently dropped or quietly
|
||||||
|
// sanitised. Several of these strings are serialised into an hl.config payload,
|
||||||
|
// where stripping an unexpected character would turn a typo into a different
|
||||||
|
// working setting -- so the schema's pattern decides, the field turns red when
|
||||||
|
// it fails, and nothing is committed until it passes.
|
||||||
|
//
|
||||||
|
// Committed on Enter or when focus leaves, not per keystroke: half a layout
|
||||||
|
// name is a valid string that means something else, and each commit is a round
|
||||||
|
// trip to the compositor.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string setting
|
||||||
|
property string placeholder: ""
|
||||||
|
|
||||||
|
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||||
|
readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "")
|
||||||
|
|
||||||
|
// Empty is always allowed to be typed through, even where the pattern
|
||||||
|
// forbids it, so a field can be cleared on the way to a new value.
|
||||||
|
readonly property bool valid: input.text === ""
|
||||||
|
|| !root.spec?.pattern
|
||||||
|
|| new RegExp(root.spec.pattern).test(input.text)
|
||||||
|
|
||||||
|
label: root.spec ? root.spec.label : root.setting
|
||||||
|
detail: root.spec ? root.spec.detail : ""
|
||||||
|
controlWidth: 210
|
||||||
|
|
||||||
|
function commit(): void {
|
||||||
|
if (!root.valid || input.text === root.stored)
|
||||||
|
return;
|
||||||
|
SystemSettings.commitPreference(root.setting, input.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revert(): void {
|
||||||
|
input.text = root.stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follows the store when the value changes elsewhere -- a reset, a restored
|
||||||
|
// backup -- but never while the field has focus, which would overwrite what
|
||||||
|
// is being typed.
|
||||||
|
onStoredChanged: if (!input.activeFocus) input.text = root.stored
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: root.controlWidth
|
||||||
|
height: 30
|
||||||
|
radius: 8
|
||||||
|
color: Theme.alpha(Theme.fg, 0.05)
|
||||||
|
border.width: 1
|
||||||
|
border.color: !root.valid
|
||||||
|
? Theme.danger
|
||||||
|
: (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: input
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
text: root.stored
|
||||||
|
color: root.valid ? Theme.fg : Theme.danger
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
selectByMouse: true
|
||||||
|
selectionColor: Theme.alpha(Theme.accent, 0.35)
|
||||||
|
|
||||||
|
onAccepted: root.commit()
|
||||||
|
|
||||||
|
// Commit, then show what is actually stored. If the compositor
|
||||||
|
// refused the value, the field snaps back to the value in effect
|
||||||
|
// rather than displaying a setting that was never applied; if it
|
||||||
|
// accepted, onStoredChanged brings the field back up to date the
|
||||||
|
// moment the write lands.
|
||||||
|
onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); }
|
||||||
|
|
||||||
|
Keys.onEscapePressed: {
|
||||||
|
root.revert();
|
||||||
|
input.focus = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: input.text === "" && !input.activeFocus
|
||||||
|
text: root.placeholder
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font: input.font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,7 +156,7 @@ Item {
|
|||||||
width: parent.width - 40
|
width: parent.width - 40
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
wrapMode: Text.WordWrap
|
wrapMode: Text.WordWrap
|
||||||
text: "No images found. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
text: "No images found. Looked in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||||
color: Theme.fgMuted
|
color: Theme.fgMuted
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
|||||||
@@ -46,3 +46,9 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml
|
|||||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||||
LocationPicker 1.0 LocationPicker.qml
|
LocationPicker 1.0 LocationPicker.qml
|
||||||
FontPicker 1.0 FontPicker.qml
|
FontPicker 1.0 FontPicker.qml
|
||||||
|
MousePage 1.0 MousePage.qml
|
||||||
|
TextEntryRow 1.0 TextEntryRow.qml
|
||||||
|
PrivacyPage 1.0 PrivacyPage.qml
|
||||||
|
RegionPage 1.0 RegionPage.qml
|
||||||
|
SearchPicker 1.0 SearchPicker.qml
|
||||||
|
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
|
||||||
|
|||||||
Executable
+127
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# What this machine is, as JSON: {label, value} pairs in display order.
|
||||||
|
#
|
||||||
|
# GNOME's About panel answers "what am I running on" in one screen, and
|
||||||
|
# fastfetch answers it in more detail; this covers both -- model, OS, kernel,
|
||||||
|
# uptime, package counts, shell, resolution, processor, memory, swap, disk and
|
||||||
|
# locale. Rows are ordered roughly the way fastfetch presents them: what the
|
||||||
|
# system is, then what is installed on it, then the hardware underneath.
|
||||||
|
#
|
||||||
|
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
|
||||||
|
# the vitals readout, and naming them again here would be a second source of
|
||||||
|
# truth that could disagree with the first. The page joins the two.
|
||||||
|
#
|
||||||
|
# Anything unreadable is omitted rather than reported as "Unknown". These are
|
||||||
|
# facts about hardware, and a row saying "Processor: Unknown" is noise where
|
||||||
|
# simply not having the row is not.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
facts=()
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
[[ -n "${2:-}" ]] || return 0
|
||||||
|
facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')")
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── System ───────────────────────────────────────────────────────────────────
|
||||||
|
if [[ -r /etc/os-release ]]; then
|
||||||
|
# Sourced in a subshell so the variables cannot leak into this script.
|
||||||
|
os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )"
|
||||||
|
emit "Operating system" "$os"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default
|
||||||
|
# string"). Those are worse than nothing, so they are filtered out.
|
||||||
|
dmi() {
|
||||||
|
local value
|
||||||
|
value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0
|
||||||
|
case "$value" in
|
||||||
|
""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;;
|
||||||
|
esac
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
vendor="$(dmi sys_vendor)"
|
||||||
|
product="$(dmi product_name)"
|
||||||
|
if [[ -n "$vendor" && -n "$product" ]]; then
|
||||||
|
emit "Model" "$vendor $product"
|
||||||
|
else
|
||||||
|
emit "Model" "${product:-$vendor}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
|
||||||
|
emit "Kernel" "$(uname -r 2>/dev/null)"
|
||||||
|
|
||||||
|
# `uptime -p` already reads as prose ("1 week, 23 hours, 5 minutes"); only the
|
||||||
|
# leading "up " needs removing.
|
||||||
|
emit "Uptime" "$(uptime -p 2>/dev/null | sed 's/^up //')"
|
||||||
|
|
||||||
|
# ── Installed ────────────────────────────────────────────────────────────────
|
||||||
|
# Counted rather than listed. rpm -qa on a full workstation is a few thousand
|
||||||
|
# lines and takes a moment, which is part of why this whole helper runs on
|
||||||
|
# demand rather than at startup.
|
||||||
|
packages=""
|
||||||
|
if command -v rpm >/dev/null 2>&1; then
|
||||||
|
rpm_count="$(rpm -qa 2>/dev/null | wc -l)"
|
||||||
|
[[ "$rpm_count" -gt 0 ]] && packages="$rpm_count rpm"
|
||||||
|
fi
|
||||||
|
if command -v flatpak >/dev/null 2>&1; then
|
||||||
|
flatpak_count="$(flatpak list --app 2>/dev/null | wc -l)"
|
||||||
|
if [[ "$flatpak_count" -gt 0 ]]; then
|
||||||
|
[[ -n "$packages" ]] && packages="$packages, "
|
||||||
|
packages="$packages$flatpak_count flatpak"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
emit "Packages" "$packages"
|
||||||
|
|
||||||
|
# $SHELL is the login shell, which is the one worth reporting -- the shell this
|
||||||
|
# script happens to run under is an implementation detail of the caller.
|
||||||
|
if [[ -n "${SHELL:-}" ]]; then
|
||||||
|
shell_name="$(basename "$SHELL")"
|
||||||
|
shell_version="$("$SHELL" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)"
|
||||||
|
emit "Shell" "${shell_name}${shell_version:+ $shell_version}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
emit "Locale" "${LANG:-}"
|
||||||
|
|
||||||
|
case "${XDG_SESSION_TYPE:-}" in
|
||||||
|
wayland) emit "Windowing system" "Wayland" ;;
|
||||||
|
x11) emit "Windowing system" "X11" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── Hardware ─────────────────────────────────────────────────────────────────
|
||||||
|
# The focused monitor's actual mode, including the fractional scale, since a
|
||||||
|
# 4500x3000 panel at 1.5 presents very differently from one at 1.
|
||||||
|
if command -v hyprctl >/dev/null 2>&1; then
|
||||||
|
emit "Resolution" "$(hyprctl -j monitors 2>/dev/null \
|
||||||
|
| jq -r 'map(select(.focused)) + . | .[0]
|
||||||
|
| select(. != null)
|
||||||
|
| "\(.width)x\(.height) @ \(.refreshRate | floor)Hz · scale \(.scale)"' 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)"
|
||||||
|
threads="$(nproc 2>/dev/null)"
|
||||||
|
if [[ -n "$cpu" ]]; then
|
||||||
|
# The marketing name usually already says "6-Core", so the thread count is
|
||||||
|
# the part that adds information.
|
||||||
|
[[ -n "$threads" ]] && cpu="$cpu ($threads threads)"
|
||||||
|
emit "Processor" "$cpu"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reported as the kernel sees it, which is a little under the sticker figure
|
||||||
|
# because firmware and integrated graphics reserve some before Linux starts.
|
||||||
|
#
|
||||||
|
# Total rather than used: About is not a monitor, and a "12.4 GiB used" figure
|
||||||
|
# is stale before it finishes drawing. The Home page's vitals readout is where
|
||||||
|
# live numbers belong.
|
||||||
|
emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
|
||||||
|
|
||||||
|
swap="$(awk '/^SwapTotal:/ { if ($2 > 0) printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
|
||||||
|
emit "Swap" "$swap"
|
||||||
|
|
||||||
|
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
|
||||||
|
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
|
||||||
|
|
||||||
|
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Online accounts, through GNOME Online Accounts.
|
||||||
|
|
||||||
|
GOA is a daemon plus a D-Bus API, and it is already running in this session --
|
||||||
|
gvfs activates it, and the four accounts on this machine work without
|
||||||
|
gnome-shell involved anywhere. What GNOME owns is only the *panel*; the accounts
|
||||||
|
themselves are ordinary D-Bus objects that anything may read and modify.
|
||||||
|
|
||||||
|
So everything except the initial sign-in is available to us: listing accounts,
|
||||||
|
turning individual services on and off, and removing an account. That is the
|
||||||
|
whole Online Accounts panel apart from one OAuth handshake.
|
||||||
|
|
||||||
|
Adding an account is the part that splits. The daemon's AddAccount takes
|
||||||
|
credentials as an ARGUMENT -- it stores them, it does not obtain them. For
|
||||||
|
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
|
||||||
|
password, which a settings app can reasonably collect. For Google it is an OAuth
|
||||||
|
token, and the code that runs that exchange lives in libgoa-backend, which
|
||||||
|
Fedora ships without a GIR binding -- reachable from C only. Reimplementing it
|
||||||
|
would mean our own Google client credentials. So Google sign-in is handed to
|
||||||
|
GNOME's panel, and only the sign-in.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
panama-accounts list
|
||||||
|
panama-accounts set <object-path> <service> <true|false>
|
||||||
|
panama-accounts remove <object-path>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Every service GOA models. The account object carries one interface per service
|
||||||
|
# it supports, so presence of the interface is what "this account can do mail"
|
||||||
|
# means -- there is no capability list to read.
|
||||||
|
SERVICES = [
|
||||||
|
("mail", "Mail", "get_mail", "mail_disabled"),
|
||||||
|
("calendar", "Calendar", "get_calendar", "calendar_disabled"),
|
||||||
|
("contacts", "Contacts", "get_contacts", "contacts_disabled"),
|
||||||
|
("files", "Files", "get_files", "files_disabled"),
|
||||||
|
("photos", "Photos", "get_photos", "photos_disabled"),
|
||||||
|
("music", "Music", "get_music", "music_disabled"),
|
||||||
|
("chat", "Chat", "get_chat", "chat_disabled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_client():
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Goa", "1.0")
|
||||||
|
from gi.repository import Goa
|
||||||
|
|
||||||
|
return Goa.Client.new_sync(None)
|
||||||
|
|
||||||
|
|
||||||
|
def describe(obj):
|
||||||
|
account = obj.get_account()
|
||||||
|
services = []
|
||||||
|
for key, label, getter, disabled_prop in SERVICES:
|
||||||
|
if getattr(obj, getter)() is None:
|
||||||
|
continue
|
||||||
|
services.append({
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"enabled": not getattr(account.props, disabled_prop),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"path": obj.get_object_path(),
|
||||||
|
"provider": account.props.provider_type,
|
||||||
|
"providerName": account.props.provider_name,
|
||||||
|
# PresentationIdentity is the human one (an email address); Identity is
|
||||||
|
# the internal handle and is not always readable.
|
||||||
|
"identity": account.props.presentation_identity or account.props.identity,
|
||||||
|
# GOA raises this when stored credentials stop working -- an expired
|
||||||
|
# token, a changed password. It is the one piece of state a user must
|
||||||
|
# act on, and nothing else surfaces it.
|
||||||
|
"needsAttention": bool(account.props.attention_needed),
|
||||||
|
"services": services,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find(client, path):
|
||||||
|
for obj in client.get_accounts():
|
||||||
|
if obj.get_object_path() == path:
|
||||||
|
return obj
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
action = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = load_client()
|
||||||
|
except Exception as error: # noqa: BLE001 - any failure here means "no GOA"
|
||||||
|
# A machine without GOA is a legitimate state, not a crash.
|
||||||
|
print(json.dumps({
|
||||||
|
"accounts": [],
|
||||||
|
"error": f"GNOME Online Accounts is not available: {error}",
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "list":
|
||||||
|
print(json.dumps({
|
||||||
|
"accounts": [describe(obj) for obj in client.get_accounts()],
|
||||||
|
"error": "",
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "set":
|
||||||
|
if len(sys.argv) != 5:
|
||||||
|
print("usage: panama-accounts set <path> <service> <true|false>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
path, service, value = sys.argv[2], sys.argv[3], sys.argv[4]
|
||||||
|
obj = find(client, path)
|
||||||
|
if obj is None:
|
||||||
|
print(f"panama-accounts: no account at {path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
match = next((s for s in SERVICES if s[0] == service), None)
|
||||||
|
if match is None:
|
||||||
|
print(f"panama-accounts: unknown service {service!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if getattr(obj, match[2])() is None:
|
||||||
|
print(f"panama-accounts: this account does not support {service}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
# The property is "disabled", so enabling a service clears it.
|
||||||
|
setattr(obj.get_account().props, match[3], value.lower() not in ("true", "1", "yes"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "remove":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("usage: panama-accounts remove <path>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
obj = find(client, sys.argv[2])
|
||||||
|
if obj is None:
|
||||||
|
print(f"panama-accounts: no account at {sys.argv[2]}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
obj.get_account().call_remove_sync(None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("usage: panama-accounts [list|set|remove]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# External monitor brightness over DDC/CI.
|
||||||
|
#
|
||||||
|
# A desktop with no backlight class device has no brightness control at all --
|
||||||
|
# brightnessctl only sees keyboard and NIC LEDs. The panel itself still has a
|
||||||
|
# brightness setting, reachable over the monitor's DDC/CI channel (VCP feature
|
||||||
|
# 0x10), which is what the buttons on the bezel drive.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# panama-brightness list -> {"displays":[...],"error":""}
|
||||||
|
# panama-brightness get <bus> -> integer percent
|
||||||
|
# panama-brightness set <bus> <pct> -> applies, prints nothing
|
||||||
|
#
|
||||||
|
# Displays are enumerated from sysfs rather than from `ddcutil detect`. The
|
||||||
|
# kernel publishes the connector-to-I2C-bus mapping directly, as
|
||||||
|
# /sys/class/drm/<card>-<connector>/ddc, along with whether anything is plugged
|
||||||
|
# in. That is better than parsing detect output in three ways: the format is
|
||||||
|
# stable where detect's brief output is undocumented, the connector name comes
|
||||||
|
# out exactly as Hyprland and the Displays page already spell it (DP-2), and
|
||||||
|
# only connectors with a monitor attached get probed -- one bus on this machine
|
||||||
|
# instead of fourteen, which is the difference between a fast scan and a slow
|
||||||
|
# one, since each probe of an empty bus waits for a timeout.
|
||||||
|
#
|
||||||
|
# No model name is reported. Hyprland already knows the human-readable
|
||||||
|
# description of every output, so the UI joins on the connector name rather than
|
||||||
|
# having two sources of truth for what a monitor is called.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
readonly VCP_BRIGHTNESS=0x10
|
||||||
|
|
||||||
|
# Test seams. The contract needs to exercise enumeration and parsing on a
|
||||||
|
# machine whose real monitors it must not touch, so both roots this script
|
||||||
|
# reads are overridable. Nothing sets them in normal use.
|
||||||
|
readonly DRM_ROOT="${PANAMA_BRIGHTNESS_DRM_ROOT:-/sys/class/drm}"
|
||||||
|
readonly DEV_ROOT="${PANAMA_BRIGHTNESS_DEV_ROOT:-/dev}"
|
||||||
|
|
||||||
|
emit_error() {
|
||||||
|
printf '{"displays":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v ddcutil >/dev/null 2>&1 || emit_error 'ddcutil is not installed'
|
||||||
|
|
||||||
|
# Reading a VCP value needs read/write access to the monitor's I2C bus. The
|
||||||
|
# udev rule ddcutil ships grants that to the seat user through uaccess, but only
|
||||||
|
# to devices created after the rule was installed -- so a machine that installed
|
||||||
|
# ddcutil without rebooting has the rule in place and no access to show for it.
|
||||||
|
# That is by far the most likely reason for an empty list, and it is fixable in
|
||||||
|
# one command, so say so rather than reporting "no displays".
|
||||||
|
has_accessible_bus() {
|
||||||
|
local dev
|
||||||
|
for dev in "$DEV_ROOT"/i2c-*; do
|
||||||
|
[[ -r "$dev" && -w "$dev" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The I2C bus that carries DDC/CI for one connector.
|
||||||
|
#
|
||||||
|
# There are two, and picking the wrong one finds no monitor at all:
|
||||||
|
#
|
||||||
|
# DisplayPort carries DDC/CI over the AUX channel. That adapter shows up as a
|
||||||
|
# child directory of the connector -- /sys/class/drm/card1-DP-2/i2c-9 -- and
|
||||||
|
# is the one ddcutil talks to.
|
||||||
|
#
|
||||||
|
# The connector's `ddc` symlink points at the classic I2C line used for EDID
|
||||||
|
# on HDMI and DVI. On a DisplayPort connector it still exists and still
|
||||||
|
# resolves, but nothing answers on it: this machine's DP-2 has ddc -> i2c-5,
|
||||||
|
# where ddcutil reports "No monitor detected", while i2c-9 answers VCP 0x10
|
||||||
|
# immediately.
|
||||||
|
#
|
||||||
|
# So prefer the AUX child and fall back to the symlink. Note the readlink: the
|
||||||
|
# entries under /sys/class/drm are symlinks, and `find` does not follow the path
|
||||||
|
# it is given, so searching the unresolved path silently finds nothing.
|
||||||
|
bus_for_connector() {
|
||||||
|
local path="$1" real aux ddc
|
||||||
|
|
||||||
|
real="$(readlink -f "$path")"
|
||||||
|
aux="$(find "$real" -maxdepth 1 -name 'i2c-*' -printf '%f' -quit 2>/dev/null)"
|
||||||
|
if [[ -n "$aux" ]]; then
|
||||||
|
printf '%s' "${aux#i2c-}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ddc="$(readlink -f "$path/ddc" 2>/dev/null)" || return 1
|
||||||
|
[[ -n "$ddc" ]] || return 1
|
||||||
|
ddc="$(basename "$ddc")"
|
||||||
|
printf '%s' "${ddc#i2c-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
|
||||||
|
|
||||||
|
local rows=() connector bus value path
|
||||||
|
for path in "$DRM_ROOT"/card*-*; do
|
||||||
|
[[ -e "$path/ddc" ]] || continue
|
||||||
|
[[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue
|
||||||
|
|
||||||
|
# card1-DP-2 -> DP-2, the name Hyprland uses.
|
||||||
|
connector="$(basename "$path")"
|
||||||
|
connector="${connector#card*-}"
|
||||||
|
|
||||||
|
bus="$(bus_for_connector "$path")"
|
||||||
|
[[ "$bus" =~ ^[0-9]+$ ]] || continue
|
||||||
|
|
||||||
|
# A monitor that does not implement 0x10 is not an error; it simply
|
||||||
|
# cannot be controlled, and is left out rather than shown as a slider
|
||||||
|
# that does nothing.
|
||||||
|
value="$(cmd_get "$bus")" || continue
|
||||||
|
[[ -n "$value" ]] || continue
|
||||||
|
|
||||||
|
rows+=("$(jq -cn \
|
||||||
|
--argjson bus "$bus" \
|
||||||
|
--arg connector "$connector" \
|
||||||
|
--argjson value "$value" \
|
||||||
|
'{bus: $bus, connector: $connector, value: $value}')")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||||
|
emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"displays":[%s],"error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints the current brightness as a whole percent, or nothing when the display
|
||||||
|
# cannot report it. `getvcp --brief` is documented as machine readable and
|
||||||
|
# answers "VCP 10 C <current> <max>"; the max is almost always 100 but is not
|
||||||
|
# guaranteed to be, so it is read rather than assumed.
|
||||||
|
cmd_get() {
|
||||||
|
local bus="$1" out current max
|
||||||
|
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||||
|
read -r _ _ _ current max <<<"$out"
|
||||||
|
[[ "$current" =~ ^[0-9]+$ && "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || return 1
|
||||||
|
printf '%s' "$(( current * 100 / max ))"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_set() {
|
||||||
|
local bus="$1" percent="$2" max out
|
||||||
|
[[ "$percent" =~ ^[0-9]+$ ]] || return 1
|
||||||
|
(( percent > 100 )) && percent=100
|
||||||
|
|
||||||
|
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||||
|
read -r _ _ _ _ max <<<"$out"
|
||||||
|
[[ "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || max=100
|
||||||
|
|
||||||
|
timeout 10 ddcutil --bus "$bus" setvcp "$VCP_BRIGHTNESS" "$(( percent * max / 100 ))" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
get) cmd_get "${2:?bus required}" ;;
|
||||||
|
set) cmd_set "${2:?bus required}" "${3:?percent required}" ;;
|
||||||
|
*) printf 'usage: panama-brightness [list|get <bus>|set <bus> <percent>]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""The login keyring's lock state, and a way to unlock it.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so
|
||||||
|
does this desktop -- the PAM stack is GDM's and it works. What GNOME also has,
|
||||||
|
and a bare Hyprland session does not, is anywhere to SEE that it failed.
|
||||||
|
|
||||||
|
It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in
|
||||||
|
service_method_open_session, seen once here), and when it does, D-Bus activates
|
||||||
|
a replacement. That replacement never received the login password, so the login
|
||||||
|
keyring comes back LOCKED in the middle of a session that unlocked it correctly
|
||||||
|
at login. Everything that stores a secret then starts failing in ways that do
|
||||||
|
not mention keyrings at all: a mail client that will not authenticate, a git
|
||||||
|
push that cannot find its key, an integration that reports "not configured".
|
||||||
|
|
||||||
|
So this reports the state plainly and offers the one action that fixes it.
|
||||||
|
|
||||||
|
Unlocking prompts
|
||||||
|
-----------------
|
||||||
|
`unlock` asks the Secret Service to unlock, which raises the gcr password
|
||||||
|
dialog. That is deliberate: the password is not ours to store or handle, and it
|
||||||
|
never passes through this script. The dialog is the same one GNOME shows.
|
||||||
|
|
||||||
|
Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it
|
||||||
|
is not hung, it is waiting for a dialog nobody is looking at. That is worth
|
||||||
|
knowing before debugging one for an hour.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
panama-keyring status -> {"available", "locked", "collections", "daemon"}
|
||||||
|
panama-keyring unlock -> raises the password prompt; prints the new state
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def daemon_origin():
|
||||||
|
"""Whether the running secrets daemon came from PAM or from D-Bus activation.
|
||||||
|
|
||||||
|
A D-Bus-activated daemon is the signature of the crash-and-replace case
|
||||||
|
above: it is the one that cannot have the login password. PAM's daemon lives
|
||||||
|
outside the app slice, so the cgroup tells the two apart.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as handle:
|
||||||
|
cmdline = handle.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if "gnome-keyring-daemon" not in cmdline:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cgroup", "r") as handle:
|
||||||
|
cgroup = handle.read()
|
||||||
|
except OSError:
|
||||||
|
return "unknown"
|
||||||
|
if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup):
|
||||||
|
return "dbus"
|
||||||
|
return "pam"
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
def load_service():
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Secret", "1")
|
||||||
|
from gi.repository import Secret
|
||||||
|
|
||||||
|
return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None)
|
||||||
|
|
||||||
|
|
||||||
|
def report(service, Secret):
|
||||||
|
collections = [
|
||||||
|
{"label": c.get_label(), "locked": c.get_locked()}
|
||||||
|
for c in service.get_collections()
|
||||||
|
]
|
||||||
|
# The login keyring is the one that matters; the others are per-application
|
||||||
|
# stores that manage their own unlocking.
|
||||||
|
login = next((c for c in collections if c["label"] == "Login"), None)
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"locked": bool(login["locked"]) if login else False,
|
||||||
|
"hasLogin": login is not None,
|
||||||
|
"collections": collections,
|
||||||
|
"daemon": daemon_origin(),
|
||||||
|
"error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
action = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||||
|
if action not in ("status", "unlock"):
|
||||||
|
print("usage: panama-keyring [status|unlock]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
Secret, service = load_service()
|
||||||
|
except Exception as error: # noqa: BLE001 - any failure here is "no keyring"
|
||||||
|
# No Secret Service at all is a legitimate state, not a crash: report it
|
||||||
|
# so the UI can say so instead of showing an empty card.
|
||||||
|
print(json.dumps({
|
||||||
|
"available": False, "locked": False, "hasLogin": False,
|
||||||
|
"collections": [], "daemon": daemon_origin(),
|
||||||
|
"error": f"The secret service is not answering: {error}",
|
||||||
|
}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if action == "unlock":
|
||||||
|
login = next(
|
||||||
|
(c for c in service.get_collections() if c.get_label() == "Login"), None)
|
||||||
|
if login is not None and login.get_locked():
|
||||||
|
try:
|
||||||
|
# Blocks until the dialog is answered or dismissed.
|
||||||
|
service.unlock_sync([login], None)
|
||||||
|
except Exception as error: # noqa: BLE001
|
||||||
|
state = report(service, Secret)
|
||||||
|
state["error"] = f"The keyring was not unlocked: {error}"
|
||||||
|
print(json.dumps(state))
|
||||||
|
return 0
|
||||||
|
# The collection object caches its state; re-read it.
|
||||||
|
Secret, service = load_service()
|
||||||
|
|
||||||
|
print(json.dumps(report(service, Secret)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# System locale, via localectl.
|
||||||
|
#
|
||||||
|
# panama-locale list -> [{value, label, detail}]
|
||||||
|
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
|
||||||
|
# panama-locale set <locale>
|
||||||
|
#
|
||||||
|
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
|
||||||
|
# already know, which defeats the point of a picker, so codes are resolved
|
||||||
|
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
|
||||||
|
# The code stays visible as the row's detail, because it is what actually gets
|
||||||
|
# written and someone choosing between two Spanish variants needs to see it.
|
||||||
|
#
|
||||||
|
# The join happens in a single jq pass. Doing it per locale meant 327 jq
|
||||||
|
# invocations, which took long enough to be visible when opening the page.
|
||||||
|
#
|
||||||
|
# Setting the locale is a privileged operation: localectl goes through polkit,
|
||||||
|
# which prompts. It also only takes effect for programs started afterwards, so
|
||||||
|
# the caller is responsible for saying a sign-out is needed -- this script does
|
||||||
|
# not pretend the running session changed.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
|
||||||
|
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
|
||||||
|
|
||||||
|
cmd_get() {
|
||||||
|
localectl status 2>/dev/null \
|
||||||
|
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
|
||||||
|
| tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local locales
|
||||||
|
locales="$(localectl list-locales 2>/dev/null)" || locales=""
|
||||||
|
if [[ -z "$locales" ]]; then
|
||||||
|
printf '[]\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Without iso-codes installed the codes are still perfectly usable; they
|
||||||
|
# just do not get friendly names. That is a degraded list, not a failure.
|
||||||
|
if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
|
||||||
|
jq -Rn --rawfile raw /dev/stdin \
|
||||||
|
'[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
|
||||||
|
<<<"$locales"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -Rn \
|
||||||
|
--slurpfile languages "$ISO_LANG" \
|
||||||
|
--slurpfile countries "$ISO_COUNTRY" \
|
||||||
|
--rawfile raw /dev/stdin '
|
||||||
|
# alpha_2 -> name, for both databases. Languages without a two-letter
|
||||||
|
# code cannot appear in a locale name, so they are simply absent.
|
||||||
|
($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
|
||||||
|
| ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
|
||||||
|
| [ $raw
|
||||||
|
| split("\n")[]
|
||||||
|
| select(length > 0)
|
||||||
|
| . as $value
|
||||||
|
# en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
|
||||||
|
# not part of the human name.
|
||||||
|
| ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
|
||||||
|
| ($lang[$parts[0]] // $parts[0]) as $language
|
||||||
|
| (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
|
||||||
|
| {
|
||||||
|
value: $value,
|
||||||
|
label: (if $region then "\($language) (\($region))" else $language end),
|
||||||
|
detail: $value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
| sort_by(.label)
|
||||||
|
' <<<"$locales"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_set() {
|
||||||
|
local locale="${1:-}"
|
||||||
|
# Constrained rather than passed through: this reaches a privileged
|
||||||
|
# command, and the set of legal locale names is narrow and well known.
|
||||||
|
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
|
||||||
|
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
|
||||||
|
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
localectl set-locale "LANG=$locale"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
get) cmd_get ;;
|
||||||
|
set) shift; cmd_set "${1:-}" ;;
|
||||||
|
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
|
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
strict_delivery() {
|
strict_delivery() {
|
||||||
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
||||||
}
|
}
|
||||||
@@ -67,18 +69,151 @@ adjust_microphone() {
|
|||||||
show_volume "$target" microphone
|
show_volume "$target" microphone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
brightness_percent() {
|
||||||
|
local output="$1" percent
|
||||||
|
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||||
|
[[ $percent =~ ^[0-9]+$ ]] || return 1
|
||||||
|
printf '%s\n' "$percent"
|
||||||
|
}
|
||||||
|
|
||||||
|
brightness_error() {
|
||||||
|
local detail="$1" label="External brightness unavailable"
|
||||||
|
if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then
|
||||||
|
label="Brightness needs permission"
|
||||||
|
fi
|
||||||
|
|
||||||
|
show_message dialog-warning-symbolic "$label" || true
|
||||||
|
if command -v notify-send >/dev/null 2>&1; then
|
||||||
|
notify-send --app-name=Panama --icon=display-brightness-symbolic \
|
||||||
|
"Brightness unavailable" "$detail" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
discover_ddc_bus() {
|
||||||
|
local helper="$1" cache_file="$2" list_json focused selected error bus connector
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || {
|
||||||
|
brightness_error "jq is required to discover DDC/CI displays."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
list_json="$("$helper" list 2>/dev/null)" || {
|
||||||
|
brightness_error "The external brightness helper could not inspect connected displays."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then
|
||||||
|
brightness_error "The external brightness helper returned invalid display information."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
error="$(jq -r '.error // empty' <<<"$list_json")"
|
||||||
|
if [[ -n $error ]]; then
|
||||||
|
brightness_error "$error"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
focused="$(hyprctl -j monitors 2>/dev/null \
|
||||||
|
| jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \
|
||||||
|
| head -n1)"
|
||||||
|
selected="$(jq -r --arg connector "$focused" '
|
||||||
|
([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty)
|
||||||
|
| [.bus, .connector]
|
||||||
|
| @tsv
|
||||||
|
' <<<"$list_json")"
|
||||||
|
IFS=$'\t' read -r bus connector <<<"$selected"
|
||||||
|
if [[ ! $bus =~ ^[0-9]+$ ]]; then
|
||||||
|
brightness_error "No connected monitor exposes DDC/CI brightness control."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
printf '%s\t%s\n' "$bus" "$connector" >"$cache_file"
|
||||||
|
printf '%s\n' "$bus"
|
||||||
|
}
|
||||||
|
|
||||||
|
adjust_ddc_brightness() {
|
||||||
|
local action="$1" step="$2"
|
||||||
|
local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}"
|
||||||
|
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
|
||||||
|
local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock"
|
||||||
|
local bus="" connector="" current target lock_fd
|
||||||
|
|
||||||
|
[[ -x $helper ]] || {
|
||||||
|
brightness_error "The external brightness helper is not installed."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
mkdir -p "$runtime_dir" || return 0
|
||||||
|
chmod 700 "$runtime_dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
exec {lock_fd}>"$lock_file" || return 0
|
||||||
|
# DDC transactions on one I2C bus cannot safely overlap. A short wait also
|
||||||
|
# sheds an excessive key-repeat backlog instead of replaying it seconds later.
|
||||||
|
flock -w 2 "$lock_fd" || return 0
|
||||||
|
|
||||||
|
if [[ -r $cache_file ]]; then
|
||||||
|
IFS=$'\t' read -r bus connector <"$cache_file" || true
|
||||||
|
[[ $bus =~ ^[0-9]+$ ]] || bus=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n $bus ]]; then
|
||||||
|
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
|
||||||
|
if [[ ! $current =~ ^[0-9]+$ ]]; then
|
||||||
|
: >"$cache_file"
|
||||||
|
bus=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $bus ]]; then
|
||||||
|
bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0
|
||||||
|
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
|
||||||
|
fi
|
||||||
|
if [[ ! $current =~ ^[0-9]+$ ]]; then
|
||||||
|
brightness_error "The selected monitor stopped responding over DDC/CI."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $action == up ]]; then
|
||||||
|
target=$(( current + step ))
|
||||||
|
else
|
||||||
|
target=$(( current - step ))
|
||||||
|
fi
|
||||||
|
(( target > 100 )) && target=100
|
||||||
|
(( target < 0 )) && target=0
|
||||||
|
|
||||||
|
if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then
|
||||||
|
brightness_error "The selected monitor did not accept the brightness change."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
show_progress brightness "$target" "${target}%"
|
||||||
|
}
|
||||||
|
|
||||||
adjust_brightness() {
|
adjust_brightness() {
|
||||||
local action="${1:-}" step="${2:-5}" output percent
|
local action="${1:-}" step="${2:-5}" output percent
|
||||||
|
[[ $step =~ ^[0-9]+$ ]] || {
|
||||||
|
printf 'Usage: panama-osd brightness up|down [step]\n' >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
case "$action" in
|
case "$action" in
|
||||||
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
|
up|down) ;;
|
||||||
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
|
|
||||||
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Laptop panels expose a kernel backlight class and remain the fastest,
|
||||||
|
# most reliable path. Desktops fall through to DDC/CI monitor control.
|
||||||
|
output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
|
||||||
|
if percent="$(brightness_percent "$output")"; then
|
||||||
|
if [[ $action == up ]]; then
|
||||||
|
brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0
|
||||||
|
else
|
||||||
|
brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0
|
||||||
|
fi
|
||||||
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
||||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
percent="$(brightness_percent "$output")" || return 0
|
||||||
[[ $percent =~ ^[0-9]+$ ]] || return 0
|
|
||||||
show_progress brightness "$percent" "${percent}%"
|
show_progress brightness "$percent" "${percent}%"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
adjust_ddc_brightness "$action" "$step"
|
||||||
}
|
}
|
||||||
|
|
||||||
media_action() {
|
media_action() {
|
||||||
|
|||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Device security facts, as JSON.
|
||||||
|
#
|
||||||
|
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
|
||||||
|
# disk encryption, SELinux mode and the firewall are set in firmware, at install
|
||||||
|
# time, or by system policy -- none of them is a desktop preference, and a
|
||||||
|
# settings app that offered to toggle them would either fail or do something
|
||||||
|
# far-reaching from a switch that looks like any other.
|
||||||
|
#
|
||||||
|
# What it is for is answering "is this machine set up the way I think it is",
|
||||||
|
# which is the question GNOME's Device Security panel exists to answer and which
|
||||||
|
# otherwise needs five commands and root.
|
||||||
|
#
|
||||||
|
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
|
||||||
|
# the UI can highlight what deserves attention without hard-coding the meaning
|
||||||
|
# of each string. Anything that cannot be determined reports "Unknown" with
|
||||||
|
# ok:false rather than guessing, because a security readout that quietly reports
|
||||||
|
# "fine" when it failed to look is worse than no readout.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
fact() {
|
||||||
|
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
|
||||||
|
'{label: $label, value: $value, ok: $ok, detail: $detail}'
|
||||||
|
}
|
||||||
|
|
||||||
|
facts=()
|
||||||
|
|
||||||
|
# ── Secure Boot ──────────────────────────────────────────────────────────────
|
||||||
|
if command -v mokutil >/dev/null 2>&1; then
|
||||||
|
case "$(mokutil --sb-state 2>/dev/null)" in
|
||||||
|
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
|
||||||
|
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
|
||||||
|
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
|
||||||
|
esac
|
||||||
|
elif [[ -d /sys/firmware/efi ]]; then
|
||||||
|
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── TPM ──────────────────────────────────────────────────────────────────────
|
||||||
|
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
|
||||||
|
if [[ -n "$tpm_major" ]]; then
|
||||||
|
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
|
||||||
|
elif [[ -e /sys/class/tpm/tpm0 ]]; then
|
||||||
|
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Disk encryption ──────────────────────────────────────────────────────────
|
||||||
|
# Counts LUKS mappings rather than naming them: which volume is encrypted is
|
||||||
|
# more detail than this readout needs, and device names are not meaningful here.
|
||||||
|
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
|
||||||
|
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
|
||||||
|
if (( crypt_count > 0 )); then
|
||||||
|
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── SELinux ──────────────────────────────────────────────────────────────────
|
||||||
|
if command -v getenforce >/dev/null 2>&1; then
|
||||||
|
case "$(getenforce 2>/dev/null)" in
|
||||||
|
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
|
||||||
|
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
|
||||||
|
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
|
||||||
|
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Firewall ─────────────────────────────────────────────────────────────────
|
||||||
|
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
|
||||||
|
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
|
||||||
|
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Propagates the colour scheme to applications that do not read the desktop
|
||||||
|
# portal.
|
||||||
|
#
|
||||||
|
# Most modern applications DO follow org.freedesktop.portal.Settings and need
|
||||||
|
# nothing from us: GTK4/libadwaita, Qt6, Chromium and Electron all read
|
||||||
|
# org.freedesktop.appearance color-scheme, which xdg-desktop-portal-gtk serves
|
||||||
|
# from gsettings. Panama sets that, so those follow automatically.
|
||||||
|
#
|
||||||
|
# Terminals are the notable exception -- they predate the standard and carry
|
||||||
|
# their own palettes. kitty is handled here.
|
||||||
|
#
|
||||||
|
# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme
|
||||||
|
# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini
|
||||||
|
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
|
||||||
|
# scheme in light mode, so it is generated from a template here instead.
|
||||||
|
#
|
||||||
|
# panama-theme-apps dark|light
|
||||||
|
#
|
||||||
|
# kitty gets it twice: the generated include file so terminals opened later
|
||||||
|
# start correct, and a live `set-colors` over its control socket so terminals
|
||||||
|
# already open change now. Without the second, a scheme change appears to do
|
||||||
|
# nothing until you open a new window.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
scheme="${1:-dark}"
|
||||||
|
case "$scheme" in
|
||||||
|
dark|light) ;;
|
||||||
|
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── GTK ──────────────────────────────────────────────────────────────────────
|
||||||
|
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
|
||||||
|
# naming a theme that does not exist makes GTK fall back to its light default --
|
||||||
|
# which made dark mode silently produce light windows.
|
||||||
|
if [[ "$scheme" == "light" ]]; then
|
||||||
|
gtk_theme="adw-gtk3"
|
||||||
|
prefer_dark=0
|
||||||
|
else
|
||||||
|
gtk_theme="adw-gtk3-dark"
|
||||||
|
prefer_dark=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
status_gtk="skipped"
|
||||||
|
for gtk_version in 3.0 4.0; do
|
||||||
|
gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version"
|
||||||
|
template="$gtk_dir/settings.ini.template"
|
||||||
|
[[ -r "$template" ]] || continue
|
||||||
|
|
||||||
|
# Written atomically: a GTK application starting mid-write would otherwise
|
||||||
|
# read a truncated file and fall back to defaults.
|
||||||
|
if sed -e "s/@GTK_THEME@/$gtk_theme/" -e "s/@PREFER_DARK@/$prefer_dark/" "$template" \
|
||||||
|
>"$gtk_dir/settings.ini.tmp" 2>/dev/null \
|
||||||
|
&& mv "$gtk_dir/settings.ini.tmp" "$gtk_dir/settings.ini" 2>/dev/null; then
|
||||||
|
status_gtk="written"
|
||||||
|
else
|
||||||
|
rm -f "$gtk_dir/settings.ini.tmp"
|
||||||
|
status_gtk="failed"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
|
||||||
|
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||||
|
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
||||||
|
|
||||||
|
status_kitty="skipped"
|
||||||
|
if [[ -r "$theme_file" ]]; then
|
||||||
|
# Written atomically: kitty may read this while a new window is starting.
|
||||||
|
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
|
||||||
|
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
|
||||||
|
status_kitty="written"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Live-apply to running terminals. kitty appends its PID to the socket name
|
||||||
|
# from listen_on, so there is one socket per instance -- "unix:@mykitty"
|
||||||
|
# alone reaches nothing, which is exactly how this looked like remote
|
||||||
|
# control being disabled when it was not.
|
||||||
|
if command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
|
||||||
|
applied=0
|
||||||
|
while read -r socket; do
|
||||||
|
[[ -n "$socket" ]] || continue
|
||||||
|
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
|
||||||
|
applied=$((applied + 1))
|
||||||
|
fi
|
||||||
|
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
|
||||||
|
(( applied > 0 )) && status_kitty="applied to $applied"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty"
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Panel brightness for external monitors, over DDC/CI.
|
||||||
|
//
|
||||||
|
// brightnessctl covers laptop panels through the kernel's backlight class. A
|
||||||
|
// desktop driving a DisplayPort monitor has no such device, so it has no
|
||||||
|
// brightness control at all -- the only way to dim the screen is the buttons on
|
||||||
|
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
|
||||||
|
// over the same I2C lines that carry EDID.
|
||||||
|
//
|
||||||
|
// Two things shape everything here:
|
||||||
|
//
|
||||||
|
// Detection is slow. Probing every I2C bus takes on the order of a second,
|
||||||
|
// which is far too slow to sit in front of a settings page opening. It runs
|
||||||
|
// once, on demand, and afterwards each display is addressed by its bus number
|
||||||
|
// directly.
|
||||||
|
//
|
||||||
|
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
|
||||||
|
// emits values continuously; sending each one produces a queue the panel
|
||||||
|
// works through seconds after the user let go, and some monitors drop or
|
||||||
|
// garble writes that arrive too fast. So `value` updates immediately for the
|
||||||
|
// UI and the hardware write is debounced, with only the latest value sent.
|
||||||
|
//
|
||||||
|
// Displays are keyed by DRM connector name (DP-2) so they line up with what
|
||||||
|
// Hyprland, the Displays page, and the monitor list already call them.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
|
||||||
|
|
||||||
|
// [{ bus, connector, model, value }] where value is 0..100.
|
||||||
|
property var displays: []
|
||||||
|
property bool scanning: false
|
||||||
|
|
||||||
|
// Empty when everything is fine. Carries the helper's explanation
|
||||||
|
// otherwise -- most usefully the udev command that grants I2C access,
|
||||||
|
// which is the difference between "brightness is unavailable" and
|
||||||
|
// "brightness is one command away".
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
readonly property bool available: root.displays.length > 0
|
||||||
|
|
||||||
|
// True once a scan has completed, however it went. Lets the UI tell "not
|
||||||
|
// looked yet" apart from "looked and found nothing", which otherwise render
|
||||||
|
// identically and leave a permanently empty panel with no explanation.
|
||||||
|
property bool scanned: false
|
||||||
|
|
||||||
|
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
|
||||||
|
// one entry no matter how many values the slider emits.
|
||||||
|
property var pending: ({})
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (root.scanning)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
scan.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayFor(connector: string): var {
|
||||||
|
return root.displays.find(display => display.connector === connector) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets brightness for one display. The stored value moves at once so the
|
||||||
|
// slider tracks the pointer; the hardware follows when the drag settles.
|
||||||
|
function set(bus: int, percent: int): void {
|
||||||
|
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
||||||
|
|
||||||
|
root.displays = root.displays.map(display =>
|
||||||
|
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
|
||||||
|
|
||||||
|
const next = Object.assign({}, root.pending);
|
||||||
|
next[String(bus)] = clamped;
|
||||||
|
root.pending = next;
|
||||||
|
writeDebounce.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: scan
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.displays = [];
|
||||||
|
root.lastError = "Could not read the brightness helper's output.";
|
||||||
|
console.warn("Brightness: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanning = false;
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Long enough that a drag produces one write rather than dozens, short
|
||||||
|
// enough that a single click still feels immediate.
|
||||||
|
Timer {
|
||||||
|
id: writeDebounce
|
||||||
|
interval: 120
|
||||||
|
onTriggered: root.pump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes run one at a time, and each is read back.
|
||||||
|
//
|
||||||
|
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
|
||||||
|
// processes talking to the same monitor interleave their exchanges and both
|
||||||
|
// can come back with garbage. Read back because a write is not a promise --
|
||||||
|
// panels clamp to their own range, ignore values while waking from standby,
|
||||||
|
// and drop writes that arrive too quickly. Without the read the slider shows
|
||||||
|
// what Panama asked for rather than what the monitor did, which is the same
|
||||||
|
// class of lie as trusting `hyprctl keyword` to have applied something.
|
||||||
|
property int writingBus: -1
|
||||||
|
|
||||||
|
function pump(): void {
|
||||||
|
if (writer.running || reader.running)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (const bus in root.pending) {
|
||||||
|
const value = root.pending[bus];
|
||||||
|
const remaining = Object.assign({}, root.pending);
|
||||||
|
delete remaining[bus];
|
||||||
|
root.pending = remaining;
|
||||||
|
|
||||||
|
root.writingBus = parseInt(bus);
|
||||||
|
writer.command = [root.helperPath, "set", bus, String(value)];
|
||||||
|
writer.running = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: writer
|
||||||
|
onExited: {
|
||||||
|
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
||||||
|
reader.running = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: reader
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const actual = parseInt(this.text.trim());
|
||||||
|
if (!isNaN(actual)) {
|
||||||
|
root.displays = root.displays.map(display =>
|
||||||
|
display.bus === root.writingBus
|
||||||
|
? Object.assign({}, display, { value: actual })
|
||||||
|
: display);
|
||||||
|
}
|
||||||
|
root.writingBus = -1;
|
||||||
|
// Anything queued while this write was in flight goes now.
|
||||||
|
root.pump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import qs.config
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||||
|
|
||||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
@@ -76,9 +78,21 @@ Singleton {
|
|||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
|
|
||||||
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
||||||
// Adwaita's light and dark are the same theme; only the preference and
|
|
||||||
// the -dark suffix differ, so applications that honour either agree.
|
// adw-gtk3, not Adwaita. This is the bug that made dark mode look
|
||||||
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita";
|
// broken while light mode looked fine:
|
||||||
|
//
|
||||||
|
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
|
||||||
|
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
|
||||||
|
// not exist makes GTK fall back to its built-in default, which is
|
||||||
|
// LIGHT. So asking for light accidentally worked, asking for dark
|
||||||
|
// silently produced light, and applications that take their cue from
|
||||||
|
// the GTK theme rather than the portal -- Chromium and Electron, when
|
||||||
|
// built against GTK -- stayed light no matter what the portal said.
|
||||||
|
//
|
||||||
|
// gtk-theme-contract asserts these names are actually installed,
|
||||||
|
// because the failure mode is silent in exactly this way.
|
||||||
|
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
||||||
@@ -92,6 +106,12 @@ Singleton {
|
|||||||
commands.push(["hyprctl", "eval",
|
commands.push(["hyprctl", "eval",
|
||||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
||||||
|
|
||||||
|
// Applications that predate org.freedesktop.appearance and carry their
|
||||||
|
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||||
|
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
|
||||||
|
// write above and needs nothing here.
|
||||||
|
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
|
||||||
|
|
||||||
root.enqueue(commands);
|
root.enqueue(commands);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux,
|
||||||
|
// firewall.
|
||||||
|
//
|
||||||
|
// Nothing here is a preference. These are set in firmware, at install time, or
|
||||||
|
// by system policy, and a settings app that offered to change them from a
|
||||||
|
// switch would either fail or do something far-reaching from a control that
|
||||||
|
// looks like every other control. What this answers is "is this machine set up
|
||||||
|
// the way I think it is", which otherwise takes five commands and root.
|
||||||
|
//
|
||||||
|
// Read on demand. None of these can change while the desktop is running,
|
||||||
|
// short of a reboot.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security"
|
||||||
|
|
||||||
|
// [{ label, value, ok, detail }]
|
||||||
|
property var facts: []
|
||||||
|
property bool scanned: false
|
||||||
|
|
||||||
|
// The facts that are not in their reassuring state. The page leads with the
|
||||||
|
// count so a machine that is entirely fine says so in one line instead of
|
||||||
|
// making the user read five rows to find out.
|
||||||
|
readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.facts = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.facts = [];
|
||||||
|
console.warn("DeviceSecurity: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// What input hardware this machine actually has.
|
||||||
|
//
|
||||||
|
// Exists so pages can hide controls for hardware that is not present. A
|
||||||
|
// touchpad card on a desktop is not merely useless -- it is misleading, because
|
||||||
|
// every switch on it appears to work: the preference is stored and Hyprland
|
||||||
|
// accepts the option for a device class it has no member of. The user is left
|
||||||
|
// toggling settings that will never affect anything, with nothing to say so.
|
||||||
|
//
|
||||||
|
// Touchpads are identified by name. libinput exposes them through the same
|
||||||
|
// "mice" list as everything else that reports pointer motion, and Hyprland
|
||||||
|
// passes the device name through, so an Elan or Synaptics touchpad arrives as
|
||||||
|
// something like "elan-touchpad". There is no device-class field to consult.
|
||||||
|
//
|
||||||
|
// Read on demand rather than polled. Input devices do come and go -- a mouse is
|
||||||
|
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
|
||||||
|
// the device list changed, which is the only moment the answer can differ.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var mice: []
|
||||||
|
property var keyboards: []
|
||||||
|
|
||||||
|
// True when anything that looks like a touchpad is attached.
|
||||||
|
readonly property bool hasTouchpad: root.mice.some(name =>
|
||||||
|
name.includes("touchpad") || name.includes("trackpad"))
|
||||||
|
|
||||||
|
readonly property bool hasMouse: root.mice.length > 0
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
running: true
|
||||||
|
command: ["hyprctl", "-j", "devices"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
|
||||||
|
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("InputDevices: could not parse hyprctl devices:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: Hyprland
|
||||||
|
function onRawEvent(event: var): void {
|
||||||
|
if (event.name === "device" || event.name === "configreloaded")
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The login keyring's lock state.
|
||||||
|
//
|
||||||
|
// The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is
|
||||||
|
// under GNOME. What a bare Hyprland session lacks is anywhere to see when that
|
||||||
|
// has stopped being true.
|
||||||
|
//
|
||||||
|
// It stops being true rarely but expensively: gnome-keyring-daemon can crash,
|
||||||
|
// D-Bus activates a replacement, and the replacement never received the login
|
||||||
|
// password -- so the keyring is locked in the middle of a session that unlocked
|
||||||
|
// it correctly. Nothing announces this. What the user sees instead is a mail
|
||||||
|
// account that will not authenticate, a git push that cannot find its key, or
|
||||||
|
// an integration reporting "not configured", none of which mention keyrings.
|
||||||
|
//
|
||||||
|
// Checked on demand and after an unlock, not polled: the state changes only
|
||||||
|
// when a daemon dies or a password is entered.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring"
|
||||||
|
|
||||||
|
property bool available: false
|
||||||
|
property bool locked: false
|
||||||
|
property bool scanned: false
|
||||||
|
property bool unlocking: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// "pam" when the daemon that holds the keyring is the one PAM started at
|
||||||
|
// login, "dbus" when it is a D-Bus-activated replacement -- which is the
|
||||||
|
// signature of the crash case, and worth showing, because a dbus daemon
|
||||||
|
// that is currently unlocked was unlocked by hand and will not survive.
|
||||||
|
property string daemon: ""
|
||||||
|
|
||||||
|
readonly property bool replacementDaemon: root.daemon === "dbus"
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raises the standard password dialog. The password never passes through
|
||||||
|
// Panama -- the Secret Service prompts, the same way it does under GNOME.
|
||||||
|
function unlock(): void {
|
||||||
|
if (root.unlocking)
|
||||||
|
return;
|
||||||
|
root.unlocking = true;
|
||||||
|
unlockProcess.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absorb(text: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
root.available = parsed.available === true;
|
||||||
|
root.locked = parsed.locked === true;
|
||||||
|
root.daemon = String(parsed.daemon ?? "");
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.available = false;
|
||||||
|
root.lastError = "Could not read the keyring helper's output.";
|
||||||
|
console.warn("Keyring: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath, "status"]
|
||||||
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: unlockProcess
|
||||||
|
command: [root.helperPath, "unlock"]
|
||||||
|
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
|
||||||
|
onExited: root.unlocking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// What this machine is: model, processor, memory, disk, OS, kernel.
|
||||||
|
//
|
||||||
|
// Read once, on demand. None of it changes while the desktop is running except
|
||||||
|
// free disk space, and About is not a monitor -- the vitals readout on the Home
|
||||||
|
// page is where live figures belong.
|
||||||
|
//
|
||||||
|
// Graphics is not here. GraphicsDevices already enumerates GPUs for the vitals
|
||||||
|
// readout, and naming them again would be a second source of truth that could
|
||||||
|
// disagree with the first; the About page joins the two instead.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-about"
|
||||||
|
|
||||||
|
// [{ label, value }] in display order.
|
||||||
|
property var facts: []
|
||||||
|
property bool scanned: false
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.facts = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.facts = [];
|
||||||
|
console.warn("MachineInfo: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Online accounts, via GNOME Online Accounts.
|
||||||
|
//
|
||||||
|
// The daemon already runs in this session -- gvfs activates it, and accounts
|
||||||
|
// work without gnome-shell anywhere. Only the panel was GNOME's; the accounts
|
||||||
|
// are D-Bus objects anything may read and modify. So listing, per-service
|
||||||
|
// toggles, and removal all happen here, natively.
|
||||||
|
//
|
||||||
|
// Signing in is the exception, and only for OAuth providers. The daemon's
|
||||||
|
// AddAccount takes credentials as an argument rather than obtaining them, and
|
||||||
|
// the code that runs Google's OAuth exchange lives in libgoa-backend, which
|
||||||
|
// Fedora ships without a GIR binding. So that one step is handed to GNOME's
|
||||||
|
// panel and the user comes straight back here.
|
||||||
|
//
|
||||||
|
// Read on demand and after every change: accounts are added and removed by
|
||||||
|
// people, not by the system, so there is nothing to poll for.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-accounts"
|
||||||
|
|
||||||
|
// [{ path, provider, providerName, identity, needsAttention, services: [{key,label,enabled}] }]
|
||||||
|
property var accounts: []
|
||||||
|
property bool scanned: false
|
||||||
|
property bool busy: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// Accounts whose stored credentials have stopped working -- an expired
|
||||||
|
// token, a changed password. GOA knows, and nothing outside its own panel
|
||||||
|
// ever says so, which is how an account quietly stops syncing for weeks.
|
||||||
|
readonly property int attentionCount: root.accounts.filter(a => a.needsAttention).length
|
||||||
|
|
||||||
|
readonly property bool available: root.lastError === ""
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!list.running)
|
||||||
|
list.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabling a service clears GOA's "disabled" flag; the helper owns that
|
||||||
|
// inversion so the UI can speak in terms of what is on.
|
||||||
|
function setService(path: string, service: string, enabled: bool): void {
|
||||||
|
if (root.busy)
|
||||||
|
return;
|
||||||
|
root.busy = true;
|
||||||
|
write.command = [root.helperPath, "set", path, service, enabled ? "true" : "false"];
|
||||||
|
write.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(path: string): void {
|
||||||
|
if (root.busy)
|
||||||
|
return;
|
||||||
|
root.busy = true;
|
||||||
|
write.command = [root.helperPath, "remove", path];
|
||||||
|
write.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: list
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
|
||||||
|
root.lastError = String(parsed.error ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
root.accounts = [];
|
||||||
|
root.lastError = "Could not read the accounts helper's output.";
|
||||||
|
console.warn("OnlineAccounts: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: write
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
|
||||||
|
}
|
||||||
|
// Re-read rather than assuming the write landed: GOA may refuse, and a
|
||||||
|
// toggle that sprang back is the honest outcome.
|
||||||
|
onExited: {
|
||||||
|
root.busy = false;
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ Singleton {
|
|||||||
"idle": "power",
|
"idle": "power",
|
||||||
"accessibility": "accessibility",
|
"accessibility": "accessibility",
|
||||||
"input": "shortcuts",
|
"input": "shortcuts",
|
||||||
|
"pointer": "mouse",
|
||||||
|
"touchpad": "mouse",
|
||||||
|
"multitasking": "desktop",
|
||||||
"weather": "appearance",
|
"weather": "appearance",
|
||||||
"notifications": "notifications",
|
"notifications": "notifications",
|
||||||
"capture": "screen-intelligence"
|
"capture": "screen-intelligence"
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
function openSettings(page: string): void {
|
||||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"];
|
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "services", "about"];
|
||||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The system locale.
|
||||||
|
//
|
||||||
|
// Named SystemLocale, not Locale: QML has a built-in Locale value type, and a
|
||||||
|
// singleton of that name is silently shadowed by it. Every binding then reads
|
||||||
|
// properties off the wrong thing and the page renders empty with only
|
||||||
|
// "Cannot read property of undefined" to show for it.
|
||||||
|
//
|
||||||
|
// Changing it is privileged: localectl goes through polkit, which prompts
|
||||||
|
// (hyprpolkitagent serves that in this session). It also only applies to
|
||||||
|
// programs started afterwards, so `pendingRestart` goes true once a change is
|
||||||
|
// accepted and the page says a sign-out is needed. Reporting the new locale as
|
||||||
|
// simply "in effect" would be wrong -- almost nothing on screen would be using
|
||||||
|
// it yet.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale"
|
||||||
|
|
||||||
|
// [{ value, label, detail }]
|
||||||
|
property var locales: []
|
||||||
|
property string current: ""
|
||||||
|
property bool scanning: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// True once a change has been accepted but the session has not restarted,
|
||||||
|
// so the UI can stop claiming the new locale is already in use.
|
||||||
|
property bool pendingRestart: false
|
||||||
|
|
||||||
|
readonly property string currentLabel: {
|
||||||
|
const match = root.locales.find(locale => locale.value === root.current);
|
||||||
|
return match ? match.label : root.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (root.scanning)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
readCurrent.running = true;
|
||||||
|
list.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(value: string): void {
|
||||||
|
if (value === root.current)
|
||||||
|
return;
|
||||||
|
apply.command = [root.helperPath, "set", value];
|
||||||
|
apply.pendingValue = value;
|
||||||
|
apply.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: list
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.locales = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.locales = [];
|
||||||
|
console.warn("SystemLocale: could not parse the locale list:", error);
|
||||||
|
}
|
||||||
|
root.scanning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: readCurrent
|
||||||
|
command: [root.helperPath, "get"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.current = this.text.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: apply
|
||||||
|
|
||||||
|
property string pendingValue: ""
|
||||||
|
|
||||||
|
// A refused change -- polkit dismissed, or an unknown locale -- must not
|
||||||
|
// move the UI. The value is only adopted on a zero exit.
|
||||||
|
onExited: code => {
|
||||||
|
if (code === 0) {
|
||||||
|
root.current = apply.pendingValue;
|
||||||
|
root.pendingRestart = true;
|
||||||
|
root.lastError = "";
|
||||||
|
} else {
|
||||||
|
root.lastError = "The system did not accept that language. It may have needed a password.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -497,18 +497,35 @@ Singleton {
|
|||||||
].indexOf(panel) >= 0;
|
].indexOf(panel) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openGnomePanel(panel: string): bool {
|
// `subpage` reaches the panels GNOME 50 nests under System -- users,
|
||||||
|
// about, datetime, region -- which its own desktop entries open as
|
||||||
|
// `gnome-control-center system users`. Without it, a row labelled "Users"
|
||||||
|
// lands on System's front page and leaves the user to navigate, which is
|
||||||
|
// most of the way to a broken button.
|
||||||
|
function openGnomePanel(panel: string, subpage: string): bool {
|
||||||
if (!root.isGnomePanelAllowed(panel)) {
|
if (!root.isGnomePanelAllowed(panel)) {
|
||||||
root.lastError = "That GNOME Settings panel is not available.";
|
root.lastError = "That GNOME Settings panel is not available.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const command = ["gnome-control-center", panel];
|
||||||
|
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
|
||||||
|
command.push(subpage);
|
||||||
Quickshell.execDetached({
|
Quickshell.execDetached({
|
||||||
command: ["gnome-control-center", panel],
|
command: command,
|
||||||
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only System nests panels, and only these. Read off the Exec lines of the
|
||||||
|
// gnome-*-panel desktop entries rather than guessed, for the same reason
|
||||||
|
// the panel list above was.
|
||||||
|
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
|
||||||
|
if (panel !== "system")
|
||||||
|
return false;
|
||||||
|
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function openApplication(id: string): bool {
|
function openApplication(id: string): bool {
|
||||||
const commands = {
|
const commands = {
|
||||||
"nextcloud": ["nextcloud"],
|
"nextcloud": ["nextcloud"],
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Tokyo Night Day — the light counterpart to tokyonight-moon.toml.
|
||||||
|
#
|
||||||
|
# Same schema and the same role for every colour; only the palette differs, so
|
||||||
|
# the launcher keeps its identity when the desktop switches to light rather
|
||||||
|
# than becoming a different-looking application.
|
||||||
|
#
|
||||||
|
# Colours are the Tokyo Night Day palette, taken from kitty/themes/tokyonight-day.conf
|
||||||
|
# so the launcher and the terminal cannot drift apart.
|
||||||
|
|
||||||
|
[meta]
|
||||||
|
version = 1
|
||||||
|
name = "Tokyo Night Day"
|
||||||
|
description = "Tokyo Night Day, matched to the Panama Hyprland desktop."
|
||||||
|
variant = "light"
|
||||||
|
|
||||||
|
[colors.core]
|
||||||
|
background = "#e1e2e7" # bg
|
||||||
|
foreground = "#3760bf" # fg
|
||||||
|
secondary_background = "#d0d5e3" # bg_dark, a shade below the ground
|
||||||
|
border = "#a8aecb" # a visible hairline on a light ground
|
||||||
|
accent = "#2e7de9" # blue
|
||||||
|
accent_foreground = "#e1e2e7"
|
||||||
|
|
||||||
|
[colors.accents]
|
||||||
|
blue = "#2e7de9"
|
||||||
|
green = "#587539"
|
||||||
|
magenta = "#9854f1"
|
||||||
|
orange = "#b15c00"
|
||||||
|
purple = "#7847bd"
|
||||||
|
red = "#f52a65"
|
||||||
|
yellow = "#8c6c3e"
|
||||||
|
cyan = "#007197"
|
||||||
|
|
||||||
|
[colors.main_window]
|
||||||
|
border = "#a8aecb"
|
||||||
|
footer = { background = "colors.core.secondary_background" }
|
||||||
|
|
||||||
|
[colors.settings_window]
|
||||||
|
border = "#c4c8da"
|
||||||
|
|
||||||
|
[colors.shortcut]
|
||||||
|
border = "colors.core.border"
|
||||||
|
|
||||||
|
[colors.text]
|
||||||
|
default = "colors.core.foreground"
|
||||||
|
muted = "#6172b0" # fg_dark, 4.0:1 on the background
|
||||||
|
danger = "#f52a65"
|
||||||
|
success = "#587539"
|
||||||
|
placeholder = "#7079a8" # 3.25:1; Day's own #848cb5 is 2.54:1, too faint to read
|
||||||
|
selection = { background = "#2e7de9", foreground = "#e1e2e7" }
|
||||||
|
|
||||||
|
[colors.text.links]
|
||||||
|
default = "#2e7de9"
|
||||||
|
visited = "#9854f1"
|
||||||
|
|
||||||
|
[colors.input]
|
||||||
|
border = "#c4c8da"
|
||||||
|
border_focus = "#2e7de9"
|
||||||
|
border_error = "#f52a65"
|
||||||
|
|
||||||
|
[colors.button.primary]
|
||||||
|
background = "#d4d6e4" # bg_highlight
|
||||||
|
foreground = "#3760bf"
|
||||||
|
hover = { background = "#c4c8da" }
|
||||||
|
focus = { outline = "colors.core.accent" }
|
||||||
|
|
||||||
|
[colors.list.item.hover]
|
||||||
|
foreground = "#3760bf"
|
||||||
|
secondary_foreground = "#6172b0"
|
||||||
|
|
||||||
|
[colors.list.item.selection]
|
||||||
|
background = "#c3cdf0" # blue side of the Prism selection, lightened
|
||||||
|
foreground = "#3760bf"
|
||||||
|
secondary_background = "#d8c8e4" # orchid side of the Prism selection, lightened
|
||||||
|
secondary_foreground = "#3760bf"
|
||||||
|
|
||||||
|
[colors.grid.item]
|
||||||
|
background = "#d7d9e5"
|
||||||
|
hover = { outline = "#2e7de9" }
|
||||||
|
selection = { outline = "#9854f1" }
|
||||||
|
|
||||||
|
[colors.scrollbars]
|
||||||
|
background = "#c4c8da"
|
||||||
|
|
||||||
|
[colors.loading]
|
||||||
|
bar = "#2e7de9"
|
||||||
|
spinner = "#3760bf"
|
||||||
@@ -6,12 +6,14 @@
|
|||||||
// and emoji-copy extensions, so its clipboard and emoji views are bound
|
// and emoji-copy extensions, so its clipboard and emoji views are bound
|
||||||
// directly in hypr/keybinds.lua.
|
// directly in hypr/keybinds.lua.
|
||||||
|
|
||||||
// Vicinae 0.26 selects a theme per system appearance. Both point to Moon so
|
// Vicinae 0.26 selects a theme per system appearance, and now there is a
|
||||||
// the launcher never flashes or falls back to its stock palette while the
|
// theme for each: the launcher follows Panama's light/dark setting instead of
|
||||||
// desktop appearance is being initialized.
|
// staying dark on a light desktop. Both are authored here rather than using
|
||||||
|
// Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon
|
||||||
|
// and Day variants the rest of this desktop uses.
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": {
|
"light": {
|
||||||
"name": "tokyonight-moon",
|
"name": "tokyonight-day",
|
||||||
"icon_theme": "auto"
|
"icon_theme": "auto"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Panama Settings
|
Name=Settings
|
||||||
GenericName=System Settings
|
GenericName=System Settings
|
||||||
Comment=Configure the Panama Hyprland desktop
|
Comment=Configure this desktop
|
||||||
Exec=qs ipc call settings open
|
Exec=qs ipc call settings open
|
||||||
Icon=panama-settings
|
Icon=panama-settings
|
||||||
Terminal=false
|
Terminal=false
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
Categories=Settings;
|
Categories=Settings;
|
||||||
Keywords=Panama;Hyprland;Display;Dock;Notifications;Shortcuts;Services;
|
Keywords=Settings;Preferences;Configuration;Display;Sound;Network;Keyboard;Mouse;Privacy;Power;Shortcuts;
|
||||||
|
|||||||
+66
-10
@@ -70,23 +70,79 @@ for dir in "${dirs[@]}"; do
|
|||||||
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
|
||||||
|
# template rather than layered. Without this, a fresh checkout has a template
|
||||||
|
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
|
||||||
|
# panama-theme-apps rewrites both files on every scheme change after this.
|
||||||
|
for gtk_version in 3.0 4.0; do
|
||||||
|
gtk_template="$PANAMA_DOT/gtk-$gtk_version/settings.ini.template"
|
||||||
|
gtk_settings="$PANAMA_DOT/gtk-$gtk_version/settings.ini"
|
||||||
|
[ -r "$gtk_template" ] || continue
|
||||||
|
if [ -e "$gtk_settings" ]; then
|
||||||
|
log "Keeping existing GTK settings at $gtk_settings"
|
||||||
|
else
|
||||||
|
gtk_scheme="dark"
|
||||||
|
gtk_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||||
|
if [ -r "$gtk_prefs" ]; then
|
||||||
|
gtk_stored="$(jq -r '.colorScheme // "dark"' "$gtk_prefs" 2>/dev/null || echo dark)"
|
||||||
|
[ "$gtk_stored" = "light" ] && gtk_scheme="light"
|
||||||
|
fi
|
||||||
|
if [ "$gtk_scheme" = "light" ]; then
|
||||||
|
gtk_name="adw-gtk3"; gtk_dark=0
|
||||||
|
else
|
||||||
|
gtk_name="adw-gtk3-dark"; gtk_dark=1
|
||||||
|
fi
|
||||||
|
sed -e "s/@GTK_THEME@/$gtk_name/" -e "s/@PREFER_DARK@/$gtk_dark/" \
|
||||||
|
"$gtk_template" > "$gtk_settings"
|
||||||
|
log "Generated GTK $gtk_version settings ($gtk_name) → $gtk_settings"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# kitty.conf ends with `include current-theme.conf`, and that file is generated
|
||||||
|
# from the desktop colour scheme rather than committed -- it is machine state.
|
||||||
|
# A fresh checkout therefore has no such file, and kitty starts by complaining
|
||||||
|
# about a missing include and falling back to its stock colours. Seed it from
|
||||||
|
# the scheme in settings.json (dark unless the user has chosen otherwise) so a
|
||||||
|
# first launch is themed; ColorScheme.qml overwrites it on every change after.
|
||||||
|
KITTY_THEME="$PANAMA_DOT/kitty/current-theme.conf"
|
||||||
|
if [ -e "$KITTY_THEME" ]; then
|
||||||
|
log "Keeping existing kitty theme at $KITTY_THEME"
|
||||||
|
else
|
||||||
|
scheme="dark"
|
||||||
|
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||||
|
if [ -r "$settings" ]; then
|
||||||
|
stored="$(jq -r '.colorScheme // "dark"' "$settings" 2>/dev/null || echo dark)"
|
||||||
|
[ "$stored" = "light" ] && scheme="light"
|
||||||
|
fi
|
||||||
|
[ "$scheme" = "light" ] && theme="tokyonight-day" || theme="tokyonight-moon"
|
||||||
|
cp "$PANAMA_DOT/kitty/themes/$theme.conf" "$KITTY_THEME"
|
||||||
|
log "Seeded kitty $scheme theme ($theme) → $KITTY_THEME"
|
||||||
|
fi
|
||||||
|
|
||||||
# Vicinae 0.26 discovers user themes from its XDG data directory rather than
|
# Vicinae 0.26 discovers user themes from its XDG data directory rather than
|
||||||
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
|
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
|
||||||
# the launcher config and expose only that file at Vicinae's runtime path.
|
# the launcher config and expose only that file at Vicinae's runtime path.
|
||||||
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
|
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
|
||||||
VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml"
|
|
||||||
mkdir -p "$VICINAE_THEME_DIR"
|
mkdir -p "$VICINAE_THEME_DIR"
|
||||||
|
|
||||||
if [ -L "$VICINAE_THEME" ]; then
|
# Every authored theme, not just the dark one: vicinae.json selects a theme per
|
||||||
rm "$VICINAE_THEME"
|
# system appearance, so linking only Moon left the launcher falling back to its
|
||||||
fi
|
# stock palette whenever Panama was in light mode.
|
||||||
|
for theme_src in "$PANAMA_DOT"/vicinae/themes/*.toml; do
|
||||||
|
[ -e "$theme_src" ] || continue
|
||||||
|
theme_dst="$VICINAE_THEME_DIR/$(basename "$theme_src")"
|
||||||
|
|
||||||
if [ -e "$VICINAE_THEME" ]; then
|
if [ -L "$theme_dst" ]; then
|
||||||
log "Keeping existing Vicinae theme at $VICINAE_THEME"
|
rm "$theme_dst"
|
||||||
else
|
fi
|
||||||
ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME"
|
|
||||||
log "Linked Tokyo Night Moon theme → $VICINAE_THEME"
|
if [ -e "$theme_dst" ]; then
|
||||||
fi
|
log "Keeping existing Vicinae theme at $theme_dst"
|
||||||
|
else
|
||||||
|
ln -s "$theme_src" "$theme_dst"
|
||||||
|
log "Linked Vicinae theme → $theme_dst"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Panama-native applications live in the user data directory so launchers can
|
# Panama-native applications live in the user data directory so launchers can
|
||||||
# discover them alongside system desktop entries. Keep each authored file in
|
# discover them alongside system desktop entries. Keep each authored file in
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# panama-brightness enumerates monitors from sysfs and speaks DDC/CI to them.
|
||||||
|
#
|
||||||
|
# The parts worth pinning down are the ones that decide whether a slider appears
|
||||||
|
# at all, and whether it appears attached to the right screen:
|
||||||
|
#
|
||||||
|
# * only connectors with something plugged in are probed, because probing an
|
||||||
|
# empty bus costs a timeout each and there are fourteen of them here;
|
||||||
|
# * a panel that cannot report brightness is omitted rather than shown as a
|
||||||
|
# control that does nothing;
|
||||||
|
# * the connector name matches what Hyprland calls the output, since the UI
|
||||||
|
# joins on it to get the monitor's description;
|
||||||
|
# * no I2C access produces the udev command that fixes it, not "no displays".
|
||||||
|
#
|
||||||
|
# Runs entirely against fixtures. Real monitors are never touched: both the
|
||||||
|
# sysfs root and the device root are redirected, and ddcutil is replaced on PATH.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-brightness"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'brightness helper contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture="$(mktemp -d /tmp/panama-brightness.XXXXXX)"
|
||||||
|
trap 'rm -rf "$fixture"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
|
||||||
|
|
||||||
|
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
|
||||||
|
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
|
||||||
|
# must never be probed at all.
|
||||||
|
# A connector has an EDID bus (the `ddc` symlink) and, on DisplayPort, an AUX
|
||||||
|
# bus that appears as a child directory. DDC/CI rides the AUX channel where one
|
||||||
|
# exists, and the `ddc` line answers nothing on a DP connector even though it
|
||||||
|
# still resolves -- so the aux argument here is what a real DisplayPort monitor
|
||||||
|
# looks like, and omitting it is what HDMI and DVI look like.
|
||||||
|
make_connector() {
|
||||||
|
local name="$1" ddc_bus="$2" status="$3" aux_bus="${4:-}"
|
||||||
|
local device="$fixture/devices/$name"
|
||||||
|
|
||||||
|
# /sys/class/drm/<connector> is a SYMLINK to the real device directory, and
|
||||||
|
# this fixture mirrors that rather than using a plain directory. It matters:
|
||||||
|
# `find` does not follow the path it is given, so code that searches the
|
||||||
|
# unresolved path finds nothing while appearing to work anywhere the entry
|
||||||
|
# happens to be a real directory.
|
||||||
|
mkdir -p "$device"
|
||||||
|
printf '%s\n' "$status" >"$device/status"
|
||||||
|
mkdir -p "$fixture/i2c/i2c-$ddc_bus"
|
||||||
|
ln -sfn "$fixture/i2c/i2c-$ddc_bus" "$device/ddc"
|
||||||
|
[[ -n "$aux_bus" ]] && mkdir -p "$device/i2c-$aux_bus"
|
||||||
|
|
||||||
|
mkdir -p "$fixture/drm"
|
||||||
|
ln -sfn "$device" "$fixture/drm/$name"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
# DP-2 is the DisplayPort case: its EDID line is bus 5, which answers nothing,
|
||||||
|
# and its AUX child is bus 9, which does. Choosing bus 5 here finds no monitor
|
||||||
|
# at all, which is exactly the bug this pins down.
|
||||||
|
make_connector card1-DP-1 4 disconnected
|
||||||
|
make_connector card1-DP-2 5 connected 9
|
||||||
|
make_connector card1-DP-3 6 connected
|
||||||
|
make_connector card1-HDMI-A-1 7 disconnected
|
||||||
|
|
||||||
|
# has_accessible_bus only needs one readable/writable node to exist.
|
||||||
|
touch "$fixture/dev/i2c-5"
|
||||||
|
|
||||||
|
# Stub ddcutil. Records every bus it is asked about so the test can prove the
|
||||||
|
# disconnected ones were skipped. Bus 6 refuses, standing in for a panel without
|
||||||
|
# VCP 0x10.
|
||||||
|
#
|
||||||
|
# Bus 5 reports its brightness out of 200 rather than 100. Most panels do use
|
||||||
|
# 100, which is exactly the problem: with a maximum of 100 the scaling
|
||||||
|
# arithmetic is the identity, so a helper that ignored the reported maximum
|
||||||
|
# entirely would pass every assertion. 200 makes reads and writes that skip the
|
||||||
|
# conversion visibly wrong.
|
||||||
|
cat >"$fixture/bin/ddcutil" <<'STUB'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
bus=""
|
||||||
|
args=("$@")
|
||||||
|
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||||
|
[[ "${args[$i]}" == "--bus" ]] && bus="${args[$((i + 1))]}"
|
||||||
|
done
|
||||||
|
printf '%s\n' "$bus" >>"$DDCUTIL_PROBE_LOG"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [[ "$arg" == "setvcp" ]]; then
|
||||||
|
printf 'set %s %s\n' "$bus" "${args[-1]}" >>"$DDCUTIL_SET_LOG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$bus" in
|
||||||
|
9) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
STUB
|
||||||
|
chmod +x "$fixture/bin/ddcutil"
|
||||||
|
|
||||||
|
export DDCUTIL_PROBE_LOG="$fixture/probes.log"
|
||||||
|
export DDCUTIL_SET_LOG="$fixture/sets.log"
|
||||||
|
: >"$DDCUTIL_PROBE_LOG"
|
||||||
|
: >"$DDCUTIL_SET_LOG"
|
||||||
|
|
||||||
|
run_helper() {
|
||||||
|
PATH="$fixture/bin:$PATH" \
|
||||||
|
PANAMA_BRIGHTNESS_DRM_ROOT="$fixture/drm" \
|
||||||
|
PANAMA_BRIGHTNESS_DEV_ROOT="$fixture/dev" \
|
||||||
|
"$helper" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Enumeration ──────────────────────────────────────────────────────────────
|
||||||
|
listing="$(run_helper list)"
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
|
||||||
|
|
||||||
|
[[ "$(jq -r '.displays | length' <<<"$listing")" == "1" ]] \
|
||||||
|
|| fail "expected exactly one controllable display, got: $listing"
|
||||||
|
|
||||||
|
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|
||||||
|
|| fail "the connector name must match Hyprland's output name: $listing"
|
||||||
|
|
||||||
|
# The AUX bus, not the EDID bus its `ddc` symlink points at.
|
||||||
|
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "9" ]] \
|
||||||
|
|| fail "the display was mapped to its EDID bus instead of its DisplayPort AUX bus, where nothing answers: $listing"
|
||||||
|
|
||||||
|
# 120 of a maximum of 200 is 60%.
|
||||||
|
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|
||||||
|
|| fail "brightness was not read as a percent of the reported maximum: $listing"
|
||||||
|
|
||||||
|
[[ "$(jq -r '.error' <<<"$listing")" == "" ]] \
|
||||||
|
|| fail "a successful listing must not carry an error: $listing"
|
||||||
|
|
||||||
|
# A connected panel that cannot report brightness is dropped, not listed.
|
||||||
|
jq -e '.displays | map(.connector) | index("DP-3") == null' >/dev/null <<<"$listing" \
|
||||||
|
|| fail 'a display without VCP 0x10 was listed as controllable'
|
||||||
|
|
||||||
|
# ── Disconnected connectors are never probed ─────────────────────────────────
|
||||||
|
if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
|
||||||
|
fail "a disconnected connector was probed -- each empty bus costs a timeout: $(tr '\n' ' ' <"$DDCUTIL_PROBE_LOG")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Writes scale to the reported maximum ─────────────────────────────────────
|
||||||
|
run_helper set 9 40
|
||||||
|
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 80" ]] \
|
||||||
|
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
|
||||||
|
|
||||||
|
run_helper set 9 500
|
||||||
|
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 9 200" ]] \
|
||||||
|
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
|
||||||
|
|
||||||
|
# ── No I2C access explains itself ────────────────────────────────────────────
|
||||||
|
rm -f "$fixture/dev"/i2c-*
|
||||||
|
denied="$(run_helper list)"
|
||||||
|
[[ "$(jq -r '.displays | length' <<<"$denied")" == "0" ]] \
|
||||||
|
|| fail "displays were reported without I2C access: $denied"
|
||||||
|
grep -q 'udevadm' <<<"$(jq -r '.error' <<<"$denied")" \
|
||||||
|
|| fail "the no-access error must name the command that fixes it, got: $(jq -r '.error' <<<"$denied")"
|
||||||
|
|
||||||
|
printf 'brightness helper contract: PASS\n'
|
||||||
@@ -113,6 +113,25 @@ config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
|||||||
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||||
status() { run ipc call displays-test status; }
|
status() { run ipc call displays-test status; }
|
||||||
|
|
||||||
|
# The shipped geometry, read from the Hyprland config rather than from the
|
||||||
|
# running compositor.
|
||||||
|
#
|
||||||
|
# Everything below captures "original" from what it observes at start, which is
|
||||||
|
# correct only if the display is already in a good state. A previous run that
|
||||||
|
# failed mid-revert leaves the display changed, and the next run then captures
|
||||||
|
# THAT as the original and faithfully restores the desktop to a broken value.
|
||||||
|
# One flake becomes permanent. So refuse to run from a state that does not match
|
||||||
|
# what the config says, rather than laundering it.
|
||||||
|
shipped_scale="$(sed -n 's/^local shipped_scale *= *\([0-9.]*\).*/\1/p' \
|
||||||
|
"$repo_dir/config/dot/hypr/monitors.lua" | head -1)"
|
||||||
|
[[ -n "$shipped_scale" ]] || fail 'could not read the shipped scale from monitors.lua -- the guard below depends on it, and skipping it silently is how a dirty baseline gets laundered'
|
||||||
|
if [[ -n "$shipped_scale" ]]; then
|
||||||
|
live_scale="$(hyprctl -j monitors | jq -r '.[0].scale')"
|
||||||
|
if ! awk -v a="$live_scale" -v b="$shipped_scale" 'BEGIN { exit !(a == b) }'; then
|
||||||
|
fail "the display is at scale $live_scale but the config ships $shipped_scale -- refusing to capture a dirty state as the baseline. Restore it first: hyprctl eval 'hl.monitor({ output = \"DP-2\", mode = \"4500x3000@60\", scale = $shipped_scale, transform = 0 })'"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
original_mode=""
|
original_mode=""
|
||||||
original_scale=""
|
original_scale=""
|
||||||
original_transform=""
|
original_transform=""
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ cleanup() {
|
|||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
qs ipc call focus end >/dev/null 2>&1 || true
|
qs ipc call focus end >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# focus start records the caffeine state it found so end() can hand it back, and
|
||||||
|
# the assertion below requires caffeine to finish off. Both are only true if
|
||||||
|
# caffeine starts off.
|
||||||
|
#
|
||||||
|
# This test kills and restarts the live shell, so an interrupted run leaves
|
||||||
|
# caffeine on with nothing to turn it back off. The next run then records
|
||||||
|
# "previously on", faithfully restores it, and fails -- and every run after
|
||||||
|
# that fails identically, with the desktop quietly unable to idle or lock the
|
||||||
|
# whole time. Refuse to start from that state rather than latching it in.
|
||||||
|
if [[ "$(qs ipc call caffeine status 2>/dev/null)" != "false" ]]; then
|
||||||
|
fail 'caffeine is on before the session starts -- this test cannot distinguish "restored correctly" from "never released". Turn it off first: qs ipc call caffeine toggle'
|
||||||
|
fi
|
||||||
|
|
||||||
qs ipc call focus start >/dev/null
|
qs ipc call focus start >/dev/null
|
||||||
|
|
||||||
state_file=$(find "${XDG_STATE_HOME:-$HOME/.local/state}/quickshell/by-shell" -name focus-session.json -print -quit)
|
state_file=$(find "${XDG_STATE_HOME:-$HOME/.local/state}/quickshell/by-shell" -name focus-session.json -print -quit)
|
||||||
|
|||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Every GTK theme name Panama sets must be a theme that is actually installed.
|
||||||
|
#
|
||||||
|
# This exists because of a bug that was invisible for weeks. ColorScheme set
|
||||||
|
# gtk-theme to "Adwaita-dark" for dark and "Adwaita" for light. Neither is
|
||||||
|
# installed on Fedora 44 -- only adw-gtk3 and adw-gtk3-dark are -- and GTK
|
||||||
|
# responds to an unknown theme name by silently falling back to its built-in
|
||||||
|
# default, which is LIGHT.
|
||||||
|
#
|
||||||
|
# So light mode appeared to work, dark mode produced light windows, and nothing
|
||||||
|
# anywhere reported an error. Applications that take their cue from the GTK
|
||||||
|
# theme rather than the portal -- Chromium and Electron among them -- were stuck
|
||||||
|
# light with no way to diagnose it from inside the application.
|
||||||
|
#
|
||||||
|
# The failure is silent by construction, so it needs a test rather than a
|
||||||
|
# comment. Checks the compositor-facing setting and the generated GTK config
|
||||||
|
# agree, and that both name something real.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
color_scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||||
|
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'gtk theme contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
theme_installed() {
|
||||||
|
local name="$1" dir
|
||||||
|
for dir in /usr/share/themes "$HOME/.themes" "$HOME/.local/share/themes"; do
|
||||||
|
[[ -d "$dir/$name/gtk-3.0" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── The names ColorScheme sets must exist ────────────────────────────────────
|
||||||
|
names="$(grep -oE 'root\.dark \? "[a-zA-Z0-9-]+" : "[a-zA-Z0-9-]+"' "$color_scheme" \
|
||||||
|
| grep -oE '"[a-zA-Z0-9-]+"' | tr -d '"' | grep -E '^adw-|^Adwaita' | sort -u)"
|
||||||
|
[[ -n "$names" ]] || fail 'could not find the GTK theme names in ColorScheme.qml -- this contract is not reading it correctly'
|
||||||
|
|
||||||
|
while read -r name; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
theme_installed "$name" \
|
||||||
|
|| fail "ColorScheme sets gtk-theme to \"$name\", which is not installed. GTK falls back to its light default when a theme is missing, so this produces light windows in dark mode with no error anywhere."
|
||||||
|
done <<<"$names"
|
||||||
|
|
||||||
|
# ── The generated GTK config must agree, in both directions ──────────────────
|
||||||
|
# Generated into a fixture rather than the live config, so running this cannot
|
||||||
|
# retheme the desktop it is running on.
|
||||||
|
fixture="$(mktemp -d /tmp/panama-gtk-theme.XXXXXX)"
|
||||||
|
trap 'rm -rf "$fixture"' EXIT
|
||||||
|
|
||||||
|
for version in 3.0 4.0; do
|
||||||
|
mkdir -p "$fixture/gtk-$version"
|
||||||
|
cp "$repo_dir/config/dot/gtk-$version/settings.ini.template" "$fixture/gtk-$version/" \
|
||||||
|
|| fail "gtk-$version has no settings.ini.template -- the generated file would never be produced"
|
||||||
|
done
|
||||||
|
|
||||||
|
for scheme in dark light; do
|
||||||
|
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
|
||||||
|
|
||||||
|
for version in 3.0 4.0; do
|
||||||
|
generated="$fixture/gtk-$version/settings.ini"
|
||||||
|
[[ -r "$generated" ]] || fail "gtk-$version settings.ini was not generated for $scheme"
|
||||||
|
|
||||||
|
grep -q '@GTK_THEME@\|@PREFER_DARK@' "$generated" \
|
||||||
|
&& fail "gtk-$version settings.ini still contains an unsubstituted placeholder for $scheme"
|
||||||
|
|
||||||
|
theme="$(sed -n 's/^gtk-theme-name=//p' "$generated")"
|
||||||
|
prefer="$(sed -n 's/^gtk-application-prefer-dark-theme=//p' "$generated")"
|
||||||
|
|
||||||
|
theme_installed "$theme" \
|
||||||
|
|| fail "gtk-$version settings.ini names \"$theme\" for $scheme, which is not installed"
|
||||||
|
|
||||||
|
if [[ "$scheme" == "dark" ]]; then
|
||||||
|
[[ "$prefer" == "1" ]] || fail "gtk-$version asks for prefer-dark=$prefer in dark mode"
|
||||||
|
[[ "$theme" == *dark* ]] || fail "gtk-$version uses \"$theme\" in dark mode, which is not a dark theme"
|
||||||
|
else
|
||||||
|
[[ "$prefer" == "0" ]] || fail "gtk-$version asks for prefer-dark=$prefer in light mode"
|
||||||
|
[[ "$theme" != *dark* ]] || fail "gtk-$version uses \"$theme\" in light mode, which is a dark theme"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'gtk theme contract: PASS\n'
|
||||||
@@ -288,18 +288,18 @@ shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
|||||||
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
||||||
for _ in $(seq 1 40); do
|
for _ in $(seq 1 40); do
|
||||||
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then
|
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||||
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
|
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
|
||||||
|
|
||||||
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
|
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
|
||||||
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
|
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
|
||||||
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
|
'.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
|
||||||
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
|
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
|
||||||
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
|
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# panama-keyring reports the login keyring's state, and the Settings page reads
|
||||||
|
# nothing but its JSON.
|
||||||
|
#
|
||||||
|
# The state that matters is LOCKED, and it is also the one that cannot be
|
||||||
|
# rehearsed on a real desktop: locking the login keyring breaks every saved
|
||||||
|
# password on the machine and can only be undone by typing the password into a
|
||||||
|
# dialog. So the secret service is stubbed here instead. Nothing touches the
|
||||||
|
# real keyring -- this contract is safe to run on the daily driver, which is the
|
||||||
|
# entire reason it is written this way.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'keyring helper contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stub_dir="$(mktemp -d /tmp/panama-keyring.XXXXXX)"
|
||||||
|
trap 'rm -rf "$stub_dir"' EXIT
|
||||||
|
|
||||||
|
# A stand-in for the `gi` module the helper imports. PANAMA_KEYRING_FAKE decides
|
||||||
|
# what the fake service reports, so one stub covers every case.
|
||||||
|
mkdir -p "$stub_dir/gi/repository"
|
||||||
|
cat >"$stub_dir/gi/__init__.py" <<'STUB'
|
||||||
|
def require_version(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
STUB
|
||||||
|
cat >"$stub_dir/gi/repository/__init__.py" <<'STUB'
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class _Collection:
|
||||||
|
def __init__(self, label, locked):
|
||||||
|
self._label = label
|
||||||
|
self._locked = locked
|
||||||
|
|
||||||
|
def get_label(self):
|
||||||
|
return self._label
|
||||||
|
|
||||||
|
def get_locked(self):
|
||||||
|
return self._locked
|
||||||
|
|
||||||
|
|
||||||
|
class _Service:
|
||||||
|
def get_collections(self):
|
||||||
|
mode = os.environ.get("PANAMA_KEYRING_FAKE", "unlocked")
|
||||||
|
if mode == "nologin":
|
||||||
|
return [_Collection("Some App", False)]
|
||||||
|
return [_Collection("Login", mode == "locked"), _Collection("", False)]
|
||||||
|
|
||||||
|
|
||||||
|
class _ServiceFactory:
|
||||||
|
@staticmethod
|
||||||
|
def get_sync(_flags, _cancellable):
|
||||||
|
if os.environ.get("PANAMA_KEYRING_FAKE") == "unavailable":
|
||||||
|
raise RuntimeError("no secret service")
|
||||||
|
return _Service()
|
||||||
|
|
||||||
|
# unlock_sync is what the `unlock` action calls; record that it was reached.
|
||||||
|
@staticmethod
|
||||||
|
def _noop(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Secret:
|
||||||
|
class ServiceFlags:
|
||||||
|
LOAD_COLLECTIONS = 1
|
||||||
|
|
||||||
|
Service = _ServiceFactory
|
||||||
|
STUB
|
||||||
|
|
||||||
|
run() {
|
||||||
|
PYTHONPATH="$stub_dir" PANAMA_KEYRING_FAKE="$1" python3 "$helper" "${2:-status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Unlocked: the normal state after any sign-in ─────────────────────────────
|
||||||
|
out="$(run unlocked)"
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"$out" || fail "status did not emit JSON: $out"
|
||||||
|
jq -e '.available == true and .locked == false and .hasLogin == true' >/dev/null <<<"$out" \
|
||||||
|
|| fail "an unlocked login keyring was misreported: $out"
|
||||||
|
|
||||||
|
# ── Locked: the state the whole card exists for ──────────────────────────────
|
||||||
|
out="$(run locked)"
|
||||||
|
jq -e '.available == true and .locked == true' >/dev/null <<<"$out" \
|
||||||
|
|| fail "a locked login keyring was not reported as locked: $out"
|
||||||
|
|
||||||
|
# ── No secret service at all is a state, not a crash ─────────────────────────
|
||||||
|
out="$(run unavailable)"
|
||||||
|
jq -e . >/dev/null 2>&1 <<<"$out" \
|
||||||
|
|| fail "a missing secret service produced no JSON, so the page would show nothing: $out"
|
||||||
|
jq -e '.available == false and .error != ""' >/dev/null <<<"$out" \
|
||||||
|
|| fail "a missing secret service must be reported with a reason: $out"
|
||||||
|
|
||||||
|
# ── No login keyring: not locked, because there is nothing to lock ───────────
|
||||||
|
out="$(run nologin)"
|
||||||
|
jq -e '.available == true and .hasLogin == false and .locked == false' >/dev/null <<<"$out" \
|
||||||
|
|| fail "a machine with no login keyring must not report itself locked: $out"
|
||||||
|
|
||||||
|
# ── The daemon origin is reported, since it is the crash diagnostic ──────────
|
||||||
|
jq -e '.daemon | test("^(pam|dbus|none|unknown)$")' >/dev/null <<<"$(run unlocked)" \
|
||||||
|
|| fail "the daemon origin must be one of pam/dbus/none/unknown"
|
||||||
|
|
||||||
|
printf 'keyring helper contract: PASS\n'
|
||||||
@@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG"
|
|||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
printf '\n' >>"$OSD_TEST_LOG"
|
||||||
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
|
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
|
||||||
|
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
|
||||||
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
|
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
|
||||||
fi
|
fi
|
||||||
SH
|
SH
|
||||||
|
|
||||||
|
cat >"$scratch/bin/panama-brightness" <<'SH'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'panama-brightness' >>"$OSD_TEST_LOG"
|
||||||
|
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||||
|
printf '\n' >>"$OSD_TEST_LOG"
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
list)
|
||||||
|
if [[ -n ${DDC_LIST_JSON:-} ]]; then
|
||||||
|
printf '%s\n' "$DDC_LIST_JSON"
|
||||||
|
else
|
||||||
|
printf '%s\n' '{"displays":[],"error":"No displays"}'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
get)
|
||||||
|
[[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1
|
||||||
|
if [[ -s $OSD_DDC_STATE ]]; then
|
||||||
|
cat "$OSD_DDC_STATE"
|
||||||
|
else
|
||||||
|
printf '%s\n' "${DDC_GET_VALUE:-40}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
set)
|
||||||
|
if [[ -n ${DDC_SET_DELAY:-} ]]; then
|
||||||
|
if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then
|
||||||
|
printf 'ddc-overlap\n' >>"$OSD_TEST_LOG"
|
||||||
|
fi
|
||||||
|
sleep "$DDC_SET_DELAY"
|
||||||
|
rmdir "$OSD_DDC_PROBE" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE"
|
||||||
|
;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$scratch/bin/hyprctl" <<'SH'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'hyprctl' >>"$OSD_TEST_LOG"
|
||||||
|
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||||
|
printf '\n' >>"$OSD_TEST_LOG"
|
||||||
|
printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}"
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat >"$scratch/bin/notify-send" <<'SH'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'notify-send' >>"$OSD_TEST_LOG"
|
||||||
|
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||||
|
printf '\n' >>"$OSD_TEST_LOG"
|
||||||
|
SH
|
||||||
|
|
||||||
cat >"$scratch/bin/playerctl" <<'SH'
|
cat >"$scratch/bin/playerctl" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'playerctl' >>"$OSD_TEST_LOG"
|
printf 'playerctl' >>"$OSD_TEST_LOG"
|
||||||
@@ -53,9 +105,21 @@ SH
|
|||||||
chmod +x "$scratch/bin/"*
|
chmod +x "$scratch/bin/"*
|
||||||
|
|
||||||
run_helper() {
|
run_helper() {
|
||||||
|
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
|
||||||
|
mkdir -p "$runtime"
|
||||||
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
|
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
|
||||||
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
|
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
|
||||||
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
|
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
|
||||||
|
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
|
||||||
|
PANAMA_OSD_RUNTIME_DIR="$runtime" \
|
||||||
|
OSD_DDC_STATE="$runtime/ddc-state" \
|
||||||
|
OSD_DDC_PROBE="$runtime/ddc-probe" \
|
||||||
|
BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \
|
||||||
|
DDC_LIST_JSON="${DDC_LIST_JSON:-}" \
|
||||||
|
DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \
|
||||||
|
DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \
|
||||||
|
DDC_SET_DELAY="${DDC_SET_DELAY:-}" \
|
||||||
|
FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \
|
||||||
"$helper" "$@"
|
"$helper" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +150,102 @@ assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Mut
|
|||||||
|
|
||||||
: >"$log"
|
: >"$log"
|
||||||
run_helper brightness up 5
|
run_helper brightness up 5
|
||||||
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
|
|
||||||
assert_line 'brightnessctl <-m> <-c> <backlight>'
|
assert_line 'brightnessctl <-m> <-c> <backlight>'
|
||||||
|
assert_line 'brightnessctl <-e4> <-n2> <-c> <backlight> <set> <5%+>'
|
||||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
|
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
|
||||||
|
if grep -Fq 'panama-brightness' "$log"; then
|
||||||
|
printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: >"$log"
|
||||||
|
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
|
||||||
|
BACKLIGHT_AVAILABLE=false \
|
||||||
|
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||||
|
run_helper brightness up 5
|
||||||
|
assert_line 'hyprctl <-j> <monitors>'
|
||||||
|
assert_line 'panama-brightness <list>'
|
||||||
|
assert_line 'panama-brightness <get> <5>'
|
||||||
|
assert_line 'panama-brightness <set> <5> <45>'
|
||||||
|
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <45> <100> <45%>'
|
||||||
|
|
||||||
|
# A cached bus avoids the expensive display scan on subsequent key presses.
|
||||||
|
: >"$log"
|
||||||
|
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
|
||||||
|
BACKLIGHT_AVAILABLE=false \
|
||||||
|
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \
|
||||||
|
run_helper brightness down 5
|
||||||
|
assert_line 'panama-brightness <get> <5>'
|
||||||
|
assert_line 'panama-brightness <set> <5> <40>'
|
||||||
|
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <40> <100> <40%>'
|
||||||
|
if grep -Fq 'panama-brightness <list>' "$log"; then
|
||||||
|
printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A disconnected cached monitor is discarded and rediscovered once.
|
||||||
|
mkdir -p "$scratch/runtime-ddc-stale"
|
||||||
|
printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus"
|
||||||
|
: >"$log"
|
||||||
|
OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \
|
||||||
|
BACKLIGHT_AVAILABLE=false \
|
||||||
|
DDC_FAIL_GET_BUS=9 \
|
||||||
|
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||||
|
run_helper brightness up 5
|
||||||
|
assert_line 'panama-brightness <get> <9>'
|
||||||
|
assert_line 'panama-brightness <list>'
|
||||||
|
assert_line 'panama-brightness <get> <5>'
|
||||||
|
assert_line 'panama-brightness <set> <5> <45>'
|
||||||
|
|
||||||
|
# If the focused output is not DDC-capable, use the first discovered display.
|
||||||
|
: >"$log"
|
||||||
|
OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \
|
||||||
|
BACKLIGHT_AVAILABLE=false \
|
||||||
|
FOCUSED_MONITOR='eDP-1' \
|
||||||
|
DDC_GET_VALUE=35 \
|
||||||
|
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||||
|
run_helper brightness down 10
|
||||||
|
assert_line 'panama-brightness <get> <3>'
|
||||||
|
assert_line 'panama-brightness <set> <3> <25>'
|
||||||
|
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <25> <100> <25%>'
|
||||||
|
|
||||||
|
# Permission and discovery errors must be visible, never masquerade as 0%.
|
||||||
|
: >"$log"
|
||||||
|
OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \
|
||||||
|
BACKLIGHT_AVAILABLE=false \
|
||||||
|
DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \
|
||||||
|
run_helper brightness up 5
|
||||||
|
assert_line 'qs <ipc> <call> <osd> <message> <dialog-warning-symbolic> <Brightness needs permission>'
|
||||||
|
assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> <Brightness unavailable> <Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm>'
|
||||||
|
if grep -Fq 'osd> <progress> <brightness>' "$log"; then
|
||||||
|
printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Separate key-repeat processes must not overlap their DDC transactions.
|
||||||
|
: >"$log"
|
||||||
|
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
|
||||||
|
BACKLIGHT_AVAILABLE=false \
|
||||||
|
DDC_SET_DELAY=0.15 \
|
||||||
|
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||||
|
run_helper brightness up 5 &
|
||||||
|
first_pid=$!
|
||||||
|
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
|
||||||
|
BACKLIGHT_AVAILABLE=false \
|
||||||
|
DDC_SET_DELAY=0.15 \
|
||||||
|
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||||
|
run_helper brightness up 5 &
|
||||||
|
second_pid=$!
|
||||||
|
wait "$first_pid"
|
||||||
|
wait "$second_pid"
|
||||||
|
if grep -Fqx 'ddc-overlap' "$log"; then
|
||||||
|
printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then
|
||||||
|
printf 'osd helper contract: serialized key repeats did not both apply\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
: >"$log"
|
: >"$log"
|
||||||
run_helper media next
|
run_helper media next
|
||||||
|
|||||||
@@ -61,6 +61,26 @@ restore() {
|
|||||||
}
|
}
|
||||||
trap restore EXIT
|
trap restore EXIT
|
||||||
|
|
||||||
|
# This contract shares its harness file with settings-hyprland-write-contract,
|
||||||
|
# and Quickshell identifies an instance by config path -- so if that run's
|
||||||
|
# instance is still alive, the IPC wait below is satisfied by ITS target. That
|
||||||
|
# direction is the dangerous one: this contract believes the compositor seam is
|
||||||
|
# stubbed, so it would happily drive the DAILY DESKTOP's real compositor while
|
||||||
|
# reporting isolation. Refuse to start rather than find out.
|
||||||
|
harness_instances() {
|
||||||
|
# rg -c prints nothing when there are no matches, so an unguarded
|
||||||
|
# substitution yields "" rather than "0".
|
||||||
|
local count
|
||||||
|
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
|
||||||
|
printf '%s' "${count:-0}"
|
||||||
|
}
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
[[ "$(harness_instances)" == "0" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$(harness_instances)" == "0" ]] \
|
||||||
|
|| fail 'another instance of the settings harness is still running -- this contract would drive it instead of its own isolated one, and that instance may be writing to the real compositor'
|
||||||
|
|
||||||
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||||
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null
|
PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1 qs -p "$harness" --daemonize >/dev/null
|
||||||
for _ in $(seq 1 40); do
|
for _ in $(seq 1 40); do
|
||||||
|
|||||||
@@ -77,6 +77,32 @@ target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
|
|||||||
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
|
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
|
||||||
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
|
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
|
||||||
|
|
||||||
|
# settings-commit-reset-contract drives the SAME harness file with
|
||||||
|
# PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR=1, where the compositor write seam is
|
||||||
|
# stubbed out. Quickshell identifies an instance by its config path, so if that
|
||||||
|
# run's instance has not fully exited, the `ipc show` wait below is satisfied by
|
||||||
|
# ITS target -- and every write in this contract lands on the isolated instance
|
||||||
|
# and never reaches the compositor. That is exactly what "a typed batch did not
|
||||||
|
# reach the compositor" looks like when this fails in a full suite run but
|
||||||
|
# passes on its own.
|
||||||
|
#
|
||||||
|
# So wait for the harness to be clear first, and say so plainly if it is not,
|
||||||
|
# rather than silently talking to the wrong shell.
|
||||||
|
harness_instances() {
|
||||||
|
# rg -c prints nothing at all when there are no matches, so an unguarded
|
||||||
|
# substitution yields "" rather than "0" and every comparison against a
|
||||||
|
# count fails.
|
||||||
|
local count
|
||||||
|
count="$(qs list 2>/dev/null | rg -c "^ Config path: $harness\$" || true)"
|
||||||
|
printf '%s' "${count:-0}"
|
||||||
|
}
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
[[ "$(harness_instances)" == "0" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$(harness_instances)" == "0" ]] \
|
||||||
|
|| fail 'another instance of the settings harness is still running -- this contract would talk to it instead of its own, and its writes may be deliberately stubbed'
|
||||||
|
|
||||||
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||||
for _ in $(seq 1 40); do
|
for _ in $(seq 1 40); do
|
||||||
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
|
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
|
||||||
@@ -123,8 +149,23 @@ sleep 0.3
|
|||||||
qs_for_harness ipc call settings-system-test applyJson \
|
qs_for_harness ipc call settings-system-test applyJson \
|
||||||
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
||||||
|
|
||||||
|
# 10 seconds, not 4. The write path verifies each option by reading it back off
|
||||||
|
# the compositor and retries a refused batch, so a busy machine legitimately
|
||||||
|
# takes longer than a quick apply -- and this contract runs in a suite alongside
|
||||||
|
# other tests driving the same compositor. Failing at 4 seconds reported a
|
||||||
|
# product bug ("did not reach the compositor") for what was queueing.
|
||||||
|
# Re-issued periodically, because this contract and the LIVE shell both write to
|
||||||
|
# the same compositor. When the running Panama re-applies its own preferences --
|
||||||
|
# which it does on any store change -- it overwrites the values this test just
|
||||||
|
# set, and the read-back below then sees Panama's shipped defaults with the
|
||||||
|
# writer reporting no error at all. That combination is the signature: a
|
||||||
|
# rejected write leaves an error, a clobbered one does not.
|
||||||
typed=false
|
typed=false
|
||||||
for _ in $(seq 1 40); do
|
for attempt in $(seq 1 100); do
|
||||||
|
if (( attempt % 30 == 0 )); then
|
||||||
|
qs_for_harness ipc call settings-system-test applyJson \
|
||||||
|
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
|
||||||
|
fi
|
||||||
if [[ "$(read_option decoration:rounding)" == "7" \
|
if [[ "$(read_option decoration:rounding)" == "7" \
|
||||||
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
|
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
|
||||||
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
|
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
|
||||||
@@ -135,10 +176,14 @@ for _ in $(seq 1 40); do
|
|||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
if [[ "$typed" != true ]]; then
|
if [[ "$typed" != true ]]; then
|
||||||
|
# Report what the writer thinks as well as what the compositor holds. Those
|
||||||
|
# two disagreeing is a rejected write; both showing defaults is a write that
|
||||||
|
# never happened, and the messages should not look identical.
|
||||||
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
|
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
|
||||||
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
|
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
|
||||||
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
|
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
|
||||||
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
|
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float), \
|
||||||
|
writer-reported error=\"$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)\""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verification must recognise those shapes as success, not report them rejected.
|
# Verification must recognise those shapes as success, not report them rejected.
|
||||||
|
|||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Adding a settings page means editing four separate files, and missing one
|
||||||
|
# fails quietly rather than loudly:
|
||||||
|
#
|
||||||
|
# SettingsSidebar.qml the row you click
|
||||||
|
# SettingsShell.qml the case that maps that row to a component, AND the
|
||||||
|
# Component declaration itself
|
||||||
|
# ShellState.qml the allow-list openSettings() checks -- a page missing
|
||||||
|
# here silently redirects to Home, so a deep link or a
|
||||||
|
# search result lands on the wrong page with no error
|
||||||
|
# modules/settings/qmldir the component registration -- without it the page
|
||||||
|
# is "not a type" and the whole settings window fails
|
||||||
|
# to load, taking every other page with it
|
||||||
|
#
|
||||||
|
# Nothing at runtime cross-checks the four. This does, statically.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||||
|
sidebar="$settings_dir/SettingsSidebar.qml"
|
||||||
|
shell_file="$settings_dir/SettingsShell.qml"
|
||||||
|
qmldir="$settings_dir/qmldir"
|
||||||
|
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'settings nav contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for required in "$sidebar" "$shell_file" "$qmldir" "$shell_state"; do
|
||||||
|
[[ -r "$required" ]] || fail "cannot read $required"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Every sidebar row resolves everywhere ────────────────────────────────────
|
||||||
|
pages="$(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\(.*\)"/\1/')"
|
||||||
|
[[ -n "$pages" ]] || fail 'no pages found in the sidebar -- this contract is not reading it correctly'
|
||||||
|
|
||||||
|
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|
||||||
|
|| fail 'could not find the allow-list in ShellState'
|
||||||
|
|
||||||
|
while read -r page; do
|
||||||
|
[[ -n "$page" ]] || continue
|
||||||
|
|
||||||
|
# Home is the switch's default arm rather than a case, since it is also
|
||||||
|
# where an unknown page falls back to.
|
||||||
|
if [[ "$page" != "home" ]]; then
|
||||||
|
grep -qE "case \"$page\": return [a-zA-Z]+;" "$shell_file" \
|
||||||
|
|| fail "the sidebar offers \"$page\" but SettingsShell has no case for it, so clicking it shows Home"
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -qF "\"$page\"" <<<"$allowed_line" \
|
||||||
|
|| fail "\"$page\" is missing from ShellState's allow-list, so openSettings(\"$page\") silently redirects to Home"
|
||||||
|
done <<<"$pages"
|
||||||
|
|
||||||
|
# ── Every routed component is declared and registered ────────────────────────
|
||||||
|
# The case arms name a Component id; each must have a declaration, and the type
|
||||||
|
# it instantiates must appear in qmldir.
|
||||||
|
while read -r component; do
|
||||||
|
[[ -n "$component" ]] || continue
|
||||||
|
|
||||||
|
declaration="$(grep -oE "Component \{ id: $component; [A-Za-z]+ \{\} \}" "$shell_file")" \
|
||||||
|
|| fail "SettingsShell routes to \"$component\" but never declares it"
|
||||||
|
|
||||||
|
type_name="$(sed -E 's/.*; ([A-Za-z]+) \{\} \}/\1/' <<<"$declaration")"
|
||||||
|
grep -qE "^$type_name [0-9.]+ $type_name\.qml$" "$qmldir" \
|
||||||
|
|| fail "$type_name is not registered in modules/settings/qmldir -- it will fail to load as \"not a type\", and the whole settings window fails with it"
|
||||||
|
|
||||||
|
[[ -r "$settings_dir/$type_name.qml" ]] \
|
||||||
|
|| fail "$type_name is registered in qmldir but $type_name.qml does not exist"
|
||||||
|
done < <({
|
||||||
|
grep -oE 'case "[a-z-]+": return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
|
||||||
|
grep -oE 'default: return [a-zA-Z]+;' "$shell_file" | sed -E 's/.*return ([a-zA-Z]+);/\1/'
|
||||||
|
} | sort -u)
|
||||||
|
|
||||||
|
# ── Every page file is reachable ─────────────────────────────────────────────
|
||||||
|
# A page nobody can navigate to is dead code that still has to compile. The
|
||||||
|
# scaffold SettingsPage.qml is the one file here that is a base class rather
|
||||||
|
# than a page.
|
||||||
|
while read -r page_file; do
|
||||||
|
type_name="$(basename "$page_file" .qml)"
|
||||||
|
[[ "$type_name" == "SettingsPage" ]] && continue
|
||||||
|
|
||||||
|
grep -qE "; $type_name \{\} \}" "$shell_file" \
|
||||||
|
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
|
||||||
|
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
|
||||||
|
|
||||||
|
printf 'settings nav contract: PASS\n'
|
||||||
@@ -195,14 +195,14 @@ for page in "${pages[@]}"; do
|
|||||||
done
|
done
|
||||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
||||||
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||||
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||||
|| fail "$page created a missing, floating, or duplicate Settings window"
|
|| fail "$page created a missing, floating, or duplicate Settings window"
|
||||||
done
|
done
|
||||||
|
|
||||||
qs_for_test ipc call settings page '__unsupported__' >/dev/null
|
qs_for_test ipc call settings page '__unsupported__' >/dev/null
|
||||||
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
||||||
|
|
||||||
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
||||||
|| fail 'Super+I is not registered as Panama Settings'
|
|| fail 'Super+I is not registered as Panama Settings'
|
||||||
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
||||||
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ qs ipc show | rg -q '^target settings$' || fail 'settings IPC target is missing'
|
|||||||
qs ipc call settings open >/dev/null
|
qs ipc call settings open >/dev/null
|
||||||
|
|
||||||
for _ in $(seq 1 40); do
|
for _ in $(seq 1 40); do
|
||||||
if hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null; then
|
if hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \
|
hyprctl -j clients | jq -e '[.[] | select(.title == "Settings")] | length == 1' >/dev/null \
|
||||||
|| fail 'exactly one Settings window did not map'
|
|| fail 'exactly one Settings window did not map'
|
||||||
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings" and .floating == false)' >/dev/null \
|
hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == false)' >/dev/null \
|
||||||
|| fail 'Settings window is not tiled'
|
|| fail 'Settings window is not tiled'
|
||||||
|
|
||||||
qs ipc call settings page displays >/dev/null
|
qs ipc call settings page displays >/dev/null
|
||||||
@@ -32,7 +32,7 @@ qs ipc call settings page displays >/dev/null
|
|||||||
qs ipc call settings page desktop >/dev/null
|
qs ipc call settings page desktop >/dev/null
|
||||||
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
|
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
|
||||||
|
|
||||||
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Panama Settings") | .address')"
|
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')"
|
||||||
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
|
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
|
||||||
for _ in $(seq 1 40); do
|
for _ in $(seq 1 40); do
|
||||||
[[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break
|
[[ "$(qs ipc call settings status | jq -r .open)" == "false" ]] && break
|
||||||
@@ -42,13 +42,13 @@ done
|
|||||||
|
|
||||||
qs ipc call settings open >/dev/null
|
qs ipc call settings open >/dev/null
|
||||||
for _ in $(seq 1 40); do
|
for _ in $(seq 1 40); do
|
||||||
hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null && break
|
hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null && break
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
|
|
||||||
qs ipc call settings close >/dev/null
|
qs ipc call settings close >/dev/null
|
||||||
for _ in $(seq 1 40); do
|
for _ in $(seq 1 40); do
|
||||||
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Panama Settings")' >/dev/null; then
|
if ! hyprctl -j clients | jq -e '.[] | select(.title == "Settings")' >/dev/null; then
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
printf 'settings window contract: PASS\n'
|
printf 'settings window contract: PASS\n'
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
Reference in New Issue
Block a user