1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-20 10:05:22 -04:00

Compare commits

..

35 Commits

Author SHA1 Message Date
bbedward 4203148cab ci: add settings index check to pre-commit 2026-06-18 18:42:33 -04:00
bbedward 5adc0c464a toast: fix blur 2026-06-18 15:49:53 -04:00
Rocho 097290f7da fix(lock): show the faillock lockout reason instead of "incorrect password" (#2669)
When pam_faillock locks the account (DMS authenticates through the system PAM
stack: /etc/pam.d/login -> system-auth), the lock screen kept showing the
generic "Incorrect password - try again" even though the real cause is a
lockout, so a correct password looks rejected and only a reboot (which clears
the tmpfs /run/faillock tally) appears to help. See #2647.

The previous onMessageChanged only matched the *English* faillock strings
("The account is locked ...") and then wiped that text again on the trailing
pam_unix "Password:" prompt. On a non-English system (e.g. German) the strings
never matched, so the lockout was never surfaced at all.

Detect the notice by position rather than by text: pam emits its informational
messages within an attempt before the password prompt. Collect every non-prompt
info message and, once the prompt arrives, surface the collected lines (minus
the prompt itself) as lockMessage. If the stack short-circuits without ever
prompting (e.g. pam_faillock preauth configured as requisite), the notice is
surfaced on completion instead. This is locale-independent. A per-attempt flag
keeps the message stable across repeated locked attempts and retires it when an
attempt completes without a lockout (faillock reset / unlock_time elapsed).

Fixes #2647
2026-06-18 09:54:02 -04:00
Ira Limitanei 475ef5d1ca feat(launcher): make dms color picker entry toggleable (#2667) 2026-06-18 08:46:04 -04:00
Ralph Zhou 2f37019782 fix(popouts): keep Hyprland focus during close (#2655) 2026-06-18 01:41:25 -04:00
Rocho 9f4123cc3c fix(bar): clear blur region when the bar is hidden (auto-hide) (#2658)
When a bar with background transparency + blur uses auto-hide, the
ext-background-effect-v1 blur region was only slid off-surface via the
reveal Translate, but the region object stayed non-empty.

Hyprland gates layer-surface blur on `!m_blurRegion.empty()`, so the
non-empty region keeps blur enabled; the renderer then intersects the
off-surface region with the surface box, the clip degenerates to empty,
and an empty clip is treated as "unclipped" — so the whole bar surface
box gets blurred, leaving a blurred strip where the hidden bar would be.
(niri clips correctly, so it never showed there.)

Gate the published blur region on `barRevealed`: tear it down (null)
whenever the bar is not currently shown, so the region is genuinely
empty and the compositor disables the effect. Fixes #2656.
2026-06-18 00:58:19 -04:00
Yechiel 482a87a80d feat(bar): add visual indication for connected bluetooth (#2662) 2026-06-18 00:57:27 -04:00
jbwfu b925010cb3 fix(clipboard): paste selected history entry (#2660) 2026-06-18 00:57:07 -04:00
Huỳnh Thiện Lộc 085ce01da6 feat: port critical battery alert from DankBatteryAlerts plugin (#2664)
* feat: port critical battery alert from DankBatteryAlerts plugin

* ui: shorten notification type labels

* refactor: remove duplicate battery charge limit from Power & Sleep
2026-06-17 22:14:57 -04:00
Jonas Bloch 7af530de8f fix(ui): use primaryText instead of primary for text and icons displayed on primaryContainer background in the launcher (#2586)
* fix(ui): use primaryText instead of primary for text and icons displayed on primaryContainer background in the launcher

* fix(ui): use onPrimaryContainer instead of primaryText on primaryContainer background

* launcher pills: switch to buttonBg buttonText

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-06-17 21:58:11 -04:00
bbedward 9a4cff4e49 settings: fix index conflict on battery tab 2026-06-17 21:30:52 -04:00
Huỳnh Thiện Lộc 480ffa4ac2 feat: add battery settings tab and update battery widget interactions (#2634)
* feat: add battery settings tab and update battery widget interactions

* fix: import Quickshell.Io in BatteryTab.qml to fix Process type compilation error

* feat: move battery tab under media player and add notify when charge limit reached option

* chore: change default notification settings to false

* feat: move battery tab under Power & Security section in settings

* feat: add notification type button selection for battery alerts
2026-06-17 20:29:23 -04:00
dionjoshualobo d5ac0c9aa0 feat(clipboard): add type filters to clipboard history (#2640)
* feat(clipboard): add active filter state

* feat(clipboard): add clipboard filtering logic

* feat(clipboard): wire clipboard filter state to UI

* feat(clipboard): add filter dropdown

* feat(clipboard): move filter dropdown beside search

* refactor(clipboard): update filter defaults

---------

Co-authored-by: purian23 <purian23@gmail.com>
2026-06-17 17:27:45 -04:00
bbedward 29f19b07a9 widgets: fix tooltip position mapping 2026-06-17 12:46:24 -04:00
purian23 39301c534c add: DankColorSwatch component & settings sync 2026-06-17 11:38:25 -04:00
Rocho 58b9e4bda7 fix(dropdown): respect Animation Duration setting for DankDropdown popups (#2661)
DankDropdown popups opened and closed at a fixed speed regardless of the
configured Animation Duration. The Popup inherited Qt Material's default
enter/exit transitions, whose durations are hardcoded and never reference
Theme.shortDuration.

Override enter/exit with theme-driven transitions that keep the Material
grow/fade look (scale + opacity) but read their duration from
Theme.shortDuration, so every DankDropdown instance follows the
animation-speed setting.

Fixes #2659
2026-06-17 09:46:09 -04:00
purian23 820a9ce983 refactor(settings): colors & cleanup 2026-06-17 00:32:58 -04:00
purian23 68410e882d feat(dbar): add workspace & widget color customization options 2026-06-17 00:02:51 -04:00
bbedward f26c0af39a keybinds: add toggle to switch to FloatingWindow and back 2026-06-16 23:03:19 -04:00
Rocho 0ca451483f fix(shell): don't treat DPMS off/on as a screen-reconnect recovery storm (#2654)
A DPMS off/on cycle removes an output from Quickshell.screens and re-adds
it, which DMSShell's onScreensChanged cannot distinguish from a hotplug. It
fired triggerSurfaceRecovery() on every such event; on hardware where
recreating layer-shell surfaces re-wakes the just-powered-down output, this
drives an endless recovery storm that visibly power-cycles the monitor.

Route the screen-reconnect path through a 450 ms debounce (collapsing the
output-remove + re-add pair into a single pass) followed by a 4 s cooldown,
so repeated flaps trigger at most one recovery per window. Recovery still
runs once per resume, so the partial-DPMS-resume recovery added for #2579 is
preserved. The session-resume path runs its own recovery directly and now
clears any queued screen-reconnect recovery to avoid a redundant follow-up.

Fixes #2642
2026-06-16 22:58:21 -04:00
purian23 2849dd0ba2 feat(distros): Add Ubuntu 26.10 Stingray Support 2026-06-16 22:50:58 -04:00
bbedward df41ae4acb running apps: fix blurred tooltips 2026-06-16 18:26:29 -04:00
lingdianshiren 2692777707 fix(weather): robust location resolution with parallel fetch and multi-tier fallback (#2638)
Decouple weather data fetching from reverse geocoding so that
weather loads as soon as coordinates are available, even when
Nominatim is unreachable (e.g. mainland China).

- Fetch Open-Meteo weather immediately once lat/lon are known.
- Resolve city name in parallel via Nominatim -> Photon -> BigDataCloud.
- If all reverse geocoding fails, keep displaying weather with a
  placeholder city name.
- Skip reverse geocoding entirely when the user has configured a
  city name.
- Fall back to ip-api.com when GeoClue2 is unavailable or returns
  zero coordinates.
- Add request-generation tracking to discard stale geocoding
  responses after location changes.
- Hold explicit WeatherService refs in bar widget and dashboard tab.

Co-authored-by: lingdiansr <2077258365@qq.com>
2026-06-16 14:42:01 -04:00
Rocho ca1a45ccf8 fix(lock): dismiss fade-to-lock overlay when using a custom lock command (#2653)
When a custom lock command is configured, Lock.lock() runs the command and
returns early without engaging WlSessionLock, so IdleService.isShellLocked
never transitions true->false. That transition is the only trigger that
dismisses a completed FadeToLockWindow, so the fully-faded black overlay stays
on screen and the desktop is unusable after re-login (regression from b8f4c35,
which added the _completed guard and tied dismissal solely to isShellLocked).

Add a dedicated dismissFadeToLock signal that the custom-lock branch emits
after launching the external locker, mirroring the existing fade signal wiring,
so the overlay is handed off and torn down. The built-in WlSessionLock path is
unchanged and still dismisses on unlock.

Fixes #2595
2026-06-16 13:45:28 -04:00
jbwfu 2f39f248fc fix(network): keep Wi-Fi when password prompt is canceled (#2651) 2026-06-16 12:52:25 -04:00
Rocho 90f8ce5035 feat(sounds): make muting sounds during media playback configurable (#2652)
Commit e3dbaed started skipping all system sounds (notifications, volume,
power, login) whenever an MPRIS player is playing, with no way to opt out.
Users who rely on notification sounds while listening to music lost them
entirely.

Add a 'Mute During Playback' toggle (SettingsData.muteSoundsWhenMediaPlaying,
default true) so the current behaviour is preserved, but users can disable
it to restore audible sounds while media is playing. The media check is
wrapped in shouldMuteForMedia(), which gates on the new setting before
querying the active player.

Closes #2616
2026-06-16 12:49:51 -04:00
Adwait Adhikari cb29125580 add lexical-binding (#2649) 2026-06-16 09:19:38 -04:00
Rocho 988b54515e feat(tailscale): add connect/disconnect, exit-node and LAN-access controls (#2644)
* feat(tailscale): add connect/disconnect/exit-node/LAN-access backend

The Tailscale backend previously exposed only read-only status
(tailscale.getStatus, tailscale.refresh). This adds write actions through the
existing tailscale.com/client/local integration:

- tailscale.connect / tailscale.disconnect (EditPrefs WantRunning)
- tailscale.setExitNode (EditPrefs ExitNodeID; empty id clears it and any
  legacy ExitNodeIP, mirroring `tailscale set --exit-node`)
- tailscale.setAllowLanAccess (EditPrefs ExitNodeAllowLANAccess)

The manager's client interface gains GetPrefs/EditPrefs; fetchState merges
ExitNodeAllowLANAccess from prefs, and Peer exposes ExitNodeOption so the UI
can list exit-node-capable peers.

* feat(tailscale): expose the new actions in TailscaleService

Adds connectTailscale/disconnectTailscale, setExitNode/clearExitNode and
setAllowLanAccess wrappers, plus derived exitNodeOptions/currentExitNode and the
exitNodeAllowLanAccess state. Write-action errors surface via ToastService.

* feat(tailscale): add connection, exit-node and LAN-access controls to the widget

The control-center widget toggle was a no-op. It now connects/disconnects, and
the detail panel gains a connection status row with a connect/disconnect button,
an exit-node picker and a LAN-access toggle.
2026-06-16 09:08:22 -04:00
Rocho 2fd9de5062 fix(keybinds): record numpad keys as KP_* keysyms (#2645)
* fix(keybinds): record numpad keys as KP_* keysyms

The shortcut recorder passed only the Qt key code to xkbKeyFromQtKey and
dropped Qt.KeypadModifier. Since Qt reuses the same Qt::Key_* values for the
numpad and the main row / nav cluster, numpad presses collapsed onto their
twins: numpad-7 became "7" (NumLock on) or "Home" (NumLock off) instead of
"KP_7"/"KP_Home", numpad-+ became "Equal", numpad-* became "8", numpad Enter
became "Return". numpad-5 with NumLock off (Qt.Key_Clear) was missing from the
map entirely, so the capture was silently dropped.

niri and the other providers bind against the xkb KP_* keysym names, so these
tokens never matched the physical key.

Pass the keypad flag through to xkbKeyFromQtKey and map keypad presses to the
KP_* keysyms: KP_0..KP_9 for the NumLock-on digit codes, the navigation names
(KP_Home, KP_End, KP_Up, ...) for the NumLock-off codes, plus the operators
and KP_Enter. Main-row keys are unaffected because they never carry the keypad
modifier.

* fix(keybinds): ignore lock keys while capturing a shortcut

NumLock/CapsLock/ScrollLock are toggles, not useful bind targets. Pressing
NumLock to switch the numpad between its digit and navigation keysyms
(KP_7 vs KP_Home) was captured as the bind itself (e.g. "Super+Num_Lock").
Skip them in the recorder like the other pure modifier keys already are.
2026-06-16 09:07:48 -04:00
Rocho fd5aabcb17 fix(keybinds): parse niri configs with leading-underscore identifiers (#2646)
DMS reads the niri config with kdl-go, which rejects '_' as the first
character of a bare identifier ("unexpected character _") even though niri's
own parser and the KDL spec accept it. The common trigger is the
`_JAVA_AWT_WM_NONREPARENTING "1"` environment node (the standard Java /
tiling-WM fix). When the parse aborts, `dms keybinds show` returns nothing and
the Keyboard Shortcuts UI shows no binds at all.

Extend the existing preprocessor approach (the brace fix from #2230) with
quoteLeadingUnderscoreIdents, which double-quotes bare identifiers that begin
with '_' before the text reaches kdl-go. The scan is string/comment aware and
only touches a leading '_' at a token boundary, so mid-identifier underscores
(XDG_CURRENT_DESKTOP) and underscores inside strings/comments are left alone.
Token boundaries include the ends of block comments and KDL slashdash (/-), so
a node abutting a comment with no whitespace is handled too. This is safe
because the niri parser only dispatches on fixed node/section names that never
start with '_', so re-quoting such a name cannot change what DMS reads.

Refs #2230
2026-06-16 09:06:55 -04:00
jbwfu 85b63219b9 feat(network): add saved WiFi state to settings (#2648) 2026-06-16 09:06:29 -04:00
bbedward ddf943846f i18n: add Vietnamese 2026-06-15 23:47:12 -04:00
purian23 e7221ec623 fix(powermodal): use overlay layer in standalone mode 2026-06-15 21:41:00 -04:00
bbedward 78daaf0cb4 calendar: remove launch button from settings 2026-06-15 19:02:22 -04:00
jbwfu a6ab3bab4c fix(settings): dedupe search index tab entries (#2643) 2026-06-15 16:50:41 -04:00
124 changed files with 19067 additions and 2502 deletions
+8
View File
@@ -20,6 +20,14 @@ repos:
language: system language: system
files: ^core/.*\.(go|mod|sum)$ files: ^core/.*\.(go|mod|sum)$
pass_filenames: false pass_filenames: false
- repo: local
hooks:
- id: settings-search-index
name: settings search index is up to date
entry: bash -c 'python3 quickshell/translations/extract_settings_index.py >/dev/null || exit 1; if ! git diff --exit-code -- quickshell/translations/settings_search_index.json; then echo "settings_search_index.json is out of date; run quickshell/translations/extract_settings_index.py and stage the result" >&2; exit 1; fi'
language: system
files: ^quickshell/(Modules/Settings/.*\.qml|Modals/Settings/SettingsSidebar\.qml|translations/extract_settings_index\.py)$
pass_filenames: false
- repo: local - repo: local
hooks: hooks:
- id: no-console-in-qml - id: no-console-in-qml
@@ -51,7 +51,7 @@ type NiriParser struct {
} }
func parseKDL(data []byte) (*document.Document, error) { func parseKDL(data []byte) (*document.Document, error) {
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data)))) return kdl.Parse(strings.NewReader(normalizeKDLBraces(quoteLeadingUnderscoreIdents(string(data)))))
} }
func normalizeKDLBraces(input string) string { func normalizeKDLBraces(input string) string {
@@ -94,6 +94,93 @@ func normalizeKDLBraces(input string) string {
return sb.String() return sb.String()
} }
// quoteLeadingUnderscoreIdents wraps bare KDL identifiers that begin with '_'
// in double quotes. kdl-go rejects '_' as the first character of a bare
// identifier (e.g. the common `_JAVA_AWT_WM_NONREPARENTING "1"` environment
// node), even though niri's own parser and the KDL spec accept it — so without
// this the whole config fails to parse and no keybinds load. Quoting lets
// kdl-go parse it; this is safe because the niri parser only dispatches on
// fixed node/section names (binds, recent-windows, include, ...) that never
// start with '_', so re-quoting such a name cannot change what DMS reads.
// Underscores elsewhere in an identifier (XDG_CURRENT_DESKTOP) are left
// untouched, and underscores inside strings or comments are skipped. Only a
// leading '_' is handled; other start characters kdl-go over-rejects (e.g. '.'
// or '?') do not occur in niri configs.
func quoteLeadingUnderscoreIdents(input string) string {
var sb strings.Builder
sb.Grow(len(input))
var prev byte
n := len(input)
for i := 0; i < n; {
c := input[i]
switch {
case c == '"':
end := findStringEnd(input, i)
sb.WriteString(input[i:end])
prev = '"'
i = end
case c == '/' && i+1 < n && input[i+1] == '/':
end := findLineCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = '\n'
i = end
case c == '/' && i+1 < n && input[i+1] == '*':
end := findBlockCommentEnd(input, i)
sb.WriteString(input[i:end])
prev = ' '
i = end
case c == '/' && i+1 < n && input[i+1] == '-':
// KDL slashdash: /- comments out the next node/value. Keep the
// marker but treat what follows as a fresh token start, so a
// slashdashed leading-underscore node (e.g. `/-_FOO "1"`) still
// gets quoted instead of crashing kdl-go.
sb.WriteByte('/')
sb.WriteByte('-')
prev = ' '
i += 2
case c == '_' && isIdentBoundary(prev):
end := scanBareIdent(input, i)
sb.WriteByte('"')
sb.WriteString(input[i:end])
sb.WriteByte('"')
prev = '"'
i = end
default:
sb.WriteByte(c)
prev = c
i++
}
}
return sb.String()
}
// isIdentBoundary reports whether the previously emitted byte ends a token, so
// that a following '_' starts a fresh bare identifier rather than sitting in
// the middle of one.
func isIdentBoundary(prev byte) bool {
switch prev {
case 0, ' ', '\t', '\n', '\r', '{', '}', ';', '=', '(', ')', ',':
return true
}
return false
}
// scanBareIdent returns the index just past the bare identifier starting at
// start, stopping at whitespace or any KDL delimiter.
func scanBareIdent(s string, start int) int {
n := len(s)
for i := start; i < n; i++ {
switch s[i] {
case ' ', '\t', '\n', '\r', '"', '{', '}', '(', ')', ';', '=', ',', '/', '\\', '<', '>', '[', ']':
return i
}
}
return n
}
func findStringEnd(s string, start int) int { func findStringEnd(s string, start int) int {
n := len(s) n := len(s)
for i := start + 1; i < n; { for i := start + 1; i < n; {
@@ -71,6 +71,101 @@ func TestNormalizeKDLBraces(t *testing.T) {
} }
} }
func TestQuoteLeadingUnderscoreIdents(t *testing.T) {
tests := []struct {
name string
in string
out string
}{
{"leading underscore node", `_JAVA_AWT_WM_NONREPARENTING "1"`, `"_JAVA_AWT_WM_NONREPARENTING" "1"`},
{"mid underscore untouched", `XDG_CURRENT_DESKTOP "niri"`, `XDG_CURRENT_DESKTOP "niri"`},
{"indented node", "environment {\n _FOO \"1\"\n}", "environment {\n \"_FOO\" \"1\"\n}"},
{"underscore in string", `spawn "_not_a_node"`, `spawn "_not_a_node"`},
{"underscore in line comment", "// _comment\n_FOO \"1\"", "// _comment\n\"_FOO\" \"1\""},
{"underscore in block comment", "/* _x */ _FOO \"1\"", "/* _x */ \"_FOO\" \"1\""},
{"block comment abuts node", `/* x */_FOO "1"`, `/* x */"_FOO" "1"`},
{"slashdash before node", `/-_FOO "1"`, `/-"_FOO" "1"`},
{"node after closing paren", "node (u8)_v", `node (u8)"_v"`},
{"node before brace without space", "_FOO{ }", `"_FOO"{ }`},
{"lone underscore", `_ "x"`, `"_" "x"`},
{"property value", "node key=_val", `node key="_val"`},
{"no underscores", "node child", "node child"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := quoteLeadingUnderscoreIdents(tc.in)
if got != tc.out {
t.Errorf("quoteLeadingUnderscoreIdents(%q) = %q, want %q", tc.in, got, tc.out)
}
})
}
}
func TestNiriParseLeadingUnderscoreEnvironment(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
// A leading-underscore environment node (a common Java/tiling-WM fix) must
// not abort parsing of the rest of the config — keybinds still have to load.
content := `environment {
XDG_CURRENT_DESKTOP "niri"
_JAVA_AWT_WM_NONREPARENTING "1"
}
binds {
Mod+Q { close-window; }
Mod+KP_Home { focus-workspace 1; }
}
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed on config with leading-underscore env node: %v", err)
}
if len(result.Section.Keybinds) != 2 {
t.Errorf("Expected 2 keybinds, got %d", len(result.Section.Keybinds))
}
foundClose := false
for _, kb := range result.Section.Keybinds {
if kb.Action == "close-window" {
foundClose = true
}
}
if !foundClose {
t.Error("close-window keybind not found — leading-underscore env node broke parsing")
}
}
func TestNiriParseSlashdashLeadingUnderscore(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.kdl")
// A slashdashed leading-underscore node must not abort parsing either.
content := `environment {
/-_JAVA_AWT_WM_NONREPARENTING "1"
}
binds {
Mod+Q { close-window; }
}
`
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
result, err := ParseNiriKeys(tmpDir)
if err != nil {
t.Fatalf("ParseNiriKeys failed on config with slashdashed leading-underscore node: %v", err)
}
if len(result.Section.Keybinds) != 1 {
t.Errorf("Expected 1 keybind, got %d", len(result.Section.Keybinds))
}
}
func TestNiriParseKeyCombo(t *testing.T) { func TestNiriParseKeyCombo(t *testing.T) {
tests := []struct { tests := []struct {
combo string combo string
+2
View File
@@ -125,6 +125,8 @@ State updates are sent whenever network configuration changes:
- `wifiConnected`: Whether associated with an access point - `wifiConnected`: Whether associated with an access point
- `wifiSSID`: Currently connected network name - `wifiSSID`: Currently connected network name
- `wifiIP`: Assigned IP address (empty until DHCP completes) - `wifiIP`: Assigned IP address (empty until DHCP completes)
- `savedWifiNetworks` (API v26+): Saved WiFi profiles exposed at SSID granularity. If a backend has multiple profiles for the same SSID, DMS merges them into one SSID-level entry. Clients talking to older servers should derive saved visible networks from `wifiNetworks` entries where `saved` is true.
- `savedWifiNetworks[].outOfRange` (API v26+): Whether the saved profile is not currently visible in scan results. Fallback entries derived from `wifiNetworks` should be treated as visible (`outOfRange: false`).
- `lastError`: Error message from last failed connection attempt - `lastError`: Error message from last failed connection attempt
### network.credentials Service Events ### network.credentials Service Events
+1
View File
@@ -67,6 +67,7 @@ type BackendState struct {
WiFiBSSID string WiFiBSSID string
WiFiSignal uint8 WiFiSignal uint8
WiFiNetworks []WiFiNetwork WiFiNetworks []WiFiNetwork
SavedWiFiNetworks []WiFiNetwork
WiFiDevices []WiFiDevice WiFiDevices []WiFiDevice
WiredConnections []WiredConnection WiredConnections []WiredConnection
VPNProfiles []VPNProfile VPNProfiles []VPNProfile
@@ -27,6 +27,19 @@ func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) {
wifi.state.WiFiBSSID = "00:11:22:33:44:55" wifi.state.WiFiBSSID = "00:11:22:33:44:55"
wifi.state.WiFiSignal = 75 wifi.state.WiFiSignal = 75
wifi.state.WiFiDevice = "wlan0" wifi.state.WiFiDevice = "wlan0"
wifi.state.SavedWiFiNetworks = []WiFiNetwork{
{
SSID: "TestNetwork",
Saved: true,
Autoconnect: true,
Connected: true,
},
{
SSID: "AwayNetwork",
Saved: true,
OutOfRange: true,
},
}
l3.state.WiFiIP = "192.168.1.100" l3.state.WiFiIP = "192.168.1.100"
l3.state.EthernetConnected = false l3.state.EthernetConnected = false
@@ -42,6 +55,9 @@ func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) {
assert.True(t, state.WiFiConnected) assert.True(t, state.WiFiConnected)
assert.False(t, state.EthernetConnected) assert.False(t, state.EthernetConnected)
assert.Equal(t, StatusWiFi, state.NetworkStatus) assert.Equal(t, StatusWiFi, state.NetworkStatus)
assert.Len(t, state.SavedWiFiNetworks, 2)
assert.Equal(t, "TestNetwork", state.SavedWiFiNetworks[0].SSID)
assert.True(t, state.SavedWiFiNetworks[1].OutOfRange)
} }
func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) { func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) {
@@ -80,6 +80,10 @@ func (b *IWDBackend) Initialize() error {
return fmt.Errorf("failed to discover iwd devices: %w", err) return fmt.Errorf("failed to discover iwd devices: %w", err)
} }
if err := b.updateSavedWiFiNetworks(); err != nil {
log.Warnf("Failed to get initial saved WiFi networks: %v", err)
}
if err := b.updateState(); err != nil { if err := b.updateState(); err != nil {
conn.Close() conn.Close()
return fmt.Errorf("failed to get initial state: %w", err) return fmt.Errorf("failed to get initial state: %w", err)
@@ -145,6 +149,7 @@ func (b *IWDBackend) GetCurrentState() (*BackendState, error) {
state := *b.state state := *b.state
state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...) state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...)
state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
state.WiFiDevices = b.getWiFiDevicesLocked() state.WiFiDevices = b.getWiFiDevicesLocked()
@@ -45,12 +45,42 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
} }
} }
if err := b.conn.AddMatchSignal(
dbus.WithMatchInterface(dbusPropertiesInterface),
dbus.WithMatchMember("PropertiesChanged"),
dbus.WithMatchArg(0, iwdKnownNetworkInterface),
); err != nil {
return fmt.Errorf("failed to add known network signal match: %w", err)
}
if err := b.conn.AddMatchSignal(
dbus.WithMatchInterface(dbusObjectManager),
dbus.WithMatchMember("InterfacesAdded"),
); err != nil {
return fmt.Errorf("failed to add iwd interfaces-added signal match: %w", err)
}
if err := b.conn.AddMatchSignal(
dbus.WithMatchInterface(dbusObjectManager),
dbus.WithMatchMember("InterfacesRemoved"),
); err != nil {
return fmt.Errorf("failed to add iwd interfaces-removed signal match: %w", err)
}
b.sigWG.Add(1) b.sigWG.Add(1)
go b.signalHandler(sigChan) go b.signalHandler(sigChan)
return nil return nil
} }
func (b *IWDBackend) refreshWiFiNetworkState() bool {
_, err := b.updateWiFiNetworks()
if err == nil {
return true
}
return b.updateSavedWiFiNetworks() == nil
}
func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
defer b.sigWG.Done() defer b.sigWG.Done()
@@ -66,11 +96,36 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
return return
} }
if sig.Name != dbusPropertiesInterface+".PropertiesChanged" { if sig.Name == dbusObjectManager+".InterfacesAdded" {
if len(sig.Body) >= 2 {
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
if _, ok := interfaces[iwdKnownNetworkInterface]; ok {
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange()
}
}
}
}
continue continue
} }
if len(sig.Body) < 2 { if sig.Name == dbusObjectManager+".InterfacesRemoved" {
if len(sig.Body) >= 2 {
if interfaces, ok := sig.Body[1].([]string); ok {
for _, iface := range interfaces {
if iface == iwdKnownNetworkInterface {
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
b.onStateChange()
}
break
}
}
}
}
continue
}
if sig.Name != dbusPropertiesInterface+".PropertiesChanged" || len(sig.Body) < 2 {
continue continue
} }
@@ -87,6 +142,9 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
stateChanged := false stateChanged := false
switch iface { switch iface {
case iwdKnownNetworkInterface:
stateChanged = b.refreshWiFiNetworkState()
case iwdDeviceInterface: case iwdDeviceInterface:
if sig.Path == b.devicePath { if sig.Path == b.devicePath {
if poweredVar, ok := changed["Powered"]; ok { if poweredVar, ok := changed["Powered"]; ok {
@@ -105,13 +163,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
if sig.Path == b.stationPath { if sig.Path == b.stationPath {
if scanningVar, ok := changed["Scanning"]; ok { if scanningVar, ok := changed["Scanning"]; ok {
if scanning, ok := scanningVar.Value().(bool); ok && !scanning { if scanning, ok := scanningVar.Value().(bool); ok && !scanning {
networks, err := b.updateWiFiNetworks() stateChanged = b.refreshWiFiNetworkState() || stateChanged
if err == nil {
b.stateMutex.Lock()
b.state.WiFiNetworks = networks
b.stateMutex.Unlock()
stateChanged = true
}
b.stateMutex.RLock() b.stateMutex.RLock()
wifiConnected := b.state.WiFiConnected wifiConnected := b.state.WiFiConnected
@@ -236,6 +288,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
} }
} }
b.refreshWiFiNetworkState()
stateChanged = true stateChanged = true
if att != nil && isTarget { if att != nil && isTarget {
@@ -282,6 +335,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
b.state.NetworkStatus = StatusDisconnected b.state.NetworkStatus = StatusDisconnected
} }
b.stateMutex.Unlock() b.stateMutex.Unlock()
b.refreshWiFiNetworkState()
stateChanged = true stateChanged = true
} }
} }
@@ -342,6 +396,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
stateChanged = true stateChanged = true
} }
b.stateMutex.Unlock() b.stateMutex.Unlock()
b.refreshWiFiNetworkState()
} }
} }
} }
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/godbus/dbus/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -168,6 +169,92 @@ func TestIWDBackend_MapIwdDBusError(t *testing.T) {
} }
} }
func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) {
objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
"/net/connman/iwd/known_network/1": {
iwdKnownNetworkInterface: {
"Name": dbus.MakeVariant("Home"),
"AutoConnect": dbus.MakeVariant(false),
"Hidden": dbus.MakeVariant(true),
"Type": dbus.MakeVariant("psk"),
},
},
"/net/connman/iwd/known_network/2": {
iwdKnownNetworkInterface: {
"Name": dbus.MakeVariant("Office"),
"Type": dbus.MakeVariant("8021x"),
},
},
"/net/connman/iwd/known_network/3": {
iwdKnownNetworkInterface: {
"Name": dbus.MakeVariant("Cafe"),
"Type": dbus.MakeVariant("open"),
},
},
"/net/connman/iwd/network/1": {
iwdNetworkInterface: {
"Name": dbus.MakeVariant("VisibleOnly"),
},
},
}
profiles := iwdSavedWiFiProfilesFromManagedObjects(objects)
assert.Len(t, profiles, 3)
assert.False(t, profiles["Home"].Autoconnect)
assert.True(t, profiles["Home"].Hidden)
assert.True(t, profiles["Home"].Secured)
assert.False(t, profiles["Home"].Enterprise)
assert.True(t, profiles["Office"].Autoconnect)
assert.True(t, profiles["Office"].Secured)
assert.True(t, profiles["Office"].Enterprise)
assert.True(t, profiles["Cafe"].Autoconnect)
assert.False(t, profiles["Cafe"].Secured)
assert.False(t, profiles["Cafe"].Enterprise)
}
func TestIWDWiFiNetworksFromVisibleIncludesConnectedHiddenFallback(t *testing.T) {
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Secured: true,
Hidden: true,
Mode: "infrastructure",
},
}
visible := []WiFiNetwork{
{
SSID: "Cafe",
Signal: 42,
Secured: false,
},
}
networks := iwdWiFiNetworksFromVisible(visible, profiles, "Home", true, 68)
savedNetworks := savedWiFiNetworksFromProfiles(profiles, map[string]WiFiNetwork{
networks[0].SSID: networks[0],
networks[1].SSID: networks[1],
}, "Home", true)
assert.Len(t, networks, 2)
assert.Equal(t, "Cafe", networks[0].SSID)
assert.False(t, networks[0].Connected)
assert.Equal(t, "Home", networks[1].SSID)
assert.True(t, networks[1].Connected)
assert.True(t, networks[1].Hidden)
assert.True(t, networks[1].Saved)
assert.True(t, networks[1].Autoconnect)
assert.Equal(t, uint8(68), networks[1].Signal)
assert.Len(t, savedNetworks, 1)
assert.Equal(t, "Home", savedNetworks[0].SSID)
assert.True(t, savedNetworks[0].Connected)
assert.False(t, savedNetworks[0].OutOfRange)
}
func TestConnectAttempt_Finalization(t *testing.T) { func TestConnectAttempt_Finalization(t *testing.T) {
backend, _ := NewIWDBackend() backend, _ := NewIWDBackend()
backend.state = &BackendState{} backend.state = &BackendState{}
+138 -55
View File
@@ -164,22 +164,18 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
return nil, fmt.Errorf("failed to get networks: %w", err) return nil, fmt.Errorf("failed to get networks: %w", err)
} }
knownNetworks, err := b.getKnownNetworks() savedProfiles, err := b.getIWDSavedWiFiProfiles()
if err != nil { if err != nil {
knownNetworks = make(map[string]bool) savedProfiles = make(map[string]savedWiFiProfile)
}
autoconnectMap, err := b.getAutoconnectSettings()
if err != nil {
autoconnectMap = make(map[string]bool)
} }
b.stateMutex.RLock() b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID currentSSID := b.state.WiFiSSID
wifiConnected := b.state.WiFiConnected wifiConnected := b.state.WiFiConnected
wifiSignal := b.state.WiFiSignal
b.stateMutex.RUnlock() b.stateMutex.RUnlock()
networks := make([]WiFiNetwork, 0, len(orderedNetworks)) visibleNetworks := make([]WiFiNetwork, 0, len(orderedNetworks))
for _, netData := range orderedNetworks { for _, netData := range orderedNetworks {
if len(netData) < 2 { if len(netData) < 2 {
continue continue
@@ -225,23 +221,26 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
secured := netType != "open" secured := netType != "open"
network := WiFiNetwork{ visibleNetworks = append(visibleNetworks, WiFiNetwork{
SSID: name, SSID: name,
Signal: signal, Signal: signal,
Secured: secured, Secured: secured,
Connected: wifiConnected && name == currentSSID, Enterprise: netType == "8021x",
Saved: knownNetworks[name], })
Autoconnect: autoconnectMap[name],
Enterprise: netType == "8021x",
}
networks = append(networks, network)
} }
networks := iwdWiFiNetworksFromVisible(visibleNetworks, savedProfiles, currentSSID, wifiConnected, wifiSignal)
visibleNetworkMap := make(map[string]WiFiNetwork, len(networks))
for _, network := range networks {
visibleNetworkMap[network.SSID] = network
}
savedNetworks := savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworkMap, currentSSID, wifiConnected)
sortWiFiNetworks(networks) sortWiFiNetworks(networks)
b.stateMutex.Lock() b.stateMutex.Lock()
b.state.WiFiNetworks = networks b.state.WiFiNetworks = networks
b.state.SavedWiFiNetworks = savedNetworks
b.stateMutex.Unlock() b.stateMutex.Unlock()
now := time.Now() now := time.Now()
@@ -254,30 +253,129 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
return networks, nil return networks, nil
} }
func (b *IWDBackend) getKnownNetworks() (map[string]bool, error) { func (b *IWDBackend) updateSavedWiFiNetworks() error {
obj := b.conn.Object(iwdBusName, iwdObjectPath) savedProfiles, err := b.getIWDSavedWiFiProfiles()
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
if err != nil { if err != nil {
return nil, err return err
} }
known := make(map[string]bool) b.stateMutex.RLock()
for _, interfaces := range objects { currentSSID := b.state.WiFiSSID
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok { wifiConnected := b.state.WiFiConnected
if nameVar, ok := knownProps["Name"]; ok { wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
if name, ok := nameVar.Value().(string); ok { b.stateMutex.RUnlock()
known[name] = true
}
}
}
}
return known, nil wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, savedProfiles, currentSSID, wifiConnected)
b.stateMutex.Lock()
b.state.WiFiNetworks = wifiNetworks
b.state.SavedWiFiNetworks = savedNetworks
b.stateMutex.Unlock()
return nil
} }
func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) { func iwdWiFiNetworksFromVisible(visibleNetworks []WiFiNetwork, savedProfiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool, wifiSignal uint8) []WiFiNetwork {
networks := make([]WiFiNetwork, 0, len(visibleNetworks)+1)
seenSSIDs := make(map[string]struct{}, len(visibleNetworks)+1)
for _, network := range visibleNetworks {
profile, saved := savedProfiles[network.SSID]
network.Connected = wifiConnected && network.SSID == currentSSID
network.Saved = saved
network.Autoconnect = profile.Autoconnect
network.Hidden = network.Hidden || profile.Hidden
network.Secured = network.Secured || profile.Secured
network.Enterprise = network.Enterprise || profile.Enterprise
if network.Mode == "" {
network.Mode = profile.Mode
}
networks = append(networks, network)
seenSSIDs[network.SSID] = struct{}{}
}
if wifiConnected && currentSSID != "" {
if _, exists := seenSSIDs[currentSSID]; !exists {
profile, saved := savedProfiles[currentSSID]
secured := profile.Secured
if !saved {
secured = true
}
mode := profile.Mode
if mode == "" {
mode = "infrastructure"
}
networks = append(networks, WiFiNetwork{
SSID: currentSSID,
Signal: wifiSignal,
Secured: secured,
Enterprise: profile.Enterprise,
Connected: true,
Saved: saved,
Autoconnect: profile.Autoconnect,
Hidden: true,
Mode: mode,
})
}
}
return networks
}
func iwdSavedWiFiProfilesFromManagedObjects(objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant) map[string]savedWiFiProfile {
profiles := make(map[string]savedWiFiProfile)
for _, interfaces := range objects {
knownProps, ok := interfaces[iwdKnownNetworkInterface]
if !ok {
continue
}
nameVar, ok := knownProps["Name"]
if !ok {
continue
}
name, ok := nameVar.Value().(string)
if !ok || name == "" {
continue
}
profile := savedWiFiProfile{
Autoconnect: true,
Mode: "infrastructure",
}
if acVar, ok := knownProps["AutoConnect"]; ok {
if autoconnect, ok := acVar.Value().(bool); ok {
profile.Autoconnect = autoconnect
}
}
if hiddenVar, ok := knownProps["Hidden"]; ok {
if hidden, ok := hiddenVar.Value().(bool); ok {
profile.Hidden = hidden
}
}
if typeVar, ok := knownProps["Type"]; ok {
if networkType, ok := typeVar.Value().(string); ok {
profile.Secured = networkType != "" && networkType != "open"
profile.Enterprise = networkType == "8021x"
}
}
if existing, ok := profiles[name]; ok {
profile.Autoconnect = profile.Autoconnect || existing.Autoconnect
profile.Hidden = profile.Hidden || existing.Hidden
profile.Secured = profile.Secured || existing.Secured
profile.Enterprise = profile.Enterprise || existing.Enterprise
}
profiles[name] = profile
}
return profiles
}
func (b *IWDBackend) getIWDSavedWiFiProfiles() (map[string]savedWiFiProfile, error) {
obj := b.conn.Object(iwdBusName, iwdObjectPath) obj := b.conn.Object(iwdBusName, iwdObjectPath)
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
@@ -286,24 +384,7 @@ func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) {
return nil, err return nil, err
} }
autoconnectMap := make(map[string]bool) return iwdSavedWiFiProfilesFromManagedObjects(objects), nil
for _, interfaces := range objects {
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
if nameVar, ok := knownProps["Name"]; ok {
if name, ok := nameVar.Value().(string); ok {
autoconnect := true
if acVar, ok := knownProps["AutoConnect"]; ok {
if ac, ok := acVar.Value().(bool); ok {
autoconnect = ac
}
}
autoconnectMap[name] = autoconnect
}
}
}
}
return autoconnectMap, nil
} }
func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) { func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
@@ -614,6 +695,8 @@ func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
b.stateMutex.Unlock() b.stateMutex.Unlock()
} }
_, _ = b.updateWiFiNetworks()
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
} }
@@ -222,6 +222,10 @@ func (b *NetworkManagerBackend) Initialize() error {
log.Warnf("Failed to update WiFi state: %v", err) log.Warnf("Failed to update WiFi state: %v", err)
} }
if err := b.updateSavedWiFiNetworks(); err != nil {
log.Warnf("Failed to get initial saved WiFi networks: %v", err)
}
if wifiEnabled { if wifiEnabled {
if _, err := b.updateWiFiNetworks(); err != nil { if _, err := b.updateWiFiNetworks(); err != nil {
log.Warnf("Failed to get initial networks: %v", err) log.Warnf("Failed to get initial networks: %v", err)
@@ -261,6 +265,7 @@ func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) {
state := *b.state state := *b.state
state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...) state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...)
state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...) state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...)
state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...) state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...)
@@ -5,6 +5,12 @@ import (
"github.com/godbus/dbus/v5" "github.com/godbus/dbus/v5"
) )
const (
dbusNMSettingsPath = "/org/freedesktop/NetworkManager/Settings"
dbusNMSettingsInterface = "org.freedesktop.NetworkManager.Settings"
dbusNMSettingsConnectionInterface = "org.freedesktop.NetworkManager.Settings.Connection"
)
func (b *NetworkManagerBackend) startSignalPump() error { func (b *NetworkManagerBackend) startSignalPump() error {
conn, err := dbus.ConnectSystemBus() conn, err := dbus.ConnectSystemBus()
if err != nil { if err != nil {
@@ -27,8 +33,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
} }
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("NewConnection"), dbus.WithMatchMember("NewConnection"),
); err != nil { ); err != nil {
conn.RemoveMatchSignal( conn.RemoveMatchSignal(
@@ -42,8 +48,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
} }
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("ConnectionRemoved"), dbus.WithMatchMember("ConnectionRemoved"),
); err != nil { ); err != nil {
conn.RemoveMatchSignal( conn.RemoveMatchSignal(
@@ -52,8 +58,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
conn.RemoveMatchSignal( conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("NewConnection"), dbus.WithMatchMember("NewConnection"),
) )
conn.RemoveSignal(signals) conn.RemoveSignal(signals)
@@ -61,6 +67,31 @@ func (b *NetworkManagerBackend) startSignalPump() error {
return err return err
} }
if err := conn.AddMatchSignal(
dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsConnectionInterface),
dbus.WithMatchMember("Updated"),
); err != nil {
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusPropsInterface),
dbus.WithMatchMember("PropertiesChanged"),
)
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("NewConnection"),
)
conn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("ConnectionRemoved"),
)
conn.RemoveSignal(signals)
conn.Close()
return err
}
if err := conn.AddMatchSignal( if err := conn.AddMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusNMInterface), dbus.WithMatchInterface(dbusNMInterface),
@@ -137,6 +168,32 @@ func (b *NetworkManagerBackend) stopSignalPump() {
dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchMember("PropertiesChanged"),
) )
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("NewConnection"),
)
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsInterface),
dbus.WithMatchMember("ConnectionRemoved"),
)
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMSettingsPath)),
dbus.WithMatchInterface(dbusNMSettingsConnectionInterface),
dbus.WithMatchMember("Updated"),
)
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusNMInterface),
dbus.WithMatchMember("DeviceAdded"),
)
b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
dbus.WithMatchInterface(dbusNMInterface),
dbus.WithMatchMember("DeviceRemoved"),
)
for _, info := range b.wifiDevices { for _, info := range b.wifiDevices {
b.dbusConn.RemoveMatchSignal( b.dbusConn.RemoveMatchSignal(
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
@@ -164,9 +221,13 @@ func (b *NetworkManagerBackend) stopSignalPump() {
} }
func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) { func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
if sig.Name == "org.freedesktop.NetworkManager.Settings.NewConnection" || if sig.Name == dbusNMSettingsInterface+".NewConnection" ||
sig.Name == "org.freedesktop.NetworkManager.Settings.ConnectionRemoved" { sig.Name == dbusNMSettingsInterface+".ConnectionRemoved" ||
sig.Name == dbusNMSettingsConnectionInterface+".Updated" {
b.ListVPNProfiles() b.ListVPNProfiles()
if err := b.updateSavedWiFiNetworks(); err != nil {
b.updateWiFiNetworks()
}
if b.onStateChange != nil { if b.onStateChange != nil {
b.onStateChange() b.onStateChange()
} }
@@ -225,24 +225,14 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error
return "", fmt.Errorf("failed to identify security type of network `%s`", ssid) return "", fmt.Errorf("failed to identify security type of network `%s`", ssid)
} }
var securityType string
switch keyMgmt { switch keyMgmt {
case "none": case "none":
authAlg, _ := secSettings["auth-alg"].(string) return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` is open or WEP", ssid)
switch authAlg {
case "open":
securityType = "nopass"
default:
securityType = "WEP"
}
case "ieee8021x": case "ieee8021x":
securityType = "WEP" return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` is enterprise", ssid)
case "wpa-psk", "sae", "wpa-psk-sae":
default: default:
securityType = "WPA" return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` uses %s", ssid, keyMgmt)
}
if securityType != "WPA" {
return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType)
} }
var psk string var psk string
@@ -276,7 +266,7 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid) return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
} }
return FormatWiFiQRString(securityType, ssid, psk), nil return FormatWiFiQRString("WPA", ssid, psk), nil
} }
func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error { func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
@@ -405,6 +395,74 @@ func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
return nil return nil
} }
func getSavedWiFiProfiles(connections []gonetworkmanager.Connection) map[string]savedWiFiProfile {
profiles := make(map[string]savedWiFiProfile)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := connSettings["connection"]
if !ok {
continue
}
connType, ok := connMeta["type"].(string)
if !ok || connType != "802-11-wireless" {
continue
}
wifiSettings, ok := connSettings["802-11-wireless"]
if !ok {
continue
}
ssidBytes, ok := wifiSettings["ssid"].([]byte)
if !ok || len(ssidBytes) == 0 {
continue
}
ssid := string(ssidBytes)
profile := savedWiFiProfile{
Autoconnect: true,
Mode: "infrastructure",
}
if ac, ok := connMeta["autoconnect"].(bool); ok {
profile.Autoconnect = ac
}
if hidden, ok := wifiSettings["hidden"].(bool); ok {
profile.Hidden = hidden
}
if mode, ok := wifiSettings["mode"].(string); ok && mode != "" {
profile.Mode = mode
}
if _, ok := connSettings["802-11-wireless-security"]; ok {
profile.Secured = true
}
if _, ok := connSettings["802-1x"]; ok {
profile.Enterprise = true
profile.Secured = true
}
if existing, ok := profiles[ssid]; ok {
profile.Autoconnect = profile.Autoconnect || existing.Autoconnect
profile.Hidden = profile.Hidden || existing.Hidden
profile.Secured = profile.Secured || existing.Secured
profile.Enterprise = profile.Enterprise || existing.Enterprise
if profile.Mode == "" {
profile.Mode = existing.Mode
}
}
profiles[ssid] = profile
}
return profiles
}
func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool { func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool {
b.stateMutex.RLock() b.stateMutex.RLock()
defer b.stateMutex.RUnlock() defer b.stateMutex.RUnlock()
@@ -442,47 +500,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
return nil, fmt.Errorf("failed to get connections: %w", err) return nil, fmt.Errorf("failed to get connections: %w", err)
} }
savedSSIDs := make(map[string]bool) savedProfiles := getSavedWiFiProfiles(connections)
autoconnectMap := make(map[string]bool)
hiddenSSIDs := make(map[string]bool)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := connSettings["connection"]
if !ok {
continue
}
connType, ok := connMeta["type"].(string)
if !ok || connType != "802-11-wireless" {
continue
}
wifiSettings, ok := connSettings["802-11-wireless"]
if !ok {
continue
}
ssidBytes, ok := wifiSettings["ssid"].([]byte)
if !ok {
continue
}
ssid := string(ssidBytes)
savedSSIDs[ssid] = true
autoconnect := true
if ac, ok := connMeta["autoconnect"].(bool); ok {
autoconnect = ac
}
autoconnectMap[ssid] = autoconnect
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
hiddenSSIDs[ssid] = true
}
}
b.stateMutex.RLock() b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID currentSSID := b.state.WiFiSSID
@@ -491,8 +509,8 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
wifiBSSID := b.state.WiFiBSSID wifiBSSID := b.state.WiFiBSSID
b.stateMutex.RUnlock() b.stateMutex.RUnlock()
seenSSIDs := make(map[string]*WiFiNetwork) seenSSIDs := make(map[string]int)
networks := []WiFiNetwork{} networks := make([]WiFiNetwork, 0, len(apPaths)+1)
for _, ap := range apPaths { for _, ap := range apPaths {
ssid, err := ap.GetPropertySSID() ssid, err := ap.GetPropertySSID()
@@ -500,7 +518,8 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
continue continue
} }
if existing, exists := seenSSIDs[ssid]; exists { if existingIndex, exists := seenSSIDs[ssid]; exists {
existing := &networks[existingIndex]
strength, _ := ap.GetPropertyStrength() strength, _ := ap.GetPropertyStrength()
if strength > existing.Signal { if strength > existing.Signal {
existing.Signal = strength existing.Signal = strength
@@ -550,6 +569,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
} }
} }
profile, saved := savedProfiles[ssid]
network := WiFiNetwork{ network := WiFiNetwork{
SSID: ssid, SSID: ssid,
BSSID: bssid, BSSID: bssid,
@@ -557,45 +577,86 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: isConnected, Connected: isConnected,
Saved: savedSSIDs[ssid], Saved: saved,
Autoconnect: autoconnectMap[ssid], Autoconnect: profile.Autoconnect,
Hidden: hiddenSSIDs[ssid], Hidden: profile.Hidden,
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: rate, Rate: rate,
Channel: channel, Channel: channel,
} }
seenSSIDs[ssid] = &network
networks = append(networks, network) networks = append(networks, network)
seenSSIDs[ssid] = len(networks) - 1
} }
if wifiConnected && currentSSID != "" { if wifiConnected && currentSSID != "" {
if _, exists := seenSSIDs[currentSSID]; !exists { if _, exists := seenSSIDs[currentSSID]; !exists {
profile, saved := savedProfiles[currentSSID]
hiddenNetwork := WiFiNetwork{ hiddenNetwork := WiFiNetwork{
SSID: currentSSID, SSID: currentSSID,
BSSID: wifiBSSID, BSSID: wifiBSSID,
Signal: wifiSignal, Signal: wifiSignal,
Secured: true, Secured: true,
Connected: true, Connected: true,
Saved: savedSSIDs[currentSSID], Saved: saved,
Autoconnect: autoconnectMap[currentSSID], Autoconnect: profile.Autoconnect,
Hidden: true, Hidden: true,
Mode: "infrastructure", Mode: "infrastructure",
} }
networks = append(networks, hiddenNetwork) networks = append(networks, hiddenNetwork)
seenSSIDs[currentSSID] = len(networks) - 1
} }
} }
visibleNetworks := wiFiNetworksBySSID(networks, true)
savedNetworks := savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworks, currentSSID, wifiConnected)
sortWiFiNetworks(networks) sortWiFiNetworks(networks)
b.stateMutex.Lock() b.stateMutex.Lock()
b.state.WiFiNetworks = networks b.state.WiFiNetworks = networks
b.state.SavedWiFiNetworks = savedNetworks
b.stateMutex.Unlock() b.stateMutex.Unlock()
return networks, nil return networks, nil
} }
func (b *NetworkManagerBackend) updateSavedWiFiNetworks() error {
s := b.settings
if s == nil {
var err error
s, err = gonetworkmanager.NewSettings()
if err != nil {
return fmt.Errorf("failed to get settings: %w", err)
}
b.settings = s
}
settingsMgr := s.(gonetworkmanager.Settings)
connections, err := settingsMgr.ListConnections()
if err != nil {
return fmt.Errorf("failed to get connections: %w", err)
}
savedProfiles := getSavedWiFiProfiles(connections)
b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID
wifiConnected := b.state.WiFiConnected
wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
b.stateMutex.RUnlock()
wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, savedProfiles, currentSSID, wifiConnected)
b.stateMutex.Lock()
b.state.WiFiNetworks = wifiNetworks
b.state.SavedWiFiNetworks = savedNetworks
b.stateMutex.Unlock()
return nil
}
func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) { func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) {
s := b.settings s := b.settings
if s == nil { if s == nil {
@@ -975,49 +1036,14 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
return return
} }
savedSSIDs := make(map[string]bool) savedProfiles := getSavedWiFiProfiles(connections)
autoconnectMap := make(map[string]bool)
hiddenSSIDs := make(map[string]bool)
for _, conn := range connections {
connSettings, err := conn.GetSettings()
if err != nil {
continue
}
connMeta, ok := connSettings["connection"]
if !ok {
continue
}
connType, ok := connMeta["type"].(string)
if !ok || connType != "802-11-wireless" {
continue
}
wifiSettings, ok := connSettings["802-11-wireless"]
if !ok {
continue
}
ssidBytes, ok := wifiSettings["ssid"].([]byte)
if !ok {
continue
}
ssid := string(ssidBytes)
savedSSIDs[ssid] = true
autoconnect := true
if ac, ok := connMeta["autoconnect"].(bool); ok {
autoconnect = ac
}
autoconnectMap[ssid] = autoconnect
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
hiddenSSIDs[ssid] = true
}
}
var devices []WiFiDevice var devices []WiFiDevice
visibleNetworks := make(map[string]WiFiNetwork)
b.stateMutex.RLock()
currentSSID := b.state.WiFiSSID
wifiConnected := b.state.WiFiConnected
b.stateMutex.RUnlock()
for name, devInfo := range b.wifiDevices { for name, devInfo := range b.wifiDevices {
state, _ := devInfo.device.GetPropertyState() state, _ := devInfo.device.GetPropertyState()
@@ -1050,14 +1076,16 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
apPaths, err := devInfo.wireless.GetAccessPoints() apPaths, err := devInfo.wireless.GetAccessPoints()
var networks []WiFiNetwork var networks []WiFiNetwork
if err == nil { if err == nil {
seenSSIDs := make(map[string]*WiFiNetwork) seenSSIDs := make(map[string]int)
networks = make([]WiFiNetwork, 0, len(apPaths)+1)
for _, ap := range apPaths { for _, ap := range apPaths {
apSSID, err := ap.GetPropertySSID() apSSID, err := ap.GetPropertySSID()
if err != nil || apSSID == "" { if err != nil || apSSID == "" {
continue continue
} }
if existing, exists := seenSSIDs[apSSID]; exists { if existingIndex, exists := seenSSIDs[apSSID]; exists {
existing := &networks[existingIndex]
strength, _ := ap.GetPropertyStrength() strength, _ := ap.GetPropertyStrength()
if strength > existing.Signal { if strength > existing.Signal {
existing.Signal = strength existing.Signal = strength
@@ -1107,6 +1135,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
} }
} }
profile, saved := savedProfiles[apSSID]
network := WiFiNetwork{ network := WiFiNetwork{
SSID: apSSID, SSID: apSSID,
BSSID: apBSSID, BSSID: apBSSID,
@@ -1114,9 +1143,9 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
Secured: secured, Secured: secured,
Enterprise: enterprise, Enterprise: enterprise,
Connected: isConnected, Connected: isConnected,
Saved: savedSSIDs[apSSID], Saved: saved,
Autoconnect: autoconnectMap[apSSID], Autoconnect: profile.Autoconnect,
Hidden: hiddenSSIDs[apSSID], Hidden: profile.Hidden,
Frequency: freq, Frequency: freq,
Mode: modeStr, Mode: modeStr,
Rate: rate, Rate: rate,
@@ -1124,25 +1153,31 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
Device: name, Device: name,
} }
seenSSIDs[apSSID] = &network
networks = append(networks, network) networks = append(networks, network)
seenSSIDs[apSSID] = len(networks) - 1
if existing, ok := visibleNetworks[apSSID]; !ok || network.Signal > existing.Signal {
visibleNetworks[apSSID] = network
}
} }
if connected && ssid != "" { if connected && ssid != "" {
if _, exists := seenSSIDs[ssid]; !exists { if _, exists := seenSSIDs[ssid]; !exists {
profile, saved := savedProfiles[ssid]
hiddenNetwork := WiFiNetwork{ hiddenNetwork := WiFiNetwork{
SSID: ssid, SSID: ssid,
BSSID: bssid, BSSID: bssid,
Signal: signal, Signal: signal,
Secured: true, Secured: true,
Connected: true, Connected: true,
Saved: savedSSIDs[ssid], Saved: saved,
Autoconnect: autoconnectMap[ssid], Autoconnect: profile.Autoconnect,
Hidden: true, Hidden: true,
Mode: "infrastructure", Mode: "infrastructure",
Device: name, Device: name,
} }
networks = append(networks, hiddenNetwork) networks = append(networks, hiddenNetwork)
seenSSIDs[ssid] = len(networks) - 1
visibleNetworks[ssid] = hiddenNetwork
} }
} }
@@ -1168,6 +1203,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
b.stateMutex.Lock() b.stateMutex.Lock()
b.state.WiFiDevices = devices b.state.WiFiDevices = devices
b.state.SavedWiFiNetworks = savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworks, currentSSID, wifiConnected)
b.stateMutex.Unlock() b.stateMutex.Unlock()
} }
@@ -4,6 +4,7 @@ import (
"testing" "testing"
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
"github.com/Wifx/gonetworkmanager/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -176,6 +177,54 @@ func TestNetworkManagerBackend_UpdateWiFiNetworks_NoDevice(t *testing.T) {
assert.Contains(t, err.Error(), "no WiFi device available") assert.Contains(t, err.Error(), "no WiFi device available")
} }
func TestNetworkManagerBackend_UpdateSavedWiFiNetworksPreservesVisibleSavedNetworks(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
mockConn := mock_gonetworkmanager.NewMockConnection(t)
backend, err := NewNetworkManagerBackend(mockNM)
assert.NoError(t, err)
backend.settings = mockSettings
backend.stateMutex.Lock()
backend.state.WiFiNetworks = []WiFiNetwork{
{
SSID: "Home",
Signal: 76,
},
}
backend.stateMutex.Unlock()
settings := gonetworkmanager.ConnectionSettings{
"connection": {
"type": "802-11-wireless",
"autoconnect": true,
},
"802-11-wireless": {
"ssid": []byte("Home"),
},
"802-11-wireless-security": {},
}
mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil)
mockConn.EXPECT().GetSettings().Return(settings, nil)
err = backend.updateSavedWiFiNetworks()
assert.NoError(t, err)
backend.stateMutex.RLock()
savedNetworks := append([]WiFiNetwork(nil), backend.state.SavedWiFiNetworks...)
wifiNetworks := append([]WiFiNetwork(nil), backend.state.WiFiNetworks...)
backend.stateMutex.RUnlock()
assert.Len(t, wifiNetworks, 1)
assert.True(t, wifiNetworks[0].Saved)
assert.Len(t, savedNetworks, 1)
assert.Equal(t, "Home", savedNetworks[0].SSID)
assert.True(t, savedNetworks[0].Saved)
assert.False(t, savedNetworks[0].OutOfRange)
assert.Equal(t, uint8(76), savedNetworks[0].Signal)
}
func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) { func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) {
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
+26 -3
View File
@@ -64,9 +64,10 @@ func NewManager() (*Manager, error) {
m := &Manager{ m := &Manager{
backend: backend, backend: backend,
state: &NetworkState{ state: &NetworkState{
NetworkStatus: StatusDisconnected, NetworkStatus: StatusDisconnected,
Preference: PreferenceAuto, Preference: PreferenceAuto,
WiFiNetworks: []WiFiNetwork{}, WiFiNetworks: []WiFiNetwork{},
SavedWiFiNetworks: []WiFiNetwork{},
}, },
stateMutex: sync.RWMutex{}, stateMutex: sync.RWMutex{},
@@ -120,6 +121,7 @@ func (m *Manager) syncStateFromBackend() error {
m.state.WiFiBSSID = backendState.WiFiBSSID m.state.WiFiBSSID = backendState.WiFiBSSID
m.state.WiFiSignal = backendState.WiFiSignal m.state.WiFiSignal = backendState.WiFiSignal
m.state.WiFiNetworks = backendState.WiFiNetworks m.state.WiFiNetworks = backendState.WiFiNetworks
m.state.SavedWiFiNetworks = backendState.SavedWiFiNetworks
m.state.WiFiDevices = backendState.WiFiDevices m.state.WiFiDevices = backendState.WiFiDevices
m.state.WiredConnections = backendState.WiredConnections m.state.WiredConnections = backendState.WiredConnections
m.state.VPNProfiles = backendState.VPNProfiles m.state.VPNProfiles = backendState.VPNProfiles
@@ -156,6 +158,7 @@ func (m *Manager) snapshotState() NetworkState {
defer m.stateMutex.RUnlock() defer m.stateMutex.RUnlock()
s := *m.state s := *m.state
s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...) s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...)
s.SavedWiFiNetworks = append([]WiFiNetwork(nil), m.state.SavedWiFiNetworks...)
s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...) s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...)
s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...) s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...)
s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...) s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...)
@@ -211,6 +214,9 @@ func stateChangedMeaningfully(old, new *NetworkState) bool {
if len(old.WiFiNetworks) != len(new.WiFiNetworks) { if len(old.WiFiNetworks) != len(new.WiFiNetworks) {
return true return true
} }
if len(old.SavedWiFiNetworks) != len(new.SavedWiFiNetworks) {
return true
}
if len(old.WiFiDevices) != len(new.WiFiDevices) { if len(old.WiFiDevices) != len(new.WiFiDevices) {
return true return true
} }
@@ -238,6 +244,23 @@ func stateChangedMeaningfully(old, new *NetworkState) bool {
} }
} }
for i := range old.SavedWiFiNetworks {
oldNet := &old.SavedWiFiNetworks[i]
newNet := &new.SavedWiFiNetworks[i]
if oldNet.SSID != newNet.SSID {
return true
}
if oldNet.Connected != newNet.Connected {
return true
}
if oldNet.Autoconnect != newNet.Autoconnect {
return true
}
if oldNet.OutOfRange != newNet.OutOfRange {
return true
}
}
for i := range old.WiredConnections { for i := range old.WiredConnections {
oldNet := &old.WiredConnections[i] oldNet := &old.WiredConnections[i]
newNet := &new.WiredConnections[i] newNet := &new.WiredConnections[i]
+2
View File
@@ -34,6 +34,7 @@ type WiFiNetwork struct {
Saved bool `json:"saved"` Saved bool `json:"saved"`
Autoconnect bool `json:"autoconnect"` Autoconnect bool `json:"autoconnect"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
OutOfRange bool `json:"outOfRange"`
Frequency uint32 `json:"frequency"` Frequency uint32 `json:"frequency"`
Mode string `json:"mode"` Mode string `json:"mode"`
Rate uint32 `json:"rate"` Rate uint32 `json:"rate"`
@@ -111,6 +112,7 @@ type NetworkState struct {
WiFiBSSID string `json:"wifiBSSID"` WiFiBSSID string `json:"wifiBSSID"`
WiFiSignal uint8 `json:"wifiSignal"` WiFiSignal uint8 `json:"wifiSignal"`
WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` WiFiNetworks []WiFiNetwork `json:"wifiNetworks"`
SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"`
WiFiDevices []WiFiDevice `json:"wifiDevices"` WiFiDevices []WiFiDevice `json:"wifiDevices"`
WiredConnections []WiredConnection `json:"wiredConnections"` WiredConnections []WiredConnection `json:"wiredConnections"`
VPNProfiles []VPNProfile `json:"vpnProfiles"` VPNProfiles []VPNProfile `json:"vpnProfiles"`
+103
View File
@@ -0,0 +1,103 @@
package network
import "sort"
type savedWiFiProfile struct {
Autoconnect bool
Hidden bool
Secured bool
Enterprise bool
Mode string
}
// Saved WiFi state is keyed by SSID because the UI/API accepts SSID actions.
// Multiple backend profiles for the same SSID are intentionally collapsed here.
func mergeSavedProfilesIntoWiFiNetworks(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool) []WiFiNetwork {
merged := make([]WiFiNetwork, len(networks))
for i, network := range networks {
profile, saved := profiles[network.SSID]
network.Connected = wifiConnected && network.SSID == currentSSID
network.Saved = saved
if saved {
network.Autoconnect = profile.Autoconnect
network.Hidden = network.Hidden || profile.Hidden
network.Secured = network.Secured || profile.Secured
network.Enterprise = network.Enterprise || profile.Enterprise
if network.Mode == "" {
network.Mode = profile.Mode
}
} else {
network.Autoconnect = false
}
merged[i] = network
}
return merged
}
func wiFiNetworksBySSID(networks []WiFiNetwork, visibleOnly bool) map[string]WiFiNetwork {
visible := make(map[string]WiFiNetwork, len(networks))
for _, network := range networks {
if visibleOnly && network.OutOfRange {
continue
}
visible[network.SSID] = network
}
return visible
}
func refreshSavedWiFiState(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool) ([]WiFiNetwork, []WiFiNetwork) {
mergedNetworks := mergeSavedProfilesIntoWiFiNetworks(networks, profiles, currentSSID, wifiConnected)
visibleNetworks := wiFiNetworksBySSID(mergedNetworks, true)
savedNetworks := savedWiFiNetworksFromProfiles(profiles, visibleNetworks, currentSSID, wifiConnected)
return mergedNetworks, savedNetworks
}
func savedWiFiNetworksFromProfiles(profiles map[string]savedWiFiProfile, visible map[string]WiFiNetwork, currentSSID string, wifiConnected bool) []WiFiNetwork {
networks := make([]WiFiNetwork, 0, len(profiles))
for ssid, profile := range profiles {
if network, ok := visible[ssid]; ok {
network.Saved = true
network.Autoconnect = profile.Autoconnect
network.Hidden = network.Hidden || profile.Hidden
network.Secured = network.Secured || profile.Secured
network.Enterprise = network.Enterprise || profile.Enterprise
network.OutOfRange = false
if network.Mode == "" {
network.Mode = profile.Mode
}
networks = append(networks, network)
continue
}
isConnected := wifiConnected && ssid == currentSSID
networks = append(networks, WiFiNetwork{
SSID: ssid,
Secured: profile.Secured,
Enterprise: profile.Enterprise,
Connected: isConnected,
Saved: true,
Autoconnect: profile.Autoconnect,
Hidden: profile.Hidden,
OutOfRange: !isConnected,
Mode: profile.Mode,
})
}
sort.Slice(networks, func(i, j int) bool {
if networks[i].Connected && !networks[j].Connected {
return true
}
if !networks[i].Connected && networks[j].Connected {
return false
}
if networks[i].OutOfRange != networks[j].OutOfRange {
return !networks[i].OutOfRange
}
if networks[i].Signal != networks[j].Signal {
return networks[i].Signal > networks[j].Signal
}
return networks[i].SSID < networks[j].SSID
})
return networks
}
@@ -0,0 +1,170 @@
package network
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMergeSavedProfilesIntoWiFiNetworks(t *testing.T) {
networks := []WiFiNetwork{
{
SSID: "Home",
Signal: 80,
Secured: false,
Autoconnect: false,
},
{
SSID: "Cafe",
Signal: 50,
Secured: false,
Autoconnect: true,
},
}
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Hidden: true,
Secured: true,
Mode: "infrastructure",
},
}
merged := mergeSavedProfilesIntoWiFiNetworks(networks, profiles, "Home", true)
assert.Len(t, merged, 2)
assert.Equal(t, "Home", merged[0].SSID)
assert.True(t, merged[0].Connected)
assert.True(t, merged[0].Saved)
assert.True(t, merged[0].Autoconnect)
assert.True(t, merged[0].Hidden)
assert.True(t, merged[0].Secured)
assert.Equal(t, "infrastructure", merged[0].Mode)
assert.Equal(t, "Cafe", merged[1].SSID)
assert.False(t, merged[1].Saved)
assert.False(t, merged[1].Autoconnect)
}
func TestSavedWiFiNetworksFromProfilesOutOfRangeWithoutVisibleNetworks(t *testing.T) {
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Secured: true,
Mode: "infrastructure",
},
}
networks := savedWiFiNetworksFromProfiles(profiles, nil, "", false)
assert.Len(t, networks, 1)
assert.Equal(t, "Home", networks[0].SSID)
assert.True(t, networks[0].Saved)
assert.True(t, networks[0].OutOfRange)
assert.Equal(t, uint8(0), networks[0].Signal)
}
func TestSavedWiFiNetworksFromProfilesKeepsConnectedCurrentNetworkInRange(t *testing.T) {
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Secured: true,
},
}
networks := savedWiFiNetworksFromProfiles(profiles, nil, "Home", true)
assert.Len(t, networks, 1)
assert.Equal(t, "Home", networks[0].SSID)
assert.True(t, networks[0].Connected)
assert.False(t, networks[0].OutOfRange)
}
func TestSavedWiFiNetworksFromProfilesIncludesOutOfRange(t *testing.T) {
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Hidden: true,
Secured: true,
Mode: "infrastructure",
},
"Office": {
Autoconnect: false,
Secured: true,
Enterprise: true,
Mode: "infrastructure",
},
}
visible := map[string]WiFiNetwork{
"Home": {
SSID: "Home",
Signal: 72,
Secured: true,
Connected: true,
},
}
networks := savedWiFiNetworksFromProfiles(profiles, visible, "Home", true)
assert.Len(t, networks, 2)
assert.Equal(t, "Home", networks[0].SSID)
assert.True(t, networks[0].Saved)
assert.True(t, networks[0].Connected)
assert.False(t, networks[0].OutOfRange)
assert.True(t, networks[0].Hidden)
assert.Equal(t, uint8(72), networks[0].Signal)
assert.Equal(t, "Office", networks[1].SSID)
assert.True(t, networks[1].Saved)
assert.False(t, networks[1].Autoconnect)
assert.True(t, networks[1].Enterprise)
assert.True(t, networks[1].OutOfRange)
}
func TestWiFiNetworksBySSIDVisibleOnlySkipsOutOfRange(t *testing.T) {
visible := wiFiNetworksBySSID([]WiFiNetwork{
{SSID: "Home", Signal: 70},
{SSID: "Office", Signal: 0, OutOfRange: true},
}, true)
assert.Contains(t, visible, "Home")
assert.NotContains(t, visible, "Office")
}
func TestRefreshSavedWiFiStatePreservesVisibleSavedNetworks(t *testing.T) {
networks := []WiFiNetwork{
{
SSID: "Home",
Signal: 82,
},
}
profiles := map[string]savedWiFiProfile{
"Home": {
Autoconnect: true,
Secured: true,
Mode: "infrastructure",
},
"Office": {
Autoconnect: false,
Secured: true,
Mode: "infrastructure",
},
}
mergedNetworks, savedNetworks := refreshSavedWiFiState(networks, profiles, "", false)
assert.Len(t, mergedNetworks, 1)
assert.Equal(t, "Home", mergedNetworks[0].SSID)
assert.True(t, mergedNetworks[0].Saved)
assert.True(t, mergedNetworks[0].Autoconnect)
assert.Len(t, savedNetworks, 2)
assert.Equal(t, "Home", savedNetworks[0].SSID)
assert.True(t, savedNetworks[0].Saved)
assert.False(t, savedNetworks[0].OutOfRange)
assert.Equal(t, uint8(82), savedNetworks[0].Signal)
assert.Equal(t, "Office", savedNetworks[1].SSID)
assert.True(t, savedNetworks[1].Saved)
assert.True(t, savedNetworks[1].OutOfRange)
}
+1 -1
View File
@@ -38,7 +38,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
) )
const APIVersion = 25 const APIVersion = 26
var CLIVersion = "dev" var CLIVersion = "dev"
+11 -10
View File
@@ -66,16 +66,17 @@ func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg
} }
peer := Peer{ peer := Peer{
ID: string(ps.ID), ID: string(ps.ID),
Hostname: hostname, Hostname: hostname,
DNSName: dnsName, DNSName: dnsName,
OS: ps.OS, OS: ps.OS,
Online: ps.Online, Online: ps.Online,
Active: ps.Active, Active: ps.Active,
ExitNode: ps.ExitNode, ExitNode: ps.ExitNode,
Relay: ps.Relay, ExitNodeOption: ps.ExitNodeOption,
RxBytes: ps.RxBytes, Relay: ps.Relay,
TxBytes: ps.TxBytes, RxBytes: ps.RxBytes,
TxBytes: ps.TxBytes,
} }
for _, ip := range ps.TailscaleIPs { for _, ip := range ps.TailscaleIPs {
@@ -14,6 +14,14 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
handleGetStatus(conn, req, manager) handleGetStatus(conn, req, manager)
case "tailscale.refresh": case "tailscale.refresh":
handleRefresh(conn, req, manager) handleRefresh(conn, req, manager)
case "tailscale.connect":
handleConnect(conn, req, manager)
case "tailscale.disconnect":
handleDisconnect(conn, req, manager)
case "tailscale.setExitNode":
handleSetExitNode(conn, req, manager)
case "tailscale.setAllowLanAccess":
handleSetAllowLanAccess(conn, req, manager)
default: default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
} }
@@ -28,3 +36,37 @@ func handleRefresh(conn net.Conn, req models.Request, manager *Manager) {
manager.RefreshState() manager.RefreshState()
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"}) models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
} }
func handleConnect(conn net.Conn, req models.Request, manager *Manager) {
if err := manager.Connect(); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connected"})
}
func handleDisconnect(conn net.Conn, req models.Request, manager *Manager) {
if err := manager.Disconnect(); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"})
}
func handleSetExitNode(conn net.Conn, req models.Request, manager *Manager) {
id := models.GetOr(req, "id", "")
if err := manager.SetExitNode(id); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "exit node updated"})
}
func handleSetAllowLanAccess(conn net.Conn, req models.Request, manager *Manager) {
enabled := models.GetOr(req, "enabled", false)
if err := manager.SetAllowLANAccess(enabled); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "lan access updated"})
}
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net" "net"
"testing" "testing"
"time" "time"
@@ -78,6 +79,63 @@ func TestHandleRefresh(t *testing.T) {
assert.True(t, resp.Result.Success) assert.True(t, resp.Result.Success)
} }
func TestHandleActions(t *testing.T) {
cases := []struct {
name string
method string
params map[string]any
}{
{"connect", "tailscale.connect", nil},
{"disconnect", "tailscale.disconnect", nil},
{"setExitNode", "tailscale.setExitNode", map[string]any{"id": "nABC123"}},
{"clearExitNode", "tailscale.setExitNode", map[string]any{"id": ""}},
{"setAllowLanAccess", "tailscale.setAllowLanAccess", map[string]any{"enabled": true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := handlerTestManager()
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: tc.method, Params: tc.params}
HandleRequest(conn, req, m)
var resp models.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(buf).Decode(&resp))
assert.Equal(t, 1, resp.ID)
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.True(t, resp.Result.Success)
})
}
}
func TestHandleAction_BackendError(t *testing.T) {
client := &mockClient{
watchFn: blockingWatch,
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
return nil, fmt.Errorf("backend rejected edit")
},
}
m := newManager(client)
defer m.Close()
buf := &bytes.Buffer{}
conn := &mockConn{Buffer: buf}
req := models.Request{ID: 1, Method: "tailscale.connect"}
HandleRequest(conn, req, m)
var resp models.Response[models.SuccessResult]
require.NoError(t, json.NewDecoder(buf).Decode(&resp))
assert.Nil(t, resp.Result)
assert.Contains(t, resp.Error, "backend rejected edit")
}
func TestHandleRequest_UnknownMethod(t *testing.T) { func TestHandleRequest_UnknownMethod(t *testing.T) {
m := handlerTestManager() m := handlerTestManager()
defer m.Close() defer m.Close()
+85 -4
View File
@@ -11,6 +11,7 @@ import (
"tailscale.com/client/local" "tailscale.com/client/local"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
) )
const ( const (
@@ -22,6 +23,8 @@ const (
type tailscaleClient interface { type tailscaleClient interface {
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
Status(ctx context.Context) (*ipnstate.Status, error) Status(ctx context.Context) (*ipnstate.Status, error)
GetPrefs(ctx context.Context) (*ipn.Prefs, error)
EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error)
} }
// ipnBusWatcher abstracts the IPN bus watcher for testing. // ipnBusWatcher abstracts the IPN bus watcher for testing.
@@ -43,6 +46,14 @@ func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, erro
return w.client.Status(ctx) return w.client.Status(ctx)
} }
func (w *localClientWrapper) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
return w.client.GetPrefs(ctx)
}
func (w *localClientWrapper) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
return w.client.EditPrefs(ctx, mp)
}
// Manager manages Tailscale state via IPN bus events and subscriber notifications. // Manager manages Tailscale state via IPN bus events and subscriber notifications.
type Manager struct { type Manager struct {
state *TailscaleState state *TailscaleState
@@ -169,16 +180,36 @@ func (m *Manager) fetchAndBroadcast(ctx context.Context) {
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout) statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel() defer cancel()
status, err := m.client.Status(statusCtx) state, err := m.fetchState(statusCtx)
if err != nil { if err != nil {
log.Warnf("[Tailscale] Failed to fetch status: %v", err) log.Warnf("[Tailscale] Failed to fetch status: %v", err)
return return
} }
state := convertStatus(status)
m.updateState(state) m.updateState(state)
} }
// fetchState fetches the current status and merges in pref-derived fields
// (e.g. exit-node LAN access) that are not present in the IPN status itself.
func (m *Manager) fetchState(ctx context.Context) (*TailscaleState, error) {
status, err := m.client.Status(ctx)
if err != nil {
return nil, err
}
state := convertStatus(status)
// Prefs carry the exit-node LAN-access toggle, which the status does not
// expose. Treat a prefs failure as non-fatal so status still updates.
if prefs, err := m.client.GetPrefs(ctx); err != nil {
log.Warnf("[Tailscale] Failed to fetch prefs: %v", err)
} else if prefs != nil {
state.ExitNodeAllowLANAccess = prefs.ExitNodeAllowLANAccess
}
return state, nil
}
func (m *Manager) updateState(state *TailscaleState) { func (m *Manager) updateState(state *TailscaleState) {
m.stateMutex.Lock() m.stateMutex.Lock()
m.state = state m.state = state
@@ -266,12 +297,62 @@ func (m *Manager) RefreshState() {
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout) ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
defer cancel() defer cancel()
status, err := m.client.Status(ctx) state, err := m.fetchState(ctx)
if err != nil { if err != nil {
log.Warnf("[Tailscale] Failed to refresh state: %v", err) log.Warnf("[Tailscale] Failed to refresh state: %v", err)
return return
} }
state := convertStatus(status)
m.updateState(state) m.updateState(state)
} }
// Connect brings the Tailscale backend up (WantRunning = true).
func (m *Manager) Connect() error {
return m.editPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: true},
WantRunningSet: true,
})
}
// Disconnect brings the Tailscale backend down (WantRunning = false).
func (m *Manager) Disconnect() error {
return m.editPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: false},
WantRunningSet: true,
})
}
// SetExitNode selects the exit node identified by its stable node ID. An empty
// id clears the current exit node. Mirrors `tailscale set --exit-node=<id>`,
// which also clears any legacy IP-based exit node so a stale ExitNodeIP cannot
// silently take precedence over the now-empty ID.
func (m *Manager) SetExitNode(id string) error {
return m.editPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{ExitNodeID: tailcfg.StableNodeID(id)},
ExitNodeIDSet: true,
ExitNodeIPSet: true,
})
}
// SetAllowLANAccess toggles whether locally accessible subnets remain
// reachable while an exit node is in use.
func (m *Manager) SetAllowLANAccess(enabled bool) error {
return m.editPrefs(&ipn.MaskedPrefs{
Prefs: ipn.Prefs{ExitNodeAllowLANAccess: enabled},
ExitNodeAllowLANAccessSet: true,
})
}
// editPrefs applies a masked prefs edit and refreshes state so subscribers see
// the result immediately, in addition to the IPN bus notification it triggers.
func (m *Manager) editPrefs(mp *ipn.MaskedPrefs) error {
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
defer cancel()
if _, err := m.client.EditPrefs(ctx, mp); err != nil {
return err
}
m.RefreshState()
return nil
}
+101 -2
View File
@@ -12,8 +12,16 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
) )
// blockingWatch is a watchFn that blocks until the context is cancelled, used
// by tests that exercise direct manager calls rather than the watch loop.
func blockingWatch(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
<-ctx.Done()
return nil, ctx.Err()
}
// mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel. // mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel.
type mockWatcher struct { type mockWatcher struct {
events []ipn.Notify events []ipn.Notify
@@ -68,8 +76,10 @@ func (w *mockWatcher) Close() error {
// mockClient implements tailscaleClient for testing. // mockClient implements tailscaleClient for testing.
type mockClient struct { type mockClient struct {
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
statusFn func(ctx context.Context) (*ipnstate.Status, error) statusFn func(ctx context.Context) (*ipnstate.Status, error)
getPrefsFn func(ctx context.Context) (*ipn.Prefs, error)
editPrefsFn func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error)
} }
func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) { func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
@@ -80,6 +90,20 @@ func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) {
return c.statusFn(ctx) return c.statusFn(ctx)
} }
func (c *mockClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
if c.getPrefsFn != nil {
return c.getPrefsFn(ctx)
}
return &ipn.Prefs{}, nil
}
func (c *mockClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
if c.editPrefsFn != nil {
return c.editPrefsFn(ctx, mp)
}
return &ipn.Prefs{}, nil
}
func runningStatus() *ipnstate.Status { func runningStatus() *ipnstate.Status {
return &ipnstate.Status{ return &ipnstate.Status{
Version: "1.94.2", Version: "1.94.2",
@@ -296,3 +320,78 @@ func TestManager_RefreshState(t *testing.T) {
assert.True(t, state.Connected) assert.True(t, state.Connected)
assert.Equal(t, "cachyos", state.Self.Hostname) assert.Equal(t, "cachyos", state.Self.Hostname)
} }
func TestManager_RefreshState_MergesPrefs(t *testing.T) {
client := &mockClient{
watchFn: blockingWatch,
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
getPrefsFn: func(ctx context.Context) (*ipn.Prefs, error) {
return &ipn.Prefs{ExitNodeAllowLANAccess: true}, nil
},
}
m := newManager(client)
defer m.Close()
m.RefreshState()
assert.True(t, m.GetState().ExitNodeAllowLANAccess)
}
func TestManager_Actions_EditPrefs(t *testing.T) {
var captured *ipn.MaskedPrefs
client := &mockClient{
watchFn: blockingWatch,
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
captured = mp
return &ipn.Prefs{}, nil
},
}
m := newManager(client)
defer m.Close()
require.NoError(t, m.Connect())
require.NotNil(t, captured)
assert.True(t, captured.WantRunningSet)
assert.True(t, captured.WantRunning)
require.NoError(t, m.Disconnect())
assert.True(t, captured.WantRunningSet)
assert.False(t, captured.WantRunning)
require.NoError(t, m.SetExitNode("nABC123"))
assert.True(t, captured.ExitNodeIDSet)
assert.Equal(t, tailcfg.StableNodeID("nABC123"), captured.ExitNodeID)
// ExitNodeIPSet must also be set so a stale legacy ExitNodeIP cannot
// override the ID-based selection (mirrors `tailscale set --exit-node`).
assert.True(t, captured.ExitNodeIPSet)
require.NoError(t, m.SetExitNode(""))
assert.True(t, captured.ExitNodeIDSet)
assert.Equal(t, tailcfg.StableNodeID(""), captured.ExitNodeID)
// Clearing must zero both the ID and any legacy IP-based exit node.
assert.True(t, captured.ExitNodeIPSet)
require.NoError(t, m.SetAllowLANAccess(true))
assert.True(t, captured.ExitNodeAllowLANAccessSet)
assert.True(t, captured.ExitNodeAllowLANAccess)
}
func TestManager_Actions_PropagateError(t *testing.T) {
client := &mockClient{
watchFn: blockingWatch,
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
return nil, fmt.Errorf("backend rejected edit")
},
}
m := newManager(client)
defer m.Close()
assert.Error(t, m.Connect())
assert.Error(t, m.SetExitNode("nABC123"))
assert.Error(t, m.SetAllowLANAccess(true))
}
+24 -22
View File
@@ -2,30 +2,32 @@ package tailscale
// TailscaleState represents the current state of the Tailscale daemon. // TailscaleState represents the current state of the Tailscale daemon.
type TailscaleState struct { type TailscaleState struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
Version string `json:"version"` Version string `json:"version"`
BackendState string `json:"backendState"` BackendState string `json:"backendState"`
MagicDNSSuffix string `json:"magicDnsSuffix"` MagicDNSSuffix string `json:"magicDnsSuffix"`
TailnetName string `json:"tailnetName"` TailnetName string `json:"tailnetName"`
Self Peer `json:"self"` ExitNodeAllowLANAccess bool `json:"exitNodeAllowLanAccess"`
Peers []Peer `json:"peers"` Self Peer `json:"self"`
Peers []Peer `json:"peers"`
} }
// Peer represents a single node in the Tailscale network. // Peer represents a single node in the Tailscale network.
type Peer struct { type Peer struct {
ID string `json:"id"` ID string `json:"id"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
DNSName string `json:"dnsName"` DNSName string `json:"dnsName"`
TailscaleIP string `json:"tailscaleIp"` TailscaleIP string `json:"tailscaleIp"`
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"` TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
OS string `json:"os"` OS string `json:"os"`
Online bool `json:"online"` Online bool `json:"online"`
LastSeen string `json:"lastSeen,omitempty"` LastSeen string `json:"lastSeen,omitempty"`
ExitNode bool `json:"exitNode"` ExitNode bool `json:"exitNode"`
Tags []string `json:"tags,omitempty"` ExitNodeOption bool `json:"exitNodeOption"`
Owner string `json:"owner"` Tags []string `json:"tags,omitempty"`
Relay string `json:"relay,omitempty"` Owner string `json:"owner"`
Active bool `json:"active"` Relay string `json:"relay,omitempty"`
RxBytes int64 `json:"rxBytes"` Active bool `json:"active"`
TxBytes int64 `json:"txBytes"` RxBytes int64 `json:"rxBytes"`
TxBytes int64 `json:"txBytes"`
} }
+7 -7
View File
@@ -3,10 +3,10 @@
# Usage: ./create-source.sh <package-dir> [ubuntu-series] # Usage: ./create-source.sh <package-dir> [ubuntu-series]
# #
# Example: # Example:
# ./create-source.sh ../dms questing # Ubuntu 25.10 (default series in ppa-upload) # ./create-source.sh ../dms resolute # Ubuntu 26.04 LTS (default series in ppa-upload)
# ./create-source.sh ../dms resolute # Ubuntu 26.04 LTS # ./create-source.sh ../dms stonking # Ubuntu 26.10
# ./create-source.sh ../dms-git questing
# ./create-source.sh ../dms-git resolute # ./create-source.sh ../dms-git resolute
# ./create-source.sh ../dms-git stonking
set -e set -e
@@ -27,13 +27,13 @@ if [ $# -lt 1 ]; then
echo "Arguments:" echo "Arguments:"
echo " package-dir : Path to package directory (e.g., ../dms)" echo " package-dir : Path to package directory (e.g., ../dms)"
echo " ubuntu-series : Ubuntu series (optional, default: noble)" echo " ubuntu-series : Ubuntu series (optional, default: noble)"
echo " Options: noble, jammy, oracular, mantic, questing, resolute" echo " Options: noble, jammy, oracular, mantic, resolute, stonking"
echo echo
echo "Examples:" echo "Examples:"
echo " $0 ../dms questing"
echo " $0 ../dms resolute" echo " $0 ../dms resolute"
echo " $0 ../dms-git questing" echo " $0 ../dms stonking"
echo " $0 ../dms-git resolute" echo " $0 ../dms-git resolute"
echo " $0 ../dms-git stonking"
exit 1 exit 1
fi fi
@@ -135,7 +135,7 @@ check_ppa_version_exists() {
local CHECK_MODE="${4:-commit}" local CHECK_MODE="${4:-commit}"
local DISTRO_SERIES="${5:-}" local DISTRO_SERIES="${5:-}"
# Query Launchpad API (optionally scoped to one Ubuntu series so the same version can ship to questing and resolute) # Query Launchpad API (optionally scoped to one Ubuntu series so the same version can ship to resolute and stonking)
local API_URL="https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$SOURCE_NAME&status=Published" local API_URL="https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$SOURCE_NAME&status=Published"
if [[ -n "$DISTRO_SERIES" ]]; then if [[ -n "$DISTRO_SERIES" ]]; then
API_URL+="&distro_series=https://api.launchpad.net/1.0/ubuntu/${DISTRO_SERIES}" API_URL+="&distro_series=https://api.launchpad.net/1.0/ubuntu/${DISTRO_SERIES}"
+2 -2
View File
@@ -10,8 +10,8 @@
PPA_OWNER="avengemedia" PPA_OWNER="avengemedia"
LAUNCHPAD_API="https://api.launchpad.net/1.0" LAUNCHPAD_API="https://api.launchpad.net/1.0"
# Supported Ubuntu series for PPA builds (25.10 questing + 26.04 LTS resolute) # Supported Ubuntu series for PPA builds (26.04 LTS resolute + 26.10 stonking)
DISTRO_SERIES_LIST=(questing resolute) DISTRO_SERIES_LIST=(resolute stonking)
# Define packages (sync with ppa-upload.sh) # Define packages (sync with ppa-upload.sh)
ALL_PACKAGES=(dms dms-git dms-greeter) ALL_PACKAGES=(dms dms-git dms-greeter)
+3 -3
View File
@@ -5,7 +5,7 @@ set -euo pipefail
PPA_OWNER="avengemedia" PPA_OWNER="avengemedia"
LAUNCHPAD_API="https://api.launchpad.net/1.0" LAUNCHPAD_API="https://api.launchpad.net/1.0"
SERIES_LIST=(questing resolute) SERIES_LIST=(resolute stonking)
PACKAGE_FILTER="dms-git" PACKAGE_FILTER="dms-git"
REBUILD_RELEASE="" REBUILD_RELEASE=""
JSON=false JSON=false
@@ -72,12 +72,12 @@ embedded_commit() {
target_ppa() { target_ppa() {
local series="$1" local series="$1"
if [[ -n "$REBUILD_RELEASE" ]]; then if [[ -n "$REBUILD_RELEASE" ]]; then
if [[ "$series" == "resolute" ]]; then if [[ "$series" == "stonking" ]]; then
echo $((REBUILD_RELEASE + 1)) echo $((REBUILD_RELEASE + 1))
else else
echo "$REBUILD_RELEASE" echo "$REBUILD_RELEASE"
fi fi
elif [[ "$series" == "resolute" ]]; then elif [[ "$series" == "stonking" ]]; then
echo "2" echo "2"
else else
echo "1" echo "1"
+8 -8
View File
@@ -3,13 +3,13 @@
# Usage: ./ppa-upload.sh [package-name] [ppa-name] [ubuntu-series] [rebuild-number] [--keep-builds] [--rebuild=N] # Usage: ./ppa-upload.sh [package-name] [ppa-name] [ubuntu-series] [rebuild-number] [--keep-builds] [--rebuild=N]
# #
# Examples: # Examples:
# ./ppa-upload.sh dms # Upload to questing + resolute (default) # ./ppa-upload.sh dms # Upload to resolute + stonking (default)
# ./ppa-upload.sh dms 2 # Native: questing ppa2, resolute ppa3 (auto +1 on second series) # ./ppa-upload.sh dms 2 # Native: resolute ppa2, stonking ppa3 (auto +1 on second series)
# ./ppa-upload.sh dms --rebuild=2 # Rebuild with ppa2 (flag syntax) # ./ppa-upload.sh dms --rebuild=2 # Rebuild with ppa2 (flag syntax)
# ./ppa-upload.sh dms-git # Single package (both series) # ./ppa-upload.sh dms-git # Single package (both series)
# ./ppa-upload.sh all # All packages (each to both series) # ./ppa-upload.sh all # All packages (each to both series)
# ./ppa-upload.sh dms resolute # 26.04 LTS only (same as "dms dms resolute") # ./ppa-upload.sh dms resolute # 26.04 LTS only (same as "dms dms resolute")
# ./ppa-upload.sh dms questing # 25.10 only # ./ppa-upload.sh dms stonking # 26.10 only
# ./ppa-upload.sh dms dms resolute # Explicit PPA name + one series (optional form) # ./ppa-upload.sh dms dms resolute # Explicit PPA name + one series (optional form)
# ./ppa-upload.sh dms dms resolute 2 # One series + rebuild number # ./ppa-upload.sh dms dms resolute 2 # One series + rebuild number
# ./ppa-upload.sh distro/ubuntu/dms dms # Path-style (backward compatible) # ./ppa-upload.sh distro/ubuntu/dms dms # Path-style (backward compatible)
@@ -70,8 +70,8 @@ if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then
fi fi
fi fi
# Shorthand: "dms resolute" / "dms questing" (package + series; PPA inferred — no need for "dms dms resolute") # Shorthand: "dms resolute" / "dms stonking" (package + series; PPA inferred — no need for "dms dms resolute")
if [[ ${#POSITIONAL_ARGS[@]} -eq 2 ]] && [[ "${POSITIONAL_ARGS[1]}" == "questing" || "${POSITIONAL_ARGS[1]}" == "resolute" ]]; then if [[ ${#POSITIONAL_ARGS[@]} -eq 2 ]] && [[ "${POSITIONAL_ARGS[1]}" == "resolute" || "${POSITIONAL_ARGS[1]}" == "stonking" ]]; then
PACKAGE_INPUT="${POSITIONAL_ARGS[0]}" PACKAGE_INPUT="${POSITIONAL_ARGS[0]}"
PPA_NAME_INPUT="" PPA_NAME_INPUT=""
UBUNTU_SERIES_RAW="${POSITIONAL_ARGS[1]}" UBUNTU_SERIES_RAW="${POSITIONAL_ARGS[1]}"
@@ -79,11 +79,11 @@ fi
SERIES_LIST=() SERIES_LIST=()
if [[ -z "$UBUNTU_SERIES_RAW" ]]; then if [[ -z "$UBUNTU_SERIES_RAW" ]]; then
SERIES_LIST=(questing resolute) SERIES_LIST=(resolute stonking)
elif [[ "$UBUNTU_SERIES_RAW" == "questing" || "$UBUNTU_SERIES_RAW" == "resolute" ]]; then elif [[ "$UBUNTU_SERIES_RAW" == "resolute" || "$UBUNTU_SERIES_RAW" == "stonking" ]]; then
SERIES_LIST=("$UBUNTU_SERIES_RAW") SERIES_LIST=("$UBUNTU_SERIES_RAW")
else else
error "Invalid Ubuntu series: $UBUNTU_SERIES_RAW (use questing, resolute, or omit for both)" error "Invalid Ubuntu series: $UBUNTU_SERIES_RAW (use resolute, stonking, or omit for both)"
exit 1 exit 1
fi fi
+11 -4
View File
@@ -40,10 +40,17 @@ override_dh_auto_install:
install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \ install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE
install -Dpm0644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf \ # Install systemd tmpfiles/sysusers fragments only when present in the fetched source.
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf # sysusers-dms-greeter.conf landed upstream after v1.4.6; guarding both lets older
install -Dm644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf \ # release tarballs build, while future tags that ship the files install them automatically.
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf if [ -f DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf ]; then \
install -Dpm0644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf \
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf; \
fi
if [ -f DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf ]; then \
install -Dm644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf \
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf; \
fi
# Create cache directory structure (will be created by postinst) # Create cache directory structure (will be created by postinst)
mkdir -p debian/dms-greeter/var/cache/dms-greeter mkdir -p debian/dms-greeter/var/cache/dms-greeter
+34 -1
View File
@@ -126,7 +126,40 @@ const KEY_MAP = {
161: "exclamdown" 161: "exclamdown"
}; };
function xkbKeyFromQtKey(qk) { // Numpad (keypad) keys. Qt reuses the same Qt::Key_* values for the numpad and
// the main rows/nav cluster; only Qt.KeypadModifier distinguishes them. niri and
// the other compositors bind against the xkb KP_* keysym names, so we must emit
// those instead of the collapsed twin. With NumLock off the numpad sends the
// navigation keysyms (KP_Home, KP_End, ...); with NumLock on it sends KP_0..KP_9
// (handled by the digit range in xkbKeyFromQtKey). Operators/Enter are the same
// in both states.
const KP_MAP = {
16777232: "KP_Home",
16777235: "KP_Up",
16777238: "KP_Prior",
16777234: "KP_Left",
16777227: "KP_Begin",
16777236: "KP_Right",
16777233: "KP_End",
16777237: "KP_Down",
16777239: "KP_Next",
16777222: "KP_Insert",
16777223: "KP_Delete",
16777221: "KP_Enter",
43: "KP_Add",
45: "KP_Subtract",
42: "KP_Multiply",
47: "KP_Divide",
46: "KP_Decimal"
};
function xkbKeyFromQtKey(qk, isKeypad) {
if (isKeypad) {
if (qk >= 48 && qk <= 57)
return "KP_" + (qk - 48);
if (KP_MAP[qk])
return KP_MAP[qk];
}
if (qk >= 65 && qk <= 90) if (qk >= 65 && qk <= 90)
return String.fromCharCode(qk); return String.fromCharCode(qk);
if (qk >= 97 && qk <= 122) if (qk >= 97 && qk <= 122)
+19
View File
@@ -108,6 +108,8 @@ Singleton {
} }
property bool clipboardEnterToPaste: false property bool clipboardEnterToPaste: false
property bool clipboardRememberTypeFilter: false
property string clipboardTypeFilter: "all"
property var clipboardVisibleEntryActions: ["pin", "edit", "delete"] property var clipboardVisibleEntryActions: ["pin", "edit", "delete"]
property var launcherPluginVisibility: ({}) property var launcherPluginVisibility: ({})
@@ -164,6 +166,8 @@ Singleton {
property real popupTransparency: 1.0 property real popupTransparency: 1.0
property real dockTransparency: 1 property real dockTransparency: 1
property string widgetBackgroundColor: "sch" property string widgetBackgroundColor: "sch"
property string widgetBackgroundCustomColor: "#6750A4"
property real widgetBackgroundCustomStrength: 0.50
property string widgetColorMode: "default" property string widgetColorMode: "default"
property string controlCenterTileColorMode: "primary" property string controlCenterTileColorMode: "primary"
property string buttonColorMode: "primary" property string buttonColorMode: "primary"
@@ -385,11 +389,16 @@ Singleton {
property bool dwlShowAllTags: false property bool dwlShowAllTags: false
property bool workspaceActiveAppHighlightEnabled: false property bool workspaceActiveAppHighlightEnabled: false
property string workspaceColorMode: "default" property string workspaceColorMode: "default"
property string workspaceFocusedCustomColor: "#6750A4"
property string workspaceOccupiedColorMode: "none" property string workspaceOccupiedColorMode: "none"
property string workspaceOccupiedCustomColor: "#625B71"
property string workspaceUnfocusedColorMode: "default" property string workspaceUnfocusedColorMode: "default"
property string workspaceUnfocusedCustomColor: "#49454E"
property string workspaceUrgentColorMode: "default" property string workspaceUrgentColorMode: "default"
property string workspaceUrgentCustomColor: "#B3261E"
property bool workspaceFocusedBorderEnabled: false property bool workspaceFocusedBorderEnabled: false
property string workspaceFocusedBorderColor: "primary" property string workspaceFocusedBorderColor: "primary"
property string workspaceFocusedBorderCustomColor: "#6750A4"
property int workspaceFocusedBorderThickness: 2 property int workspaceFocusedBorderThickness: 2
property var workspaceNameIcons: ({}) property var workspaceNameIcons: ({})
property bool waveProgressEnabled: true property bool waveProgressEnabled: true
@@ -465,6 +474,8 @@ Singleton {
property bool launcherUseOverlayLayer: false property bool launcherUseOverlayLayer: false
property string launcherStyle: "full" property string launcherStyle: "full"
property bool spotlightBarShowModeChips: false property bool spotlightBarShowModeChips: false
property bool keybindsFloatingWindow: false
onKeybindsFloatingWindowChanged: saveSettings()
property string _legacyWeatherLocation: "New York, NY" property string _legacyWeatherLocation: "New York, NY"
property string _legacyWeatherCoordinates: "40.7128,-74.0060" property string _legacyWeatherCoordinates: "40.7128,-74.0060"
@@ -572,6 +583,7 @@ Singleton {
property bool soundVolumeChanged: true property bool soundVolumeChanged: true
property bool soundPluggedIn: true property bool soundPluggedIn: true
property bool soundLogin: false property bool soundLogin: false
property bool muteSoundsWhenMediaPlaying: true
property int acMonitorTimeout: 0 property int acMonitorTimeout: 0
property int acLockTimeout: 0 property int acLockTimeout: 0
@@ -586,6 +598,13 @@ Singleton {
property string batteryProfileName: "" property string batteryProfileName: ""
property int batteryPostLockMonitorTimeout: 0 property int batteryPostLockMonitorTimeout: 0
property int batteryChargeLimit: 100 property int batteryChargeLimit: 100
property bool batteryNotifyChargeLimit: false
property int batteryCriticalThreshold: 10
property bool batteryNotifyCritical: true
property int batteryLowThreshold: 20
property bool batteryNotifyLow: false
property int batteryNotificationType: 0
property bool batteryAutoPowerSaver: false
property bool lockBeforeSuspend: false property bool lockBeforeSuspend: false
property bool loginctlLockIntegration: true property bool loginctlLockIntegration: true
property bool fadeToLockEnabled: true property bool fadeToLockEnabled: true
+66 -2
View File
@@ -450,7 +450,9 @@ Singleton {
"primaryText": getMatugenColor("on_primary", "#ffffff"), "primaryText": getMatugenColor("on_primary", "#ffffff"),
"primaryContainer": getMatugenColor("primary_container", "#1976d2"), "primaryContainer": getMatugenColor("primary_container", "#1976d2"),
"secondary": getMatugenColor("secondary", "#8ab4f8"), "secondary": getMatugenColor("secondary", "#8ab4f8"),
"secondaryContainer": getMatugenColor("secondary_container", getMatugenColor("surface_container_high", "#292b2f")),
"tertiary": getMatugenColor("tertiary", "#efb8c8"), "tertiary": getMatugenColor("tertiary", "#efb8c8"),
"tertiaryContainer": getMatugenColor("tertiary_container", getMatugenColor("surface_container_high", "#292b2f")),
"surface": getMatugenColor("surface", "#1a1c1e"), "surface": getMatugenColor("surface", "#1a1c1e"),
"surfaceText": getMatugenColor("on_background", "#e3e8ef"), "surfaceText": getMatugenColor("on_background", "#e3e8ef"),
"surfaceVariant": getMatugenColor("surface_variant", "#44464f"), "surfaceVariant": getMatugenColor("surface_variant", "#44464f"),
@@ -521,7 +523,6 @@ Singleton {
property color primary: currentThemeData.primary property color primary: currentThemeData.primary
property color primaryText: currentThemeData.primaryText property color primaryText: currentThemeData.primaryText
property color primaryContainer: currentThemeData.primaryContainer
property color secondary: currentThemeData.secondary property color secondary: currentThemeData.secondary
property color tertiary: currentThemeData.tertiary || currentThemeData.secondary property color tertiary: currentThemeData.tertiary || currentThemeData.secondary
property color surface: currentThemeData.surface property color surface: currentThemeData.surface
@@ -536,6 +537,9 @@ Singleton {
property color surfaceContainer: currentThemeData.surfaceContainer property color surfaceContainer: currentThemeData.surfaceContainer
property color surfaceContainerHigh: currentThemeData.surfaceContainerHigh property color surfaceContainerHigh: currentThemeData.surfaceContainerHigh
property color surfaceContainerHighest: currentThemeData.surfaceContainerHighest || surfaceContainerHigh property color surfaceContainerHighest: currentThemeData.surfaceContainerHighest || surfaceContainerHigh
property color primaryContainer: currentThemeData.primaryContainer || blend(surfaceContainerHigh, primary, 0.45)
property color secondaryContainer: currentThemeData.secondaryContainer || blend(surfaceContainerHigh, secondary, 0.35)
property color tertiaryContainer: currentThemeData.tertiaryContainer || blend(surfaceContainerHigh, tertiary, 0.35)
property color onSurface: surfaceText property color onSurface: surfaceText
property color onSurfaceVariant: surfaceVariantText property color onSurfaceVariant: surfaceVariantText
@@ -577,6 +581,45 @@ Singleton {
readonly property int layerOutlineWidth: BlurService.enabled && layerOutlineOpacity > 0 ? 1 : 0 readonly property int layerOutlineWidth: BlurService.enabled && layerOutlineOpacity > 0 ? 1 : 0
property color surfaceTextHover: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.08) property color surfaceTextHover: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.08)
property color surfaceTextAlpha: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.3) property color surfaceTextAlpha: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.3)
function roleColor(mode) {
switch (mode) {
case "primary":
case "pri":
return primary;
case "primaryContainer":
return primaryContainer;
case "secondary":
case "sec":
return secondary;
case "secondaryContainer":
return secondaryContainer;
case "tertiary":
case "ter":
return tertiary;
case "tertiaryContainer":
return tertiaryContainer;
case "surfaceText":
return surfaceText;
case "surfaceVariant":
return surfaceVariant;
case "s":
return surface;
case "sc":
return surfaceContainer;
case "sch":
return surfaceContainerHigh;
case "schh":
return surfaceContainerHighest;
case "sth":
return surfaceTextHover;
case "error":
case "err":
return error;
default:
return "transparent";
}
}
property color surfaceTextLight: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.06) property color surfaceTextLight: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.06)
property color surfaceTextMedium: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.7) property color surfaceTextMedium: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.7)
@@ -1430,9 +1473,22 @@ Singleton {
property bool widgetBackgroundHasAlpha: { property bool widgetBackgroundHasAlpha: {
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch"; const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
return colorMode === "sth"; return colorMode === "sth" || colorMode === "custom";
} }
function safeColor(value, fallback) {
try {
if (value === undefined || value === null || value === "")
return fallback;
return Qt.color(value);
} catch (e) {
return fallback;
}
}
readonly property color widgetBackgroundCustomBaseColor: safeColor(typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundCustomColor : "#6750A4", primaryContainer)
readonly property real widgetBackgroundCustomStrength: Math.max(0, Math.min(1, typeof SettingsData !== "undefined" ? (SettingsData.widgetBackgroundCustomStrength ?? 0.4) : 0.4))
property var widgetBaseBackgroundColor: { property var widgetBaseBackgroundColor: {
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch"; const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
switch (colorMode) { switch (colorMode) {
@@ -1442,6 +1498,14 @@ Singleton {
return surfaceContainer; return surfaceContainer;
case "sch": case "sch":
return surfaceContainerHigh; return surfaceContainerHigh;
case "primaryContainer":
return primaryContainer;
case "secondaryContainer":
return secondaryContainer;
case "tertiaryContainer":
return tertiaryContainer;
case "custom":
return blend(surfaceContainerHigh, widgetBackgroundCustomBaseColor, widgetBackgroundCustomStrength);
case "sth": case "sth":
default: default:
return surfaceTextHover; return surfaceTextHover;
@@ -19,6 +19,8 @@ var SPEC = {
dockTransparency: { def: 1.0, coerce: percentToUnit }, dockTransparency: { def: 1.0, coerce: percentToUnit },
widgetBackgroundColor: { def: "sch" }, widgetBackgroundColor: { def: "sch" },
widgetBackgroundCustomColor: { def: "#6750A4" },
widgetBackgroundCustomStrength: { def: 0.50, coerce: percentToUnit },
widgetColorMode: { def: "default" }, widgetColorMode: { def: "default" },
controlCenterTileColorMode: { def: "primary" }, controlCenterTileColorMode: { def: "primary" },
buttonColorMode: { def: "primary" }, buttonColorMode: { def: "primary" },
@@ -144,11 +146,16 @@ var SPEC = {
dwlShowAllTags: { def: false }, dwlShowAllTags: { def: false },
workspaceActiveAppHighlightEnabled: { def: false }, workspaceActiveAppHighlightEnabled: { def: false },
workspaceColorMode: { def: "default" }, workspaceColorMode: { def: "default" },
workspaceFocusedCustomColor: { def: "#6750A4" },
workspaceOccupiedColorMode: { def: "none" }, workspaceOccupiedColorMode: { def: "none" },
workspaceOccupiedCustomColor: { def: "#625B71" },
workspaceUnfocusedColorMode: { def: "default" }, workspaceUnfocusedColorMode: { def: "default" },
workspaceUnfocusedCustomColor: { def: "#49454E" },
workspaceUrgentColorMode: { def: "default" }, workspaceUrgentColorMode: { def: "default" },
workspaceUrgentCustomColor: { def: "#B3261E" },
workspaceFocusedBorderEnabled: { def: false }, workspaceFocusedBorderEnabled: { def: false },
workspaceFocusedBorderColor: { def: "primary" }, workspaceFocusedBorderColor: { def: "primary" },
workspaceFocusedBorderCustomColor: { def: "#6750A4" },
workspaceFocusedBorderThickness: { def: 2 }, workspaceFocusedBorderThickness: { def: 2 },
workspaceNameIcons: { def: {} }, workspaceNameIcons: { def: {} },
waveProgressEnabled: { def: true }, waveProgressEnabled: { def: true },
@@ -230,6 +237,7 @@ var SPEC = {
launcherUseOverlayLayer: { def: false }, launcherUseOverlayLayer: { def: false },
launcherStyle: { def: "full" }, launcherStyle: { def: "full" },
spotlightBarShowModeChips: { def: false }, spotlightBarShowModeChips: { def: false },
keybindsFloatingWindow: { def: false },
useAutoLocation: { def: false }, useAutoLocation: { def: false },
weatherEnabled: { def: true }, weatherEnabled: { def: true },
@@ -282,6 +290,7 @@ var SPEC = {
soundNewNotification: { def: true }, soundNewNotification: { def: true },
soundVolumeChanged: { def: true }, soundVolumeChanged: { def: true },
soundPluggedIn: { def: true }, soundPluggedIn: { def: true },
muteSoundsWhenMediaPlaying: { def: true },
acMonitorTimeout: { def: 0 }, acMonitorTimeout: { def: 0 },
acLockTimeout: { def: 0 }, acLockTimeout: { def: 0 },
@@ -296,6 +305,13 @@ var SPEC = {
batteryProfileName: { def: "" }, batteryProfileName: { def: "" },
batteryPostLockMonitorTimeout: { def: 0 }, batteryPostLockMonitorTimeout: { def: 0 },
batteryChargeLimit: { def: 100 }, batteryChargeLimit: { def: 100 },
batteryNotifyChargeLimit: { def: false },
batteryCriticalThreshold: { def: 10 },
batteryNotifyCritical: { def: true },
batteryLowThreshold: { def: 20 },
batteryNotifyLow: { def: false },
batteryNotificationType: { def: 0 },
batteryAutoPowerSaver: { def: false },
lockBeforeSuspend: { def: false }, lockBeforeSuspend: { def: false },
loginctlLockIntegration: { def: true }, loginctlLockIntegration: { def: true },
fadeToLockEnabled: { def: true }, fadeToLockEnabled: { def: true },
@@ -582,6 +598,8 @@ var SPEC = {
builtInPluginSettings: { def: {} }, builtInPluginSettings: { def: {} },
clipboardEnterToPaste: { def: false }, clipboardEnterToPaste: { def: false },
clipboardRememberTypeFilter: { def: false },
clipboardTypeFilter: { def: "all" },
clipboardVisibleEntryActions: { def: ["pin", "edit", "delete"] }, clipboardVisibleEntryActions: { def: ["pin", "edit", "delete"] },
launcherPluginVisibility: { def: {} }, launcherPluginVisibility: { def: {} },
+65 -3
View File
@@ -116,6 +116,12 @@ Item {
fadeWindowLoader.item.cancelFade(); fadeWindowLoader.item.cancelFade();
} }
} }
function onDismissFadeToLock() {
if (fadeWindowLoader.item) {
fadeWindowLoader.item.dismiss();
}
}
} }
} }
} }
@@ -317,6 +323,9 @@ Item {
property bool hadRealScreen: true property bool hadRealScreen: true
property var previousRealScreenNames: [] property var previousRealScreenNames: []
// Guards for the screen-reconnect recovery path (see scheduleScreenReconnectRecovery).
property bool _screenRecoveryCooldown: false
property bool _screenRecoveryPending: false
function _getRealScreenNames() { function _getRealScreenNames() {
const names = []; const names = [];
@@ -359,15 +368,60 @@ Item {
const partialReconnect = root.previousRealScreenNames.length > 0 const partialReconnect = root.previousRealScreenNames.length > 0
&& currentNames.some(name => !root.previousRealScreenNames.includes(name)); && currentNames.some(name => !root.previousRealScreenNames.includes(name));
if (fullReconnect || partialReconnect) { if (fullReconnect || partialReconnect) {
log.info("Screen reconnect detected, triggering surface recovery", log.info("Screen reconnect detected, scheduling surface recovery",
"full:", fullReconnect, "partial:", partialReconnect); "full:", fullReconnect, "partial:", partialReconnect);
root.triggerSurfaceRecovery("screen-reconnect"); root.scheduleScreenReconnectRecovery();
} }
root.hadRealScreen = hasReal; root.hadRealScreen = hasReal;
root.previousRealScreenNames = currentNames; root.previousRealScreenNames = currentNames;
} }
} }
// A DPMS off/on cycle removes an output from the screen list and re-adds it,
// which is indistinguishable here from a hotplug. Recovering immediately on
// every such event lets a flapping monitor (or a recovery that itself perturbs
// the output) drive an endless recovery storm that power-cycles the display
// (#2642). Debounce a burst of changes into a single pass, then hold a cooldown
// so repeated flaps trigger at most one recovery per window. Recovery still runs
// once per resume, so a partial DPMS resume keeps redrawing its surfaces (#2579).
function scheduleScreenReconnectRecovery() {
if (root._screenRecoveryCooldown) {
root._screenRecoveryPending = true;
return;
}
screenReconnectDebounce.restart();
}
Timer {
id: screenReconnectDebounce
// Wide enough to collapse the output-remove + output-re-add pair that one
// DPMS off/on cycle emits as two near-simultaneous events into one recovery.
interval: 450
repeat: false
onTriggered: {
root._screenRecoveryCooldown = true;
root._screenRecoveryPending = false;
screenReconnectCooldown.restart();
root.triggerSurfaceRecovery("screen-reconnect");
}
}
Timer {
id: screenReconnectCooldown
// Must exceed the full two-pass surfaceResumeRecoveryTimer sequence
// (800 + 2000 ms) so the cooldown still covers an in-flight recovery;
// raise this if those passes are lengthened.
interval: 4000
repeat: false
onTriggered: {
root._screenRecoveryCooldown = false;
if (root._screenRecoveryPending) {
root._screenRecoveryPending = false;
screenReconnectDebounce.restart();
}
}
}
Timer { Timer {
id: surfaceResumeRecoveryTimer id: surfaceResumeRecoveryTimer
interval: 800 interval: 800
@@ -653,7 +707,7 @@ Item {
if (!wifiPasswordModalLoader.item) if (!wifiPasswordModalLoader.item)
return; return;
if (wifiPasswordModalLoader.item.visible && timeSinceLastPrompt < 1000) { if (wifiPasswordModalLoader.item.shouldBeVisible && timeSinceLastPrompt < 1000) {
NetworkService.cancelCredentials(lastCredentialsToken); NetworkService.cancelCredentials(lastCredentialsToken);
lastCredentialsToken = token; lastCredentialsToken = token;
lastCredentialsTime = now; lastCredentialsTime = now;
@@ -997,6 +1051,14 @@ Item {
osdResumeRecreateTimer.interval = 400; osdResumeRecreateTimer.interval = 400;
osdResumeRecreateTimer.restart(); osdResumeRecreateTimer.restart();
// This path runs its own recovery directly, so drop any queued or
// in-flight screen-reconnect recovery to avoid a redundant pass once
// its cooldown expires.
screenReconnectDebounce.stop();
screenReconnectCooldown.stop();
root._screenRecoveryCooldown = false;
root._screenRecoveryPending = false;
root.triggerSurfaceRecovery("sessionResumed"); root.triggerSurfaceRecovery("sessionResumed");
} }
} }
@@ -11,6 +11,14 @@ Item {
property alias searchField: searchField property alias searchField: searchField
property alias clipboardListView: clipboardListView property alias clipboardListView: clipboardListView
readonly property var filterOptions: [I18n.tr("All"), I18n.tr("Text"), I18n.tr("Long Text"), I18n.tr("Image")]
readonly property var filterValues: ["all", "text", "long_text", "image"]
function closeFilterMenu() {
filterMenuLoader.active = false;
filterMenuLoader.active = true;
}
anchors.fill: parent anchors.fill: parent
Column { Column {
@@ -36,27 +44,81 @@ Item {
onCloseClicked: modal.hide() onCloseClicked: modal.hide()
} }
DankTextField { Item {
id: searchField id: searchRow
width: parent.width width: parent.width
placeholderText: "" implicitHeight: searchField.height
leftIconName: "search"
showClearButton: true DankTextField {
focus: true id: searchField
ignoreTabKeys: true
keyForwardTargets: [modal.modalFocusScope] width: parent.width
onTextChanged: { rightAccessoryWidth: filterButton.width + Theme.spacingS
modal.searchText = text; placeholderText: ""
modal.updateFilteredModel(); leftIconName: "search"
showClearButton: true
focus: true
ignoreTabKeys: true
keyForwardTargets: [modal.modalFocusScope]
onTextChanged: {
modal.searchText = text;
modal.updateFilteredModel();
}
Keys.onEscapePressed: function (event) {
modal.hide();
event.accepted = true;
}
Component.onCompleted: {
Qt.callLater(function () {
forceActiveFocus();
});
}
} }
Keys.onEscapePressed: function (event) {
modal.hide(); DankActionButton {
event.accepted = true; id: filterButton
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
iconName: "filter_list"
iconColor: modal.activeFilter !== "all" ? Theme.primary : Theme.surfaceText
backgroundColor: modal.activeFilter !== "all" ? Theme.primarySelected : "transparent"
tooltipText: I18n.tr("Filter by type", "Clipboard history type filter button tooltip")
onClicked: filterMenuLoader.item?.openDropdownMenu()
} }
Component.onCompleted: {
Qt.callLater(function () { Loader {
forceActiveFocus(); id: filterMenuLoader
});
active: true
sourceComponent: filterMenuComponent
}
Component {
id: filterMenuComponent
DankDropdown {
showTrigger: false
popupAnchorItem: filterButton
popupWidth: 180
alignPopupRight: true
options: clipboardContent.filterOptions
currentValue: {
const idx = clipboardContent.filterValues.indexOf(clipboardContent.modal.activeFilter);
return idx >= 0 ? clipboardContent.filterOptions[idx] : clipboardContent.filterOptions[0];
}
onValueChanged: value => {
const idx = clipboardContent.filterOptions.indexOf(value);
if (idx >= 0) {
clipboardContent.modal.activeFilter = clipboardContent.filterValues[idx];
}
}
}
} }
} }
} }
@@ -15,6 +15,12 @@ Item {
property var entry: null property var entry: null
property string editorText: "" property string editorText: ""
function releaseTextInputFocus() {
if (editField) {
editField.focus = false;
}
}
function decodeEntryData(data) { function decodeEntryData(data) {
if (!data) { if (!data) {
return ""; return "";
@@ -38,6 +38,7 @@ Item {
font.weight: Font.Medium font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
} }
Row { Row {
@@ -16,6 +16,7 @@ FocusScope {
property string mode: "history" property string mode: "history"
property string searchText: ClipboardService.searchText property string searchText: ClipboardService.searchText
property string activeFilter: SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all"
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
@@ -50,16 +51,56 @@ FocusScope {
} }
onSearchTextChanged: ClipboardService.searchText = searchText onSearchTextChanged: ClipboardService.searchText = searchText
onActiveFilterChanged: {
ClipboardService.activeFilter = activeFilter;
ClipboardService.selectedIndex = 0;
ClipboardService.keyboardNavigationActive = false;
ClipboardService.updateFilteredModel();
if (SettingsData.clipboardRememberTypeFilter) {
SettingsData.set("clipboardTypeFilter", activeFilter);
}
}
function releaseTextInputFocus() {
// Drop text-input focus before hiding the Wayland surface.
if (searchField) {
searchField.setFocus(false);
}
if (editorView) {
editorView.releaseTextInputFocus();
}
root.forceActiveFocus();
}
function requestClose(instant) {
releaseTextInputFocus();
if (instant) {
root.instantCloseRequested();
} else {
root.closeRequested();
}
}
function hide() { function hide() {
closeRequested(); requestClose(false);
} }
function pasteSelected() { function pasteSelected() {
ClipboardService.pasteSelected(() => root.instantCloseRequested()); const entry = selectedEntry();
if (!entry)
return;
ClipboardService.pasteEntry(entry, () => root.requestClose(true));
} }
function copyEntry(entry) { function copyEntry(entry) {
ClipboardService.copyEntry(entry, () => root.closeRequested()); ClipboardService.copyEntry(entry, () => root.requestClose(false));
}
function selectedEntry() {
const entries = activeTab === "saved" ? pinnedEntries : unpinnedEntries;
if (!entries || entries.length === 0 || selectedIndex < 0 || selectedIndex >= entries.length)
return null;
return entries[selectedIndex];
} }
function deleteEntry(entry) { function deleteEntry(entry) {
@@ -118,6 +159,8 @@ FocusScope {
function resetState() { function resetState() {
activeImageLoads = 0; activeImageLoads = 0;
mode = "history"; mode = "history";
historyContent.closeFilterMenu();
activeFilter = SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all";
ClipboardService.reset(); ClipboardService.reset();
keyboardController.reset(); keyboardController.reset();
} }
@@ -45,8 +45,22 @@ DankModal {
}); });
} }
function releaseTextInputFocus() {
contentLoader.item?.releaseTextInputFocus();
}
function hide() { function hide() {
close(); releaseTextInputFocus();
Qt.callLater(function () {
clipboardHistoryModal.close();
});
}
function instantHide() {
releaseTextInputFocus();
Qt.callLater(function () {
clipboardHistoryModal.instantClose();
});
} }
onDialogClosed: { onDialogClosed: {
@@ -68,6 +82,11 @@ DankModal {
enableShadow: true enableShadow: true
closeOnEscapeKey: (contentLoader.item?.mode ?? "history") !== "editor" closeOnEscapeKey: (contentLoader.item?.mode ?? "history") !== "editor"
onBackgroundClicked: hide() onBackgroundClicked: hide()
onShouldBeVisibleChanged: {
if (!shouldBeVisible) {
releaseTextInputFocus();
}
}
Ref { Ref {
service: ClipboardService service: ClipboardService
@@ -112,7 +131,7 @@ DankModal {
ClipboardHistoryContent { ClipboardHistoryContent {
clearConfirmDialog: clearConfirmDialog clearConfirmDialog: clearConfirmDialog
onCloseRequested: clipboardHistoryModal.hide() onCloseRequested: clipboardHistoryModal.hide()
onInstantCloseRequested: clipboardHistoryModal.instantClose() onInstantCloseRequested: clipboardHistoryModal.instantHide()
} }
} }
} }
@@ -37,8 +37,15 @@ DankPopout {
}); });
} }
function releaseTextInputFocus() {
contentLoader.item?.releaseTextInputFocus();
}
function hide() { function hide() {
close(); releaseTextInputFocus();
Qt.callLater(function () {
root.close();
});
} }
function clearAll() { function clearAll() {
@@ -57,6 +64,7 @@ DankPopout {
onShouldBeVisibleChanged: { onShouldBeVisibleChanged: {
if (!shouldBeVisible) { if (!shouldBeVisible) {
releaseTextInputFocus();
return; return;
} }
if (clipboardAvailable) { if (clipboardAvailable) {
@@ -134,7 +142,7 @@ DankPopout {
clearConfirmDialog: clearConfirmDialog clearConfirmDialog: clearConfirmDialog
onCloseRequested: root.hide() onCloseRequested: root.hide()
onInstantCloseRequested: root.close() onInstantCloseRequested: root.hide()
Component.onCompleted: { Component.onCompleted: {
activeTab = root.activeTab; activeTab = root.activeTab;
@@ -363,7 +363,7 @@ FocusScope {
width: buttonContent.width + Theme.spacingM * 2 width: buttonContent.width + Theme.spacingM * 2
height: 28 height: 28
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: controller.searchMode === modelData.id || modeArea.containsMouse ? Theme.primaryContainer : "transparent" color: controller.searchMode === modelData.id ? Theme.buttonBg : modeArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
Row { Row {
id: buttonContent id: buttonContent
@@ -374,14 +374,14 @@ FocusScope {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
name: modelData.icon name: modelData.icon
size: 14 size: 14
color: controller.searchMode === modelData.id ? Theme.primary : Theme.surfaceVariantText color: controller.searchMode === modelData.id ? Theme.buttonText : Theme.surfaceVariantText
} }
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: modelData.label text: modelData.label
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: controller.searchMode === modelData.id ? Theme.primary : Theme.surfaceText color: controller.searchMode === modelData.id ? Theme.buttonText : Theme.surfaceText
} }
} }
@@ -636,7 +636,7 @@ FocusScope {
width: chipContent.width + Theme.spacingM * 2 width: chipContent.width + Theme.spacingM * 2
height: sortDropdown.height height: sortDropdown.height
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: controller.fileSearchType === modelData.id || chipArea.containsMouse ? Theme.primaryContainer : "transparent" color: controller.fileSearchType === modelData.id ? Theme.buttonBg : chipArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
Row { Row {
id: chipContent id: chipContent
@@ -647,14 +647,14 @@ FocusScope {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
name: modelData.icon name: modelData.icon
size: 14 size: 14
color: controller.fileSearchType === modelData.id ? Theme.primary : Theme.surfaceVariantText color: controller.fileSearchType === modelData.id ? Theme.buttonText : Theme.surfaceVariantText
} }
StyledText { StyledText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: modelData.label text: modelData.label
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: controller.fileSearchType === modelData.id ? Theme.primary : Theme.surfaceText color: controller.fileSearchType === modelData.id ? Theme.buttonText : Theme.surfaceVariantText
} }
} }
+336
View File
@@ -0,0 +1,336 @@
import QtQml
import QtQuick
import QtQuick.Layouts
import qs.Common
import qs.Services
import qs.Widgets
FocusScope {
id: content
property real scrollStep: 60
property var activeFlickable: mainFlickable
property bool showFloatingToggle: true
property bool floating: false
property alias searchField: searchField
signal closeRequested
signal floatingToggleRequested
function scrollDown() {
if (!activeFlickable)
return;
let newY = activeFlickable.contentY + scrollStep;
newY = Math.min(newY, activeFlickable.contentHeight - activeFlickable.height);
activeFlickable.contentY = newY;
}
function scrollUp() {
if (!activeFlickable)
return;
let newY = activeFlickable.contentY - scrollStep;
newY = Math.max(0, newY);
activeFlickable.contentY = newY;
}
Keys.onPressed: event => {
switch (event.key) {
case Qt.Key_J:
if (event.modifiers & Qt.ControlModifier) {
scrollDown();
event.accepted = true;
}
return;
case Qt.Key_K:
if (event.modifiers & Qt.ControlModifier) {
scrollUp();
event.accepted = true;
}
return;
case Qt.Key_Down:
scrollDown();
event.accepted = true;
return;
case Qt.Key_Up:
scrollUp();
event.accepted = true;
return;
}
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingL
RowLayout {
width: parent.width
spacing: Theme.spacingM
StyledText {
Layout.alignment: Qt.AlignLeft
text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.primary
}
Item {
Layout.fillWidth: true
}
DankActionButton {
visible: content.showFloatingToggle
iconName: content.floating ? "close_fullscreen" : "open_in_new"
tooltipText: content.floating ? I18n.tr("Dock window") : I18n.tr("Open as window")
onClicked: content.floatingToggleRequested()
}
DankTextField {
id: searchField
Layout.alignment: Qt.AlignRight
leftIconName: "search"
keyForwardTargets: [content]
onTextEdited: searchDebounce.restart()
Keys.onEscapePressed: event => {
content.closeRequested();
event.accepted = true;
}
}
}
Timer {
id: searchDebounce
interval: 50
repeat: false
onTriggered: {
mainFlickable.categories = mainFlickable.generateCategories(searchField.text);
}
}
DankFlickable {
id: mainFlickable
width: parent.width
height: parent.height - parent.spacing - 40
contentWidth: rowLayout.implicitWidth
contentHeight: rowLayout.implicitHeight
clip: true
property var rawBinds: KeybindsService.cheatsheet.binds || {}
function generateCategories(query) {
const lowerQuery = query ? query.toLowerCase().trim() : "";
const lowerQueryWords = query.split(/\s+/);
const processed = {};
for (const cat in rawBinds) {
const binds = rawBinds[cat];
const catLower = cat.toLowerCase();
const subcats = {};
let hasSubcats = false;
for (let i = 0; i < binds.length; i++) {
const bind = binds[i];
const keyLower = (bind.key || "").toLowerCase();
const descLower = (bind.desc || "").toLowerCase();
const actionLower = (bind.action || "").toLowerCase();
if (bind.hideOnOverlay)
continue;
let shouldContinue = false;
for (let j = 0; j < lowerQueryWords.length; j++) {
const word = lowerQueryWords[j];
if (!(word.length === 0 || keyLower.includes(word) || descLower.includes(word) || catLower.includes(word) || actionLower.includes(word))) {
shouldContinue = true;
break;
}
}
if (shouldContinue)
continue;
if (bind.subcat) {
hasSubcats = true;
if (!subcats[bind.subcat])
subcats[bind.subcat] = [];
subcats[bind.subcat].push(bind);
} else {
if (!subcats["_root"])
subcats["_root"] = [];
subcats["_root"].push(bind);
}
}
if (Object.keys(subcats).length === 0)
continue;
processed[cat] = {
hasSubcats: hasSubcats,
subcats: subcats,
subcatKeys: Object.keys(subcats)
};
}
return processed;
}
property var categories: generateCategories("")
function estimateCategoryHeight(catName) {
const catData = categories[catName];
if (!catData)
return 0;
let bindCount = 0;
for (const key of catData.subcatKeys) {
bindCount += catData.subcats[key]?.length || 0;
if (key !== "_root")
bindCount += 1;
}
return 40 + bindCount * 28;
}
property var categoryKeys: Object.keys(categories)
function distributeCategories(cols) {
const columns = [];
const heights = [];
for (let i = 0; i < cols; i++) {
columns.push([]);
heights.push(0);
}
const sorted = [...categoryKeys].sort((a, b) => estimateCategoryHeight(b) - estimateCategoryHeight(a));
for (const cat of sorted) {
let minIdx = 0;
for (let i = 1; i < cols; i++) {
if (heights[i] < heights[minIdx])
minIdx = i;
}
columns[minIdx].push(cat);
heights[minIdx] += estimateCategoryHeight(cat);
}
return columns;
}
Row {
id: rowLayout
width: mainFlickable.width
spacing: Theme.spacingM
property int numColumns: Math.max(1, Math.min(3, Math.floor(width / 350)))
property var columnCategories: mainFlickable.distributeCategories(numColumns)
Repeater {
model: rowLayout.numColumns
Column {
id: masonryColumn
width: (rowLayout.width - rowLayout.spacing * (rowLayout.numColumns - 1)) / rowLayout.numColumns
spacing: Theme.spacingXL
Repeater {
model: rowLayout.columnCategories[index] || []
Column {
id: categoryColumn
width: parent.width
spacing: Theme.spacingXS
property string catName: modelData
property var catData: mainFlickable.categories[catName]
StyledText {
text: categoryColumn.catName
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Bold
color: Theme.primary
}
Rectangle {
width: parent.width
height: 1
color: Theme.primary
opacity: 0.3
}
Item {
width: 1
height: Theme.spacingXS
}
Column {
width: parent.width
spacing: Theme.spacingM
Repeater {
model: categoryColumn.catData?.subcatKeys || []
Column {
width: parent.width
spacing: Theme.spacingXS
property string subcatName: modelData
property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || []
StyledText {
visible: parent.subcatName !== "_root"
text: parent.subcatName
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
color: Theme.primary
opacity: 0.7
}
Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: parent.parent.subcatBinds
Item {
width: parent.width
height: 24
StyledRect {
id: keyBadge
width: Math.min(keyText.implicitWidth + 12, 160)
height: 22
radius: 4
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: keyText
anchors.centerIn: parent
color: Theme.secondary
text: (modelData.key || "").replace(/\+/g, " + ")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
isMonospace: true
elide: Text.ElideRight
width: Math.min(implicitWidth, 148)
}
}
StyledText {
anchors.left: parent.left
anchors.leftMargin: 170
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: modelData.desc || modelData.action || ""
font.pixelSize: Theme.fontSizeSmall
opacity: 0.9
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
+58 -314
View File
@@ -1,334 +1,78 @@
import QtQml
import QtQuick import QtQuick
import QtQuick.Layouts
import qs.Common import qs.Common
import qs.Modals.Common import qs.Modals
import qs.Services import qs.Services
import qs.Widgets
DankModal { Item {
id: root id: root
layerNamespace: "dms:keybinds" readonly property bool floating: SettingsData.keybindsFloatingWindow
useOverlayLayer: true readonly property bool shouldBeVisible: floating ? (windowLoader.item ? windowLoader.item.visible : false) : (overlayLoader.item ? overlayLoader.item.shouldBeVisible : false)
property real scrollStep: 60
property var activeFlickable: null
property real _maxW: Math.min(root.screenWidth * 0.92, 1200)
property real _maxH: Math.min(root.screenHeight * 0.92, 900)
modalWidth: _maxW
modalHeight: _maxH
onBackgroundClicked: close()
onOpened: {
Qt.callLater(() => {
modalFocusScope.forceActiveFocus();
if (contentLoader.item?.searchField)
contentLoader.item.searchField.forceActiveFocus();
});
if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable)
KeybindsService.loadCheatsheet();
}
function scrollDown() { function open() {
if (!root.activeFlickable) if (floating) {
windowLoader.active = true;
windowLoader.item.show();
return; return;
let newY = root.activeFlickable.contentY + scrollStep; }
newY = Math.min(newY, root.activeFlickable.contentHeight - root.activeFlickable.height); overlayLoader.active = true;
root.activeFlickable.contentY = newY; overlayLoader.item.open();
} }
function scrollUp() { function close() {
if (!root.activeFlickable) if (windowLoader.item)
windowLoader.item.hide();
if (overlayLoader.item)
overlayLoader.item.close();
}
function toggle() {
if (shouldBeVisible)
close();
else
open();
}
function _switchFloating(toFloating) {
if (toFloating) {
if (overlayLoader.item)
overlayLoader.item.close();
SettingsData.keybindsFloatingWindow = true;
windowLoader.active = true;
windowLoader.item.show();
return; return;
let newY = root.activeFlickable.contentY - root.scrollStep; }
newY = Math.max(0, newY); if (windowLoader.item)
root.activeFlickable.contentY = newY; windowLoader.item.hide();
SettingsData.keybindsFloatingWindow = false;
overlayLoader.active = true;
overlayLoader.item.open();
} }
modalFocusScope.Keys.onPressed: event => { Loader {
if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) { id: overlayLoader
scrollDown(); active: false
event.accepted = true; asynchronous: false
} else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) {
scrollUp(); sourceComponent: KeybindsModalOverlay {
event.accepted = true; onFloatingToggleRequested: root._switchFloating(true)
} else if (event.key === Qt.Key_Down) { onDialogClosed: Qt.callLater(() => {
scrollDown(); if (!shouldBeVisible)
event.accepted = true; overlayLoader.active = false;
} else if (event.key === Qt.Key_Up) { })
scrollUp();
event.accepted = true;
} }
} }
content: Component { Loader {
Item { id: windowLoader
anchors.fill: parent active: false
property alias searchField: searchField asynchronous: false
Column { sourceComponent: KeybindsModalWindow {
anchors.fill: parent onFloatingToggleRequested: root._switchFloating(false)
anchors.margins: Theme.spacingL onVisibleChanged: {
spacing: Theme.spacingL if (!visible)
Qt.callLater(() => windowLoader.active = false);
RowLayout {
width: parent.width
StyledText {
Layout.alignment: Qt.AlignLeft
text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Bold
color: Theme.primary
}
DankTextField {
id: searchField
Layout.alignment: Qt.AlignRight
leftIconName: "search"
keyForwardTargets: [root.modalFocusScope]
onTextEdited: searchDebounce.restart()
Keys.onEscapePressed: event => {
root.close();
event.accepted = true;
}
}
}
Timer {
id: searchDebounce
interval: 50
repeat: false
onTriggered: {
mainFlickable.categories = mainFlickable.generateCategories(searchField.text);
}
}
DankFlickable {
id: mainFlickable
width: parent.width
height: parent.height - parent.spacing - 40
contentWidth: rowLayout.implicitWidth
contentHeight: rowLayout.implicitHeight
clip: true
Component.onCompleted: root.activeFlickable = mainFlickable
property var rawBinds: KeybindsService.cheatsheet.binds || {}
function generateCategories(query) {
const lowerQuery = query ? query.toLowerCase().trim() : "";
const lowerQueryWords = query.split(/\s+/);
const processed = {};
for (const cat in rawBinds) {
const binds = rawBinds[cat];
const catLower = cat.toLowerCase();
const subcats = {};
let hasSubcats = false;
for (let i = 0; i < binds.length; i++) {
const bind = binds[i];
const keyLower = (bind.key || "").toLowerCase();
const descLower = (bind.desc || "").toLowerCase();
const actionLower = (bind.action || "").toLowerCase();
if (bind.hideOnOverlay)
continue;
let shouldContinue = false;
for (let j = 0; j < lowerQueryWords.length; j++) {
const word = lowerQueryWords[j];
if (!(word.length === 0 || keyLower.includes(word) || descLower.includes(word) || catLower.includes(word) || actionLower.includes(word))) {
shouldContinue = true;
break;
}
}
if (shouldContinue)
continue;
if (bind.subcat) {
hasSubcats = true;
if (!subcats[bind.subcat])
subcats[bind.subcat] = [];
subcats[bind.subcat].push(bind);
} else {
if (!subcats["_root"])
subcats["_root"] = [];
subcats["_root"].push(bind);
}
}
if (Object.keys(subcats).length === 0)
continue;
processed[cat] = {
hasSubcats: hasSubcats,
subcats: subcats,
subcatKeys: Object.keys(subcats)
};
}
return processed;
}
property var categories: generateCategories("")
function estimateCategoryHeight(catName) {
const catData = categories[catName];
if (!catData)
return 0;
let bindCount = 0;
for (const key of catData.subcatKeys) {
bindCount += catData.subcats[key]?.length || 0;
if (key !== "_root")
bindCount += 1;
}
return 40 + bindCount * 28;
}
property var categoryKeys: Object.keys(categories)
function distributeCategories(cols) {
const columns = [];
const heights = [];
for (let i = 0; i < cols; i++) {
columns.push([]);
heights.push(0);
}
const sorted = [...categoryKeys].sort((a, b) => estimateCategoryHeight(b) - estimateCategoryHeight(a));
for (const cat of sorted) {
let minIdx = 0;
for (let i = 1; i < cols; i++) {
if (heights[i] < heights[minIdx])
minIdx = i;
}
columns[minIdx].push(cat);
heights[minIdx] += estimateCategoryHeight(cat);
}
return columns;
}
Row {
id: rowLayout
width: mainFlickable.width
spacing: Theme.spacingM
property int numColumns: Math.max(1, Math.min(3, Math.floor(width / 350)))
property var columnCategories: mainFlickable.distributeCategories(numColumns)
Repeater {
model: rowLayout.numColumns
Column {
id: masonryColumn
width: (rowLayout.width - rowLayout.spacing * (rowLayout.numColumns - 1)) / rowLayout.numColumns
spacing: Theme.spacingXL
Repeater {
model: rowLayout.columnCategories[index] || []
Column {
id: categoryColumn
width: parent.width
spacing: Theme.spacingXS
property string catName: modelData
property var catData: mainFlickable.categories[catName]
StyledText {
text: categoryColumn.catName
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Bold
color: Theme.primary
}
Rectangle {
width: parent.width
height: 1
color: Theme.primary
opacity: 0.3
}
Item {
width: 1
height: Theme.spacingXS
}
Column {
width: parent.width
spacing: Theme.spacingM
Repeater {
model: categoryColumn.catData?.subcatKeys || []
Column {
width: parent.width
spacing: Theme.spacingXS
property string subcatName: modelData
property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || []
StyledText {
visible: parent.subcatName !== "_root"
text: parent.subcatName
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
color: Theme.primary
opacity: 0.7
}
Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: parent.parent.subcatBinds
Item {
width: parent.width
height: 24
StyledRect {
id: keyBadge
width: Math.min(keyText.implicitWidth + 12, 160)
height: 22
radius: 4
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: keyText
anchors.centerIn: parent
color: Theme.secondary
text: (modelData.key || "").replace(/\+/g, " + ")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
isMonospace: true
elide: Text.ElideRight
width: Math.min(implicitWidth, 148)
}
}
StyledText {
anchors.left: parent.left
anchors.leftMargin: 170
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: modelData.desc || modelData.action || ""
font.pixelSize: Theme.fontSizeSmall
opacity: 0.9
elide: Text.ElideRight
wrapMode: Text.NoWrap
}
}
}
}
}
}
}
}
}
}
}
}
}
} }
} }
} }
@@ -0,0 +1,38 @@
import QtQml
import QtQuick
import qs.Common
import qs.Modals
import qs.Modals.Common
import qs.Services
DankModal {
id: overlay
signal floatingToggleRequested
layerNamespace: "dms:keybinds"
useOverlayLayer: true
property real _maxW: Math.min(overlay.screenWidth * 0.92, 1200)
property real _maxH: Math.min(overlay.screenHeight * 0.92, 900)
modalWidth: _maxW
modalHeight: _maxH
onBackgroundClicked: close()
onOpened: {
Qt.callLater(() => {
modalFocusScope.forceActiveFocus();
if (contentLoader.item?.searchField)
contentLoader.item.searchField.forceActiveFocus();
});
if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable)
KeybindsService.loadCheatsheet();
}
content: Component {
KeybindsContent {
showFloatingToggle: true
floating: false
onCloseRequested: overlay.close()
onFloatingToggleRequested: overlay.floatingToggleRequested()
}
}
}
+140
View File
@@ -0,0 +1,140 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Modals
import qs.Services
import qs.Widgets
FloatingWindow {
id: win
property bool disablePopupTransparency: true
property alias shouldBeVisible: win.visible
signal floatingToggleRequested
function show() {
visible = true;
}
function hide() {
visible = false;
}
function toggle() {
visible = !visible;
}
objectName: "keybindsModalWindow"
title: I18n.tr("Keybinds")
minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height))
implicitWidth: 1000
implicitHeight: screen ? Math.min(820, screen.height - 100) : 820
color: Theme.surfaceContainer
visible: false
onVisibleChanged: {
if (!visible)
return;
if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable)
KeybindsService.loadCheatsheet();
Qt.callLater(() => {
keybindsContent.forceActiveFocus();
keybindsContent.searchField.forceActiveFocus();
});
}
onClosed: win.visible = false
Column {
anchors.fill: parent
spacing: 0
Item {
width: parent.width
height: 48
z: 10
MouseArea {
anchors.fill: parent
onPressed: windowControls.tryStartMove()
onDoubleClicked: windowControls.tryToggleMaximize()
}
Rectangle {
anchors.fill: parent
color: Theme.surfaceContainer
opacity: 0.5
}
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingL
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "keyboard"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds")
font.pixelSize: Theme.fontSizeXLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
DankActionButton {
circular: false
iconName: "close_fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
tooltipText: I18n.tr("Dock window")
onClicked: win.floatingToggleRequested()
}
DankActionButton {
visible: windowControls.canMaximize
circular: false
iconName: win.maximized ? "fullscreen_exit" : "fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: windowControls.tryToggleMaximize()
}
DankActionButton {
circular: false
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: win.hide()
}
}
}
KeybindsContent {
id: keybindsContent
width: parent.width
height: parent.height - 48
showFloatingToggle: false
floating: true
onCloseRequested: win.hide()
}
}
FloatingWindowControls {
id: windowControls
targetWindow: win
}
}
+1
View File
@@ -11,6 +11,7 @@ DankModal {
layerNamespace: "dms:power-menu" layerNamespace: "dms:power-menu"
keepPopoutsOpen: true keepPopoutsOpen: true
useOverlayLayer: true
property int selectedIndex: 0 property int selectedIndex: 0
property int selectedRow: 0 property int selectedRow: 0
@@ -686,5 +686,20 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus()); Qt.callLater(() => item.forceActiveFocus());
} }
} }
Loader {
id: batteryLoader
anchors.fill: parent
active: root.currentIndex === 42
visible: active
focus: active
sourceComponent: BatteryTab {}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
} }
} }
@@ -377,6 +377,12 @@ Rectangle {
"text": I18n.tr("Power & Sleep"), "text": I18n.tr("Power & Sleep"),
"icon": "power_settings_new", "icon": "power_settings_new",
"tabIndex": 21 "tabIndex": 21
},
{
"id": "battery",
"text": I18n.tr("Battery"),
"icon": "battery_charging_full",
"tabIndex": 42
} }
] ]
}, },
+28 -45
View File
@@ -1,12 +1,22 @@
import QtQuick import QtQuick
import Quickshell
import qs.Common import qs.Common
import qs.Modals.Common
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
FloatingWindow { DankModal {
id: root id: root
layerNamespace: "dms:wifi-password"
keepPopoutsOpen: true
allowStacking: true
shouldBeVisible: false
modalWidth: 420
modalHeight: calculatedHeight
enableShadow: true
onBackgroundClicked: clearAndClose()
directContent: contentFocusScope
property bool disablePopupTransparency: true property bool disablePopupTransparency: true
property string wifiPasswordSSID: "" property string wifiPasswordSSID: ""
property string wifiPasswordInput: "" property string wifiPasswordInput: ""
@@ -102,7 +112,7 @@ FloatingWindow {
const network = NetworkService.wifiNetworks.find(n => n.ssid === ssid); const network = NetworkService.wifiNetworks.find(n => n.ssid === ssid);
requiresEnterprise = network?.enterprise || false; requiresEnterprise = network?.enterprise || false;
visible = true; open();
Qt.callLater(focusFirstField); Qt.callLater(focusFirstField);
} }
@@ -126,7 +136,7 @@ FloatingWindow {
secretValues = {}; secretValues = {};
requiresEnterprise = false; requiresEnterprise = false;
visible = true; open();
Qt.callLater(focusFirstField); Qt.callLater(focusFirstField);
} }
@@ -144,6 +154,7 @@ FloatingWindow {
isVpnPrompt = (connectionType === "vpn" || connectionType === "wireguard"); isVpnPrompt = (connectionType === "vpn" || connectionType === "wireguard");
wifiPasswordSSID = isVpnPrompt ? connectionName : ssid; wifiPasswordSSID = isVpnPrompt ? connectionName : ssid;
savePasswordCheckbox.checked = !isVpnPrompt;
requiresEnterprise = setting === "802-1x"; requiresEnterprise = setting === "802-1x";
@@ -152,7 +163,7 @@ FloatingWindow {
wifiAnonymousIdentityInput = ""; wifiAnonymousIdentityInput = "";
wifiDomainInput = ""; wifiDomainInput = "";
visible = true; open();
Qt.callLater(() => { Qt.callLater(() => {
if (reason === "wrong-password" && fieldsInfo.length === 0) { if (reason === "wrong-password" && fieldsInfo.length === 0) {
passwordInput.text = ""; passwordInput.text = "";
@@ -162,7 +173,7 @@ FloatingWindow {
} }
function hide() { function hide() {
visible = false; close();
} }
function getFieldLabel(fieldName) { function getFieldLabel(fieldName) {
@@ -242,23 +253,8 @@ FloatingWindow {
secretValues = {}; secretValues = {};
} }
objectName: "wifiPasswordModal" onShouldBeVisibleChanged: {
title: { if (shouldBeVisible) {
if (promptReason === "pkcs11")
return I18n.tr("Smartcard PIN");
if (isVpnPrompt)
return I18n.tr("VPN Password");
if (isHiddenNetwork)
return I18n.tr("Hidden Network");
return I18n.tr("Wi-Fi Password");
}
minimumSize: Qt.size(420, calculatedHeight)
maximumSize: Qt.size(420, calculatedHeight)
color: Theme.surfaceContainer
visible: false
onVisibleChanged: {
if (visible) {
Qt.callLater(focusFirstField); Qt.callLater(focusFirstField);
return; return;
} }
@@ -287,7 +283,7 @@ FloatingWindow {
return; return;
wifiPasswordSSID = NetworkService.connectingSSID; wifiPasswordSSID = NetworkService.connectingSSID;
wifiPasswordInput = ""; wifiPasswordInput = "";
visible = true; open();
NetworkService.passwordDialogShouldReopen = false; NetworkService.passwordDialogShouldReopen = false;
} }
} }
@@ -296,7 +292,7 @@ FloatingWindow {
id: contentFocusScope id: contentFocusScope
anchors.fill: parent anchors.fill: parent
focus: true focus: root.shouldBeVisible
Keys.onEscapePressed: event => { Keys.onEscapePressed: event => {
clearAndClose(); clearAndClose();
@@ -318,8 +314,6 @@ FloatingWindow {
anchors.right: buttonRow.left anchors.right: buttonRow.left
anchors.rightMargin: Theme.spacingM anchors.rightMargin: Theme.spacingM
height: headerCol.height height: headerCol.height
onPressed: windowControls.tryStartMove()
onDoubleClicked: windowControls.tryToggleMaximize()
Column { Column {
id: headerCol id: headerCol
@@ -380,14 +374,6 @@ FloatingWindow {
anchors.right: parent.right anchors.right: parent.right
spacing: Theme.spacingXS spacing: Theme.spacingXS
DankActionButton {
visible: windowControls.canMaximize
iconName: root.maximized ? "fullscreen_exit" : "fullscreen"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: windowControls.tryToggleMaximize()
}
DankActionButton { DankActionButton {
iconName: "close" iconName: "close"
iconSize: Theme.iconSize - 4 iconSize: Theme.iconSize - 4
@@ -419,7 +405,7 @@ FloatingWindow {
textColor: Theme.surfaceText textColor: Theme.surfaceText
placeholderText: I18n.tr("Network Name (SSID)") placeholderText: I18n.tr("Network Name (SSID)")
backgroundColor: "transparent" backgroundColor: "transparent"
enabled: root.visible enabled: root.shouldBeVisible
keyNavigationTab: passwordInput keyNavigationTab: passwordInput
onAccepted: passwordInput.forceActiveFocus() onAccepted: passwordInput.forceActiveFocus()
} }
@@ -449,7 +435,7 @@ FloatingWindow {
echoMode: modelData.isSecret && !passwordVisible ? TextInput.Password : TextInput.Normal echoMode: modelData.isSecret && !passwordVisible ? TextInput.Password : TextInput.Normal
placeholderText: getFieldLabel(modelData.name) placeholderText: getFieldLabel(modelData.name)
backgroundColor: "transparent" backgroundColor: "transparent"
enabled: root.visible enabled: root.shouldBeVisible
Keys.onTabPressed: event => { Keys.onTabPressed: event => {
if (index < fieldsInfo.length - 1) { if (index < fieldsInfo.length - 1) {
@@ -519,7 +505,7 @@ FloatingWindow {
text: wifiUsernameInput text: wifiUsernameInput
placeholderText: I18n.tr("Username") placeholderText: I18n.tr("Username")
backgroundColor: "transparent" backgroundColor: "transparent"
enabled: root.visible enabled: root.shouldBeVisible
keyNavigationTab: passwordInput keyNavigationTab: passwordInput
keyNavigationBacktab: domainMatchInput keyNavigationBacktab: domainMatchInput
onTextEdited: wifiUsernameInput = text onTextEdited: wifiUsernameInput = text
@@ -552,7 +538,7 @@ FloatingWindow {
echoMode: passwordVisible ? TextInput.Normal : TextInput.Password echoMode: passwordVisible ? TextInput.Normal : TextInput.Password
placeholderText: (requiresEnterprise && !isVpnPrompt) ? I18n.tr("Password") : "" placeholderText: (requiresEnterprise && !isVpnPrompt) ? I18n.tr("Password") : ""
backgroundColor: "transparent" backgroundColor: "transparent"
enabled: root.visible enabled: root.shouldBeVisible
keyNavigationTab: (requiresEnterprise && !isVpnPrompt) ? anonInput : null keyNavigationTab: (requiresEnterprise && !isVpnPrompt) ? anonInput : null
keyNavigationBacktab: (requiresEnterprise && !isVpnPrompt) ? usernameInput : null keyNavigationBacktab: (requiresEnterprise && !isVpnPrompt) ? usernameInput : null
onTextEdited: wifiPasswordInput = text onTextEdited: wifiPasswordInput = text
@@ -589,7 +575,7 @@ FloatingWindow {
text: wifiAnonymousIdentityInput text: wifiAnonymousIdentityInput
placeholderText: I18n.tr("Anonymous Identity (optional)") placeholderText: I18n.tr("Anonymous Identity (optional)")
backgroundColor: "transparent" backgroundColor: "transparent"
enabled: root.visible enabled: root.shouldBeVisible
keyNavigationTab: domainMatchInput keyNavigationTab: domainMatchInput
keyNavigationBacktab: passwordInput keyNavigationBacktab: passwordInput
onTextEdited: wifiAnonymousIdentityInput = text onTextEdited: wifiAnonymousIdentityInput = text
@@ -620,7 +606,7 @@ FloatingWindow {
text: wifiDomainInput text: wifiDomainInput
placeholderText: I18n.tr("Domain (optional)") placeholderText: I18n.tr("Domain (optional)")
backgroundColor: "transparent" backgroundColor: "transparent"
enabled: root.visible enabled: root.shouldBeVisible
keyNavigationTab: usernameInput keyNavigationTab: usernameInput
keyNavigationBacktab: anonInput keyNavigationBacktab: anonInput
onTextEdited: wifiDomainInput = text onTextEdited: wifiDomainInput = text
@@ -757,8 +743,5 @@ FloatingWindow {
} }
} }
FloatingWindowControls { onOpened: Qt.callLater(() => contentFocusScope.forceActiveFocus())
id: windowControls
targetWindow: root
}
} }
@@ -25,7 +25,14 @@ PluginComponent {
} }
ccWidgetIsActive: TailscaleService.connected ccWidgetIsActive: TailscaleService.connected
onCcWidgetToggled: {} onCcWidgetToggled: {
if (!TailscaleService.available)
return;
if (TailscaleService.connected)
TailscaleService.disconnectTailscale(null);
else
TailscaleService.connectTailscale(null);
}
ccDetailContent: Component { ccDetailContent: Component {
Rectangle { Rectangle {
@@ -88,6 +95,122 @@ PluginComponent {
width: parent.width width: parent.width
spacing: Theme.spacingS spacing: Theme.spacingS
// Connection status + connect/disconnect. Always shown
// (when available) so the connection can be toggled from
// the detail, including while disconnected.
RowLayout {
width: parent.width
spacing: Theme.spacingS
Column {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: 1
StyledText {
text: TailscaleService.connected ? I18n.tr("Connected", "Tailscale connection status: connected") : I18n.tr("Disconnected", "Tailscale connection status: disconnected")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
visible: TailscaleService.connected && TailscaleService.tailnetName.length > 0
text: TailscaleService.tailnetName
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
}
}
Rectangle {
id: connButton
Layout.alignment: Qt.AlignVCenter
height: 28
radius: 14
width: connButtonRow.implicitWidth + Theme.spacingM * 2
readonly property bool isConnected: TailscaleService.connected
color: isConnected ? (connButtonArea.containsMouse ? Theme.errorHover : Theme.surfaceLight) : (connButtonArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
Row {
id: connButtonRow
anchors.centerIn: parent
spacing: Theme.spacingXS
DankIcon {
name: connButton.isConnected ? "link_off" : "link"
size: Theme.fontSizeSmall
color: connButton.isConnected ? Theme.surfaceText : Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: connButton.isConnected ? I18n.tr("Disconnect", "Tailscale disconnect button") : I18n.tr("Connect", "Tailscale connect button")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: connButton.isConnected ? Theme.surfaceText : Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: connButtonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (TailscaleService.connected)
TailscaleService.disconnectTailscale(null);
else
TailscaleService.connectTailscale(null);
}
}
}
}
// Connection controls: exit node picker + LAN access.
// Only meaningful while the backend is connected.
Column {
id: controlsColumn
width: parent.width
spacing: Theme.spacingS
visible: TailscaleService.connected
readonly property string noneLabel: I18n.tr("None", "Tailscale exit node: none selected")
DankDropdown {
width: parent.width
text: I18n.tr("Exit node", "Tailscale exit node selector label")
currentValue: TailscaleService.currentExitNode ? TailscaleService.currentExitNode.hostname : controlsColumn.noneLabel
options: {
const opts = [controlsColumn.noneLabel];
for (const p of TailscaleService.exitNodeOptions)
opts.push(p.hostname);
return opts;
}
onValueChanged: value => {
if (value === controlsColumn.noneLabel) {
TailscaleService.clearExitNode(null);
return;
}
const peer = TailscaleService.exitNodeOptions.find(p => p.hostname === value);
if (peer)
TailscaleService.setExitNode(peer.id, null);
}
}
DankToggle {
width: parent.width
text: I18n.tr("Allow LAN access", "Tailscale allow LAN access toggle")
description: I18n.tr("Reach local network devices while using an exit node", "Tailscale allow LAN access description")
visible: TailscaleService.currentExitNode !== null
checked: TailscaleService.exitNodeAllowLanAccess
onToggled: value => TailscaleService.setAllowLanAccess(value, null)
}
}
// Search bar + refresh button // Search bar + refresh button
RowLayout { RowLayout {
width: parent.width width: parent.width
@@ -93,7 +93,7 @@ DankPopout {
shouldBeVisible: false shouldBeVisible: false
property bool credentialsPromptOpen: NetworkService.credentialsRequested property bool credentialsPromptOpen: NetworkService.credentialsRequested
property bool wifiPasswordModalOpen: PopoutService.wifiPasswordModal?.visible ?? false property bool wifiPasswordModalOpen: PopoutService.wifiPasswordModal?.shouldBeVisible ?? false
property bool polkitModalOpen: PopoutService.polkitAuthModal?.visible ?? false property bool polkitModalOpen: PopoutService.polkitAuthModal?.visible ?? false
property bool anyModalOpen: credentialsPromptOpen || wifiPasswordModalOpen || polkitModalOpen || powerMenuOpen property bool anyModalOpen: credentialsPromptOpen || wifiPasswordModalOpen || polkitModalOpen || powerMenuOpen
@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Controls import QtQuick.Controls
import Quickshell import Quickshell
import qs.Common import qs.Common
import qs.Modules.Network
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modals import qs.Modals
@@ -721,7 +722,7 @@ Rectangle {
DankActionButton { DankActionButton {
id: qrCodeButton id: qrCodeButton
visible: modelData.secured && modelData.saved visible: modelData.secured && modelData.saved && !(modelData.enterprise || false)
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: optionsButton.width + pinWifiRow.width + 3 * Theme.spacingM + Theme.spacingS anchors.rightMargin: optionsButton.width + pinWifiRow.width + 3 * Theme.spacingM + Theme.spacingS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -749,11 +750,9 @@ Rectangle {
event.accepted = true; event.accepted = true;
return; return;
} }
if (modelData.secured && !modelData.saved && (DMSService.apiVersion < 7 || modelData.enterprise)) { WifiConnectionActions.connectToNetwork(modelData, {
PopoutService.showWifiPasswordModal(modelData.ssid); connected: wifiDelegate.isConnected
} else { });
NetworkService.connectToWifi(modelData.ssid);
}
event.accepted = true; event.accepted = true;
} }
} }
@@ -804,15 +803,9 @@ Rectangle {
} }
onTriggered: { onTriggered: {
if (networkContextMenu.currentConnected) { WifiConnectionActions.connectToNetworkFromDetails(networkContextMenu.currentSSID, networkContextMenu.currentSecured, networkContextMenu.currentSaved, networkContextMenu.currentEnterprise, networkContextMenu.currentConnected, {
NetworkService.disconnectWifi(); disconnectWhenConnected: true
return; });
}
if (networkContextMenu.currentSecured && !networkContextMenu.currentSaved && (DMSService.apiVersion < 7 || networkContextMenu.currentEnterprise)) {
PopoutService.showWifiPasswordModal(networkContextMenu.currentSSID);
return;
}
NetworkService.connectToWifi(networkContextMenu.currentSSID);
} }
} }
@@ -150,6 +150,9 @@ PanelWindow {
function onUsesFrameBarChromeChanged() { function onUsesFrameBarChromeChanged() {
_blurRebuildTimer.restart(); _blurRebuildTimer.restart();
} }
function onBarRevealedChanged() {
_blurRebuildTimer.restart();
}
} }
Component { Component {
@@ -176,6 +179,13 @@ PanelWindow {
teardown(); teardown();
if (!BlurService.enabled || !BlurService.available) if (!BlurService.enabled || !BlurService.available)
return; return;
// When the bar is hidden (auto-hide, or config not visible) keep the blur
// region empty rather than sliding it off-surface. Some compositors (Hyprland)
// gate blur on a non-empty region and then blur the whole surface box when the
// clip degenerates to empty, leaving the bar strip blurred while the bar is
// hidden (issue #2656). A null region disables the effect cleanly.
if (!barWindow.barRevealed)
return;
// In frame mode, FrameWindow owns the blur region for the entire screen edge // In frame mode, FrameWindow owns the blur region for the entire screen edge
// (including the bar area). The bar must not set its own competing blur region // (including the bar area). The bar must not set its own competing blur region
// so that frameBlurEnabled acts as the single control for all blur in frame mode. // so that frameBlurEnabled acts as the single control for all blur in frame mode.
@@ -933,19 +933,17 @@ BasePill {
tooltipLoader.active = true; tooltipLoader.active = true;
if (tooltipLoader.item) { if (tooltipLoader.item) {
if (root.isVerticalOrientation) { if (root.isVerticalOrientation) {
const globalPos = delegateItem.mapToGlobal(0, delegateItem.height / 2); const localPos = delegateItem.mapToItem(null, 0, delegateItem.height / 2);
const screenX = root.parentScreen ? root.parentScreen.x : 0;
const screenY = root.parentScreen ? root.parentScreen.y : 0;
const isLeft = root.axis?.edge === "left"; const isLeft = root.axis?.edge === "left";
const tooltipX = isLeft ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS); const tooltipX = isLeft ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
const screenRelativeY = globalPos.y - screenY + root.minTooltipY; const screenRelativeY = localPos.y + root.minTooltipY;
tooltipLoader.item.show(appItem.tooltipText, screenX + tooltipX, screenRelativeY, root.parentScreen, isLeft, !isLeft); tooltipLoader.item.show(appItem.tooltipText, tooltipX, screenRelativeY, root.parentScreen, isLeft, !isLeft);
} else { } else {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height);
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height; const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
const isBottom = root.axis?.edge === "bottom"; const isBottom = root.axis?.edge === "bottom";
const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS); const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS);
tooltipLoader.item.show(appItem.tooltipText, globalPos.x, tooltipY, root.parentScreen, false, false); tooltipLoader.item.show(appItem.tooltipText, localPos.x, tooltipY, root.parentScreen, false, false);
} }
} }
} }
@@ -967,14 +965,12 @@ BasePill {
contextMenuLoader.active = true; contextMenuLoader.active = true;
if (contextMenuLoader.item) { if (contextMenuLoader.item) {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
const screenX = root.parentScreen ? root.parentScreen.x : 0;
const screenY = root.parentScreen ? root.parentScreen.y : 0;
const isBarVertical = root.axis?.isVertical ?? false; const isBarVertical = root.axis?.isVertical ?? false;
const barEdge = root.axis?.edge ?? "top"; const barEdge = root.axis?.edge ?? "top";
let x = globalPos.x - screenX; let x = localPos.x;
let y = globalPos.y - screenY; let y = localPos.y;
switch (barEdge) { switch (barEdge) {
case "bottom": case "bottom":
+15 -20
View File
@@ -118,10 +118,18 @@ BasePill {
width: battery.width + battery.leftMargin + battery.rightMargin width: battery.width + battery.leftMargin + battery.rightMargin
height: battery.height + battery.topMargin + battery.bottomMargin height: battery.height + battery.topMargin + battery.bottomMargin
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton | Qt.RightButton
onPressed: mouse => { onPressed: mouse => {
battery.triggerRipple(this, mouse.x, mouse.y); battery.triggerRipple(this, mouse.x, mouse.y);
toggleBatteryPopup(); if (mouse.button === Qt.LeftButton) {
toggleBatteryPopup();
} else if (mouse.button === Qt.RightButton) {
if (PowerProfileWatcher.available) {
PowerProfileWatcher.cycleProfile();
} else {
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
}
}
} }
onWheel: wheel => { onWheel: wheel => {
var delta = wheel.angleDelta.y; var delta = wheel.angleDelta.y;
@@ -131,33 +139,20 @@ BasePill {
// Check if this is a touchpad // Check if this is a touchpad
if (delta !== 120 && delta !== -120) { if (delta !== 120 && delta !== -120) {
touchpadAccumulator += delta; touchpadAccumulator += delta;
log.info("Acc: " + touchpadAccumulator);
if (Math.abs(touchpadAccumulator) < 500) if (Math.abs(touchpadAccumulator) < 500)
return; return;
delta = touchpadAccumulator; delta = touchpadAccumulator;
touchpadAccumulator = 0; touchpadAccumulator = 0;
} }
log.info("Trigger! Delta: " + delta);
// This is after the other delta checks so it only shows on valid Y scroll if (!DisplayService.brightnessAvailable) {
if (!PowerProfileWatcher.available) {
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
return; return;
} }
const profiles = PowerProfileWatcher.availableProfiles; const step = 5;
var index = profiles.findIndex(profile => PowerProfiles.profile === profile); const change = delta > 0 ? step : -step;
const newBrightness = Math.max(0, Math.min(100, DisplayService.brightnessLevel + change));
if (delta > 0) DisplayService.setBrightness(newBrightness, "", false);
index += 1;
else
index -= 1;
if (index < 0 || index >= profiles.length)
return;
if (!PowerProfileWatcher.applyProfile(profiles[index]))
ToastService.showError(I18n.tr("Failed to set power profile"));
} }
} }
} }
@@ -513,7 +513,7 @@ BasePill {
case "vpn": case "vpn":
return "vpn_lock"; return "vpn_lock";
case "bluetooth": case "bluetooth":
return "bluetooth"; return BluetoothService.connected ? "bluetooth_connected" : "bluetooth";
case "battery": case "battery":
return Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable); return Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable);
case "printer": case "printer":
@@ -698,7 +698,7 @@ BasePill {
case "vpn": case "vpn":
return "vpn_lock"; return "vpn_lock";
case "bluetooth": case "bluetooth":
return "bluetooth"; return BluetoothService.connected ? "bluetooth_connected" : "bluetooth";
case "battery": case "battery":
return Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable); return Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable);
case "printer": case "printer":
@@ -276,15 +276,12 @@ BasePill {
if (root.isVerticalOrientation && root.selectedMount) { if (root.isVerticalOrientation && root.selectedMount) {
tooltipLoader.active = true; tooltipLoader.active = true;
if (tooltipLoader.item) { if (tooltipLoader.item) {
const globalPos = mapToGlobal(width / 2, height / 2); const localPos = mapToItem(null, width / 2, height / 2);
const currentScreen = root.parentScreen || Screen; const currentScreen = root.parentScreen || Screen;
const screenX = currentScreen ? currentScreen.x : 0; const adjustedY = localPos.y + root.minTooltipY;
const screenY = currentScreen ? currentScreen.y : 0;
const relativeY = globalPos.y - screenY;
const adjustedY = relativeY + root.minTooltipY;
const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (currentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS); const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (currentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
const isLeft = root.axis?.edge === "left"; const isLeft = root.axis?.edge === "left";
tooltipLoader.item.show(root.selectedMount.mount, screenX + tooltipX, adjustedY, currentScreen, isLeft, !isLeft); tooltipLoader.item.show(root.selectedMount.mount, tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
} }
} }
} }
@@ -304,13 +304,9 @@ BasePill {
if (root.isVerticalOrientation && activeWindow && activeWindow.appId && root.parentScreen) { if (root.isVerticalOrientation && activeWindow && activeWindow.appId && root.parentScreen) {
tooltipLoader.active = true; tooltipLoader.active = true;
if (tooltipLoader.item) { if (tooltipLoader.item) {
const globalPos = mapToGlobal(width / 2, height / 2); const localPos = mapToItem(null, width / 2, height / 2);
const currentScreen = root.parentScreen; const currentScreen = root.parentScreen;
const screenX = currentScreen ? currentScreen.x : 0; const adjustedY = localPos.y + root.minTooltipY;
const screenY = currentScreen ? currentScreen.y : 0;
const relativeY = globalPos.y - screenY;
// Add minTooltipY offset to account for top bar
const adjustedY = relativeY + root.minTooltipY;
const tooltipX = root.axis?.edge === "left" ? (Theme.barHeight + (barConfig?.spacing ?? 4) + Theme.spacingXS) : (currentScreen.width - Theme.barHeight - (barConfig?.spacing ?? 4) - Theme.spacingXS); const tooltipX = root.axis?.edge === "left" ? (Theme.barHeight + (barConfig?.spacing ?? 4) + Theme.spacingXS) : (currentScreen.width - Theme.barHeight - (barConfig?.spacing ?? 4) - Theme.spacingXS);
const appName = Paths.getAppName(activeWindow.appId, activeDesktopEntry); const appName = Paths.getAppName(activeWindow.appId, activeDesktopEntry);
@@ -318,7 +314,7 @@ BasePill {
const tooltipText = appName + (title ? " • " + title : ""); const tooltipText = appName + (title ? " • " + title : "");
const isLeft = root.axis?.edge === "left"; const isLeft = root.axis?.edge === "left";
tooltipLoader.item.show(tooltipText, screenX + tooltipX, adjustedY, currentScreen, isLeft, !isLeft); tooltipLoader.item.show(tooltipText, tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
} }
} }
} }
@@ -18,6 +18,14 @@ BasePill {
property var widgetData: null property var widgetData: null
property var hoveredItem: null property var hoveredItem: null
onHoveredItemChanged: {
if (hoveredItem)
return;
if (tooltipLoader.item)
tooltipLoader.item.hide();
tooltipLoader.active = false;
}
property var topBar: null property var topBar: null
property bool isAutoHideBar: false property bool isAutoHideBar: false
property Item windowRoot: (Window.window ? Window.window.contentItem : null) property Item windowRoot: (Window.window ? Window.window.contentItem : null)
@@ -236,6 +244,11 @@ BasePill {
delegate: Item { delegate: Item {
id: delegateItem id: delegateItem
Component.onDestruction: {
if (root.hoveredItem === delegateItem)
root.hoveredItem = null;
}
property bool isGrouped: root._groupByApp property bool isGrouped: root._groupByApp
property var groupData: isGrouped ? modelData : null property var groupData: isGrouped ? modelData : null
property var toplevelData: isGrouped ? (modelData.windows.length > 0 ? modelData.windows[0].toplevel : null) : modelData property var toplevelData: isGrouped ? (modelData.windows.length > 0 ? modelData.windows[0].toplevel : null) : modelData
@@ -411,22 +424,16 @@ BasePill {
windowContextMenuLoader.item.triggerBarThickness = root.barThickness; windowContextMenuLoader.item.triggerBarThickness = root.barThickness;
windowContextMenuLoader.item.triggerBarSpacing = root.barSpacing; windowContextMenuLoader.item.triggerBarSpacing = root.barSpacing;
if (root.isVerticalOrientation) { if (root.isVerticalOrientation) {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
const screenX = root.parentScreen ? root.parentScreen.x : 0; const adjustedY = localPos.y + root.minTooltipY;
const screenY = root.parentScreen ? root.parentScreen.y : 0;
const relativeY = globalPos.y - screenY;
// Add minTooltipY offset to account for top bar
const adjustedY = relativeY + root.minTooltipY;
const xPos = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS); const xPos = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
windowContextMenuLoader.item.showAt(xPos, adjustedY, true, root.axis?.edge); windowContextMenuLoader.item.showAt(xPos, adjustedY, true, root.axis?.edge);
} else { } else {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, 0); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, 0);
const screenX = root.parentScreen ? root.parentScreen.x : 0;
const relativeX = globalPos.x - screenX;
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height; const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
const isBottom = root.axis?.edge === "bottom"; const isBottom = root.axis?.edge === "bottom";
const yPos = isBottom ? (screenHeight - root.barThickness - root.barSpacing - 32 - Theme.spacingXS) : (root.barThickness + root.barSpacing + Theme.spacingXS); const yPos = isBottom ? (screenHeight - root.barThickness - root.barSpacing - 32 - Theme.spacingXS) : (root.barThickness + root.barSpacing + Theme.spacingXS);
windowContextMenuLoader.item.showAt(relativeX, yPos, false, root.axis?.edge); windowContextMenuLoader.item.showAt(localPos.x, yPos, false, root.axis?.edge);
} }
} }
} else if (mouse.button === Qt.MiddleButton) { } else if (mouse.button === Qt.MiddleButton) {
@@ -442,33 +449,23 @@ BasePill {
tooltipLoader.active = true; tooltipLoader.active = true;
if (tooltipLoader.item) { if (tooltipLoader.item) {
if (root.isVerticalOrientation) { if (root.isVerticalOrientation) {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
const screenX = root.parentScreen ? root.parentScreen.x : 0;
const screenY = root.parentScreen ? root.parentScreen.y : 0;
const relativeY = globalPos.y - screenY;
const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS); const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
const isLeft = root.axis?.edge === "left"; const isLeft = root.axis?.edge === "left";
const adjustedY = relativeY + root.minTooltipY; const adjustedY = localPos.y + root.minTooltipY;
const finalX = screenX + tooltipX; tooltipLoader.item.show(delegateItem.tooltipText, tooltipX, adjustedY, root.parentScreen, isLeft, !isLeft);
tooltipLoader.item.show(delegateItem.tooltipText, finalX, adjustedY, root.parentScreen, isLeft, !isLeft);
} else { } else {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height);
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height; const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
const isBottom = root.axis?.edge === "bottom"; const isBottom = root.axis?.edge === "bottom";
const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS); const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS);
tooltipLoader.item.show(delegateItem.tooltipText, globalPos.x, tooltipY, root.parentScreen, false, false); tooltipLoader.item.show(delegateItem.tooltipText, localPos.x, tooltipY, root.parentScreen, false, false);
} }
} }
} }
onExited: { onExited: {
if (root.hoveredItem === delegateItem) { if (root.hoveredItem === delegateItem)
root.hoveredItem = null; root.hoveredItem = null;
if (tooltipLoader.item) {
tooltipLoader.item.hide();
}
tooltipLoader.active = false;
}
} }
} }
} }
@@ -491,6 +488,11 @@ BasePill {
delegate: Item { delegate: Item {
id: delegateItem id: delegateItem
Component.onDestruction: {
if (root.hoveredItem === delegateItem)
root.hoveredItem = null;
}
property bool isGrouped: root._groupByApp property bool isGrouped: root._groupByApp
property var groupData: isGrouped ? modelData : null property var groupData: isGrouped ? modelData : null
property var toplevelData: isGrouped ? (modelData.windows.length > 0 ? modelData.windows[0].toplevel : null) : modelData property var toplevelData: isGrouped ? (modelData.windows.length > 0 ? modelData.windows[0].toplevel : null) : modelData
@@ -665,22 +667,16 @@ BasePill {
windowContextMenuLoader.item.triggerBarThickness = root.barThickness; windowContextMenuLoader.item.triggerBarThickness = root.barThickness;
windowContextMenuLoader.item.triggerBarSpacing = root.barSpacing; windowContextMenuLoader.item.triggerBarSpacing = root.barSpacing;
if (root.isVerticalOrientation) { if (root.isVerticalOrientation) {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
const screenX = root.parentScreen ? root.parentScreen.x : 0; const adjustedY = localPos.y + root.minTooltipY;
const screenY = root.parentScreen ? root.parentScreen.y : 0;
const relativeY = globalPos.y - screenY;
// Add minTooltipY offset to account for top bar
const adjustedY = relativeY + root.minTooltipY;
const xPos = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS); const xPos = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
windowContextMenuLoader.item.showAt(xPos, adjustedY, true, root.axis?.edge); windowContextMenuLoader.item.showAt(xPos, adjustedY, true, root.axis?.edge);
} else { } else {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, 0); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, 0);
const screenX = root.parentScreen ? root.parentScreen.x : 0;
const relativeX = globalPos.x - screenX;
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height; const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
const isBottom = root.axis?.edge === "bottom"; const isBottom = root.axis?.edge === "bottom";
const yPos = isBottom ? (screenHeight - root.barThickness - root.barSpacing - 32 - Theme.spacingXS) : (root.barThickness + root.barSpacing + Theme.spacingXS); const yPos = isBottom ? (screenHeight - root.barThickness - root.barSpacing - 32 - Theme.spacingXS) : (root.barThickness + root.barSpacing + Theme.spacingXS);
windowContextMenuLoader.item.showAt(relativeX, yPos, false, root.axis?.edge); windowContextMenuLoader.item.showAt(localPos.x, yPos, false, root.axis?.edge);
} }
} }
} else if (mouse.button === Qt.MiddleButton) { } else if (mouse.button === Qt.MiddleButton) {
@@ -696,33 +692,23 @@ BasePill {
tooltipLoader.active = true; tooltipLoader.active = true;
if (tooltipLoader.item) { if (tooltipLoader.item) {
if (root.isVerticalOrientation) { if (root.isVerticalOrientation) {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
const screenX = root.parentScreen ? root.parentScreen.x : 0;
const screenY = root.parentScreen ? root.parentScreen.y : 0;
const relativeY = globalPos.y - screenY;
const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS); const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
const isLeft = root.axis?.edge === "left"; const isLeft = root.axis?.edge === "left";
const adjustedY = relativeY + root.minTooltipY; const adjustedY = localPos.y + root.minTooltipY;
const finalX = screenX + tooltipX; tooltipLoader.item.show(delegateItem.tooltipText, tooltipX, adjustedY, root.parentScreen, isLeft, !isLeft);
tooltipLoader.item.show(delegateItem.tooltipText, finalX, adjustedY, root.parentScreen, isLeft, !isLeft);
} else { } else {
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height); const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height);
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height; const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
const isBottom = root.axis?.edge === "bottom"; const isBottom = root.axis?.edge === "bottom";
const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS); const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS);
tooltipLoader.item.show(delegateItem.tooltipText, globalPos.x, tooltipY, root.parentScreen, false, false); tooltipLoader.item.show(delegateItem.tooltipText, localPos.x, tooltipY, root.parentScreen, false, false);
} }
} }
} }
onExited: { onExited: {
if (root.hoveredItem === delegateItem) { if (root.hoveredItem === delegateItem)
root.hoveredItem = null; root.hoveredItem = null;
if (tooltipLoader.item) {
tooltipLoader.item.hide();
}
tooltipLoader.active = false;
}
} }
} }
} }
+5 -8
View File
@@ -106,18 +106,15 @@ BasePill {
} }
if (root.isVerticalOrientation) { if (root.isVerticalOrientation) {
const globalPos = mapToGlobal(width / 2, height / 2); const localPos = mapToItem(null, width / 2, height / 2);
const currentScreen = root.parentScreen || Screen; const currentScreen = root.parentScreen || Screen;
const screenX = currentScreen ? currentScreen.x : 0; const adjustedY = localPos.y + root.minTooltipY;
const screenY = currentScreen ? currentScreen.y : 0;
const relativeY = globalPos.y - screenY;
const adjustedY = relativeY + root.minTooltipY;
const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (currentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS); const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (currentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
const isLeft = root.axis?.edge === "left"; const isLeft = root.axis?.edge === "left";
tooltipLoader.item.show(tooltipText, screenX + tooltipX, adjustedY, currentScreen, isLeft, !isLeft); tooltipLoader.item.show(tooltipText, tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
} else { } else {
const isBottom = root.axis?.edge === "bottom"; const isBottom = root.axis?.edge === "bottom";
const globalPos = mapToGlobal(width / 2, 0); const localPos = mapToItem(null, width / 2, 0);
const currentScreen = root.parentScreen || Screen; const currentScreen = root.parentScreen || Screen;
let tooltipY; let tooltipY;
@@ -128,7 +125,7 @@ BasePill {
tooltipY = root.barThickness + root.barSpacing + Theme.spacingXS; tooltipY = root.barThickness + root.barSpacing + Theme.spacingXS;
} }
tooltipLoader.item.show(tooltipText, globalPos.x, tooltipY, currentScreen, false, false); tooltipLoader.item.show(tooltipText, localPos.x, tooltipY, currentScreen, false, false);
} }
} }
onExited: { onExited: {
@@ -9,9 +9,8 @@ BasePill {
visible: SettingsData.weatherEnabled visible: SettingsData.weatherEnabled
Ref { Component.onCompleted: WeatherService.addRef()
service: WeatherService Component.onDestruction: WeatherService.removeRef()
}
content: Component { content: Component {
Item { Item {
@@ -1192,38 +1192,25 @@ Item {
return Math.max(baseHeight + iconsExtraHeight, contentImplicitHeight + padding); return Math.max(baseHeight + iconsExtraHeight, contentImplicitHeight + padding);
} }
readonly property color unfocusedColor: { function colorFromMode(mode, fallbackColor, customColor, customFallbackColor) {
switch (SettingsData.workspaceUnfocusedColorMode) { switch (mode) {
case "s": case "primary":
return Theme.surface; case "pri":
case "sc":
return Theme.surfaceContainer;
case "sch":
return Theme.surfaceContainerHigh;
default:
return Theme.surfaceTextAlpha;
}
}
readonly property color activeColor: {
switch (SettingsData.workspaceColorMode) {
case "s":
return Theme.surface;
case "sc":
return Theme.surfaceContainer;
case "sch":
return Theme.surfaceContainerHigh;
case "none":
return unfocusedColor;
default:
return Theme.primary; return Theme.primary;
} case "primaryContainer":
} return Theme.primaryContainer;
case "secondary":
readonly property color occupiedColor: {
switch (SettingsData.workspaceOccupiedColorMode) {
case "sec": case "sec":
return Theme.secondary; return Theme.secondary;
case "secondaryContainer":
return Theme.secondaryContainer;
case "tertiary":
case "ter":
return Theme.tertiary;
case "tertiaryContainer":
return Theme.tertiaryContainer;
case "surfaceText":
return Theme.surfaceText;
case "s": case "s":
return Theme.surface; return Theme.surface;
case "sc": case "sc":
@@ -1232,37 +1219,34 @@ Item {
return Theme.surfaceContainerHigh; return Theme.surfaceContainerHigh;
case "schh": case "schh":
return Theme.surfaceContainerHighest; return Theme.surfaceContainerHighest;
default: case "error":
return unfocusedColor; case "err":
}
}
readonly property color urgentColor: {
switch (SettingsData.workspaceUrgentColorMode) {
case "primary":
return Theme.primary;
case "secondary":
return Theme.secondary;
case "s":
return Theme.surface;
case "sc":
return Theme.surfaceContainer;
default:
return Theme.error; return Theme.error;
case "custom":
return Theme.safeColor(customColor, customFallbackColor);
default:
return fallbackColor;
} }
} }
readonly property color focusedBorderColor: { readonly property color unfocusedColor: colorFromMode(SettingsData.workspaceUnfocusedColorMode, Theme.surfaceTextAlpha, SettingsData.workspaceUnfocusedCustomColor, Theme.surfaceTextAlpha)
switch (SettingsData.workspaceFocusedBorderColor) {
case "surfaceText": readonly property color activeColor: {
return Theme.surfaceText; if (SettingsData.workspaceColorMode === "none")
case "secondary": return unfocusedColor;
return Theme.secondary; return colorFromMode(SettingsData.workspaceColorMode, Theme.primary, SettingsData.workspaceFocusedCustomColor, Theme.primary);
default:
return Theme.primary;
}
} }
readonly property color occupiedColor: {
if (SettingsData.workspaceOccupiedColorMode === "none")
return unfocusedColor;
return colorFromMode(SettingsData.workspaceOccupiedColorMode, unfocusedColor, SettingsData.workspaceOccupiedCustomColor, Theme.secondary);
}
readonly property color urgentColor: colorFromMode(SettingsData.workspaceUrgentColorMode, Theme.error, SettingsData.workspaceUrgentCustomColor, Theme.error)
readonly property color focusedBorderColor: colorFromMode(SettingsData.workspaceFocusedBorderColor, Theme.primary, SettingsData.workspaceFocusedBorderCustomColor, Theme.primary)
function getContrastingIconColor(bgColor) { function getContrastingIconColor(bgColor) {
const luminance = 0.299 * bgColor.r + 0.587 * bgColor.g + 0.114 * bgColor.b; const luminance = 0.299 * bgColor.r + 0.587 * bgColor.g + 0.114 * bgColor.b;
return luminance > 0.4 ? Qt.rgba(0.15, 0.15, 0.15, 1) : Qt.rgba(0.8, 0.8, 0.8, 1); return luminance > 0.4 ? Qt.rgba(0.15, 0.15, 0.15, 1) : Qt.rgba(0.8, 0.8, 0.8, 1);
@@ -18,6 +18,9 @@ Item {
property bool showHourly: false property bool showHourly: false
property bool available: WeatherService.weather.available property bool available: WeatherService.weather.available
Component.onCompleted: WeatherService.addRef()
Component.onDestruction: WeatherService.removeRef()
function syncFrom(type) { function syncFrom(type) {
if (!dailyLoader.item || !hourlyLoader.item) if (!dailyLoader.item || !hourlyLoader.item)
return; return;
+5 -7
View File
@@ -511,13 +511,11 @@ Variants {
if (!dock.hoveredButton || !dock.reveal || slideXAnimation.running || slideYAnimation.running) if (!dock.hoveredButton || !dock.reveal || slideXAnimation.running || slideYAnimation.running)
return; return;
const buttonGlobalPos = dock.hoveredButton.mapToGlobal(0, 0); const buttonLocalPos = dock.hoveredButton.mapToItem(null, 0, 0);
const tooltipText = dock.hoveredButton.tooltipText || ""; const tooltipText = dock.hoveredButton.tooltipText || "";
if (!tooltipText) if (!tooltipText)
return; return;
const screenX = dock.screen ? (dock.screen.x || 0) : 0;
const screenY = dock.screen ? (dock.screen.y || 0) : 0;
const screenHeight = dock.screen ? dock.screen.height : 0; const screenHeight = dock.screen ? dock.screen.height : 0;
const gap = Theme.spacingS; const gap = Theme.spacingS;
@@ -527,19 +525,19 @@ Variants {
if (!dock.isVertical) { if (!dock.isVertical) {
const isBottom = SettingsData.dockPosition === SettingsData.Position.Bottom; const isBottom = SettingsData.dockPosition === SettingsData.Position.Bottom;
const globalX = buttonGlobalPos.x + btnW / 2 + adjacentLeftBarWidth; const tooltipX = buttonLocalPos.x + btnW / 2 + adjacentLeftBarWidth;
const tooltipHeight = 32; const tooltipHeight = 32;
const totalFromEdge = bgMargin + dockBackground.height + dock.borderThickness + gap; const totalFromEdge = bgMargin + dockBackground.height + dock.borderThickness + gap;
const screenRelativeY = isBottom ? (screenHeight - totalFromEdge - tooltipHeight) : totalFromEdge; const screenRelativeY = isBottom ? (screenHeight - totalFromEdge - tooltipHeight) : totalFromEdge;
dockTooltip.show(tooltipText, globalX, screenRelativeY, dock.screen, false, false); dockTooltip.show(tooltipText, tooltipX, screenRelativeY, dock.screen, false, false);
return; return;
} }
const isLeft = SettingsData.dockPosition === SettingsData.Position.Left; const isLeft = SettingsData.dockPosition === SettingsData.Position.Left;
const screenWidth = dock.screen ? dock.screen.width : 0; const screenWidth = dock.screen ? dock.screen.width : 0;
const totalFromEdge = bgMargin + dockBackground.width + dock.borderThickness + gap; const totalFromEdge = bgMargin + dockBackground.width + dock.borderThickness + gap;
const tooltipX = isLeft ? (screenX + totalFromEdge) : (screenX + screenWidth - totalFromEdge); const tooltipX = isLeft ? totalFromEdge : (screenWidth - totalFromEdge);
const screenRelativeY = buttonGlobalPos.y - screenY + btnH / 2 + adjacentTopBarHeight; const screenRelativeY = buttonLocalPos.y + btnH / 2 + adjacentTopBarHeight;
dockTooltip.show(tooltipText, tooltipX, screenRelativeY, dock.screen, isLeft, !isLeft); dockTooltip.show(tooltipText, tooltipX, screenRelativeY, dock.screen, isLeft, !isLeft);
} }
+4
View File
@@ -60,6 +60,10 @@ Scope {
function lock() { function lock() {
if (SettingsData.customPowerActionLock?.length > 0) { if (SettingsData.customPowerActionLock?.length > 0) {
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]); Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]);
// The custom locker manages its own surface; DMS never engages
// WlSessionLock here, so isShellLocked stays false and the fade
// overlay would never be dismissed. Hand off by dismissing it now.
IdleService.dismissFadeToLock();
return; return;
} }
if (shouldLock || pendingLock) if (shouldLock || pendingLock)
+28 -7
View File
@@ -23,6 +23,9 @@ Scope {
property string u2fPendingMode property string u2fPendingMode
property string buffer property string buffer
property var attemptInfoMessages: []
property bool lockoutAnnouncedThisAttempt: false
signal flashMsg signal flashMsg
signal unlockRequested signal unlockRequested
@@ -118,23 +121,37 @@ Scope {
configDirectory: (dankshellConfigWatcher.loaded || nixosMarker.loaded || root.runningFromNixStore) ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" configDirectory: (dankshellConfigWatcher.loaded || nixosMarker.loaded || root.runningFromNixStore) ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam"
onMessageChanged: { onMessageChanged: {
if (message.startsWith("The account is locked")) { // collected by position, not text, so it works in any locale
root.lockMessage = message; if (message.length > 0 && !responseRequired)
} else if (root.lockMessage && message.endsWith(" left to unlock)")) { root.attemptInfoMessages = root.attemptInfoMessages.concat([message]);
root.lockMessage += "\n" + message;
} else if (root.lockMessage && message && message.length > 0) {
root.lockMessage = "";
}
} }
onResponseRequiredChanged: { onResponseRequiredChanged: {
if (!responseRequired) if (!responseRequired)
return; return;
const notice = root.attemptInfoMessages.filter(m => m !== message);
if (notice.length > 0) {
root.lockMessage = notice.join("\n");
root.lockoutAnnouncedThisAttempt = true;
}
root.attemptInfoMessages = [];
respond(root.buffer); respond(root.buffer);
} }
onCompleted: res => { onCompleted: res => {
// requisite preauth can lock without ever prompting; surface it here too
if (!root.lockoutAnnouncedThisAttempt) {
if (root.attemptInfoMessages.length > 0) {
root.lockMessage = root.attemptInfoMessages.join("\n");
root.lockoutAnnouncedThisAttempt = true;
} else {
root.lockMessage = "";
}
root.attemptInfoMessages = [];
}
if (res === PamResult.Success) { if (res === PamResult.Success) {
if (!root.unlockInProgress) { if (!root.unlockInProgress) {
fprint.abort(); fprint.abort();
@@ -168,6 +185,8 @@ Scope {
function onActiveChanged() { function onActiveChanged() {
if (passwd.active) { if (passwd.active) {
root.attemptInfoMessages = [];
root.lockoutAnnouncedThisAttempt = false;
passwdActiveTimeout.restart(); passwdActiveTimeout.restart();
} else { } else {
passwdActiveTimeout.running = false; passwdActiveTimeout.running = false;
@@ -393,6 +412,8 @@ Scope {
root.u2fPending = false; root.u2fPending = false;
root.u2fPendingMode = ""; root.u2fPendingMode = "";
root.lockMessage = ""; root.lockMessage = "";
root.attemptInfoMessages = [];
root.lockoutAnnouncedThisAttempt = false;
root.resetAuthFlows(); root.resetAuthFlows();
fprint.checkAvail(); fprint.checkAvail();
u2f.checkAvail(); u2f.checkAvail();
@@ -0,0 +1,47 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import qs.Services
Singleton {
id: root
function connectToNetwork(network, options) {
if (!network)
return;
const actionOptions = options || {};
const ssid = network.ssid || "";
if (!ssid)
return;
const connected = actionOptions.connected ?? network.connected ?? (ssid === NetworkService.currentWifiSSID);
if (connected) {
if (actionOptions.disconnectWhenConnected ?? false)
NetworkService.disconnectWifi();
return;
}
if (shouldPromptForCredentials(network)) {
PopoutService.showWifiPasswordModal(ssid);
return;
}
NetworkService.connectToWifi(ssid);
}
function connectToNetworkFromDetails(ssid, secured, saved, enterprise, connected, options) {
connectToNetwork({
ssid: ssid,
secured: secured,
saved: saved,
enterprise: enterprise,
connected: connected
}, options);
}
function shouldPromptForCredentials(network) {
return (network.secured ?? false) && !(network.saved ?? false);
}
}
+344
View File
@@ -0,0 +1,344 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
Process {
id: applyLimitProcess
command: ["pkexec", "sh", "-c", "
for bat in /sys/class/power_supply/BAT*; do
if [ -f \"$bat/charge_control_limit_max\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_limit_max\"
elif [ -f \"$bat/charge_stop_threshold\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_stop_threshold\"
elif [ -f \"$bat/charge_control_end_threshold\" ]; then
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_end_threshold\"
fi
done
"]
running: false
onExited: exitCode => {
if (exitCode !== 0) {
ToastService.showError(I18n.tr("Failed to apply charge limit to system"), I18n.tr("Process exited with code %1").arg(exitCode));
} else {
ToastService.showInfo(I18n.tr("Charge limit applied successfully"), I18n.tr("Limit set to %1%").arg(SettingsData.batteryChargeLimit));
}
}
}
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: mainColumn.height + Theme.spacingXL
contentWidth: width
Column {
id: mainColumn
topPadding: 4
width: Math.min(550, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
// 1. Information Card
SettingsCard {
width: parent.width
iconName: "battery_charging_full"
title: I18n.tr("Battery Status")
settingKey: "batteryStatusCard"
Column {
width: parent.width
spacing: Theme.spacingM
Row {
width: parent.width
StyledText {
text: I18n.tr("Power Source")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
width: parent.width / 2
}
StyledText {
text: BatteryService.isPluggedIn ? I18n.tr("AC Adapter (Plugged In)") : I18n.tr("Battery Power")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width / 2
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.1
}
Row {
width: parent.width
StyledText {
text: I18n.tr("Charge Level")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
width: parent.width / 2
}
StyledText {
text: `${BatteryService.batteryLevel}%`
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width / 2
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.1
}
Row {
width: parent.width
StyledText {
text: I18n.tr("Status")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
width: parent.width / 2
}
StyledText {
text: BatteryService.batteryStatus
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width / 2
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.1
}
Row {
width: parent.width
StyledText {
text: I18n.tr("Estimated Time")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
width: parent.width / 2
}
StyledText {
text: BatteryService.formatTimeRemaining()
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width / 2
}
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.1
}
Row {
width: parent.width
StyledText {
text: I18n.tr("Battery Health")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
width: parent.width / 2
}
StyledText {
text: BatteryService.batteryHealth
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width / 2
}
}
}
}
// 2. Threshold & Limits Card
SettingsCard {
width: parent.width
iconName: "tune"
title: I18n.tr("Battery Protection & Charging")
settingKey: "batteryProtection"
SettingsSliderRow {
settingKey: "batteryChargeLimit"
text: I18n.tr("Battery Charge Limit")
description: I18n.tr("Limit the maximum battery charge level to extend lifespan.")
value: SettingsData.batteryChargeLimit
minimum: 50
maximum: 100
defaultValue: 100
onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue)
}
Row {
width: parent.width
height: applyButton.height
layoutDirection: Qt.RightToLeft
DankButton {
id: applyButton
text: I18n.tr("Apply to Hardware")
iconName: "lock"
backgroundColor: Theme.primary
textColor: Theme.onPrimary
onClicked: {
applyLimitProcess.running = true;
}
}
}
SettingsToggleRow {
settingKey: "batteryNotifyChargeLimit"
text: I18n.tr("Notify when limit is reached")
description: I18n.tr("Show a notification when battery reaches the charge limit.")
checked: SettingsData.batteryNotifyChargeLimit
onToggled: checked => SettingsData.set("batteryNotifyChargeLimit", checked)
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.15
}
SettingsSliderRow {
settingKey: "batteryLowThreshold"
text: I18n.tr("Low Battery Threshold")
description: I18n.tr("Set the percentage at which the battery is considered low.")
value: SettingsData.batteryLowThreshold
minimum: 5
maximum: 40
defaultValue: 20
onSliderValueChanged: newValue => SettingsData.set("batteryLowThreshold", newValue)
}
SettingsToggleRow {
settingKey: "batteryNotifyLow"
text: I18n.tr("Low Battery Notifications")
description: I18n.tr("Show a warning popup when battery is running low.")
checked: SettingsData.batteryNotifyLow
onToggled: checked => SettingsData.set("batteryNotifyLow", checked)
}
SettingsButtonGroupRow {
settingKey: "batteryNotificationType"
text: I18n.tr("Notification Type")
description: I18n.tr("Choose how to be notified about battery alerts.")
model: [I18n.tr("Toast"), I18n.tr("Notification")]
currentIndex: SettingsData.batteryNotificationType
onSelectionChanged: (index, selected) => {
if (selected) {
SettingsData.set("batteryNotificationType", index);
}
}
}
SettingsToggleRow {
settingKey: "batteryAutoPowerSaver"
text: I18n.tr("Auto Power Saver")
description: I18n.tr("Automatically turn on Power Saver profile when battery is low.")
checked: SettingsData.batteryAutoPowerSaver
onToggled: checked => SettingsData.set("batteryAutoPowerSaver", checked)
}
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.15
}
StyledText {
text: I18n.tr("Critical Battery Alert")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.DemiBold
color: Theme.surfaceText
topPadding: Theme.spacingM
}
SettingsSliderRow {
settingKey: "batteryCriticalThreshold"
text: I18n.tr("Critical Threshold")
description: I18n.tr("Battery percentage to trigger a critical alert.")
value: SettingsData.batteryCriticalThreshold
minimum: 1
maximum: 30
defaultValue: 10
onSliderValueChanged: newValue => SettingsData.set("batteryCriticalThreshold", newValue)
}
SettingsToggleRow {
settingKey: "batteryNotifyCritical"
text: I18n.tr("Critical Battery Notifications")
description: I18n.tr("Show an urgent alert when battery reaches critical level.")
checked: SettingsData.batteryNotifyCritical
onToggled: checked => SettingsData.set("batteryNotifyCritical", checked)
}
}
// 3. Power Profiles Card
SettingsCard {
width: parent.width
iconName: "power"
title: I18n.tr("Power Profiles Auto-Switching")
settingKey: "powerProfilesAuto"
SettingsDropdownRow {
settingKey: "acProfileName"
text: I18n.tr("Profile when Plugged In (AC)")
description: I18n.tr("Power profile to use when AC power is connected.")
options: [I18n.tr("Don't Change"), Theme.getPowerProfileLabel(0), Theme.getPowerProfileLabel(1), Theme.getPowerProfileLabel(2)]
currentValue: {
const val = SettingsData.acProfileName;
const idx = ["", "0", "1", "2"].indexOf(val);
return idx >= 0 ? options[idx] : options[0];
}
onValueChanged: value => {
const idx = options.indexOf(value);
if (idx >= 0) {
SettingsData.set("acProfileName", ["", "0", "1", "2"][idx]);
}
}
}
SettingsDropdownRow {
settingKey: "batteryProfileName"
text: I18n.tr("Profile when on Battery")
description: I18n.tr("Power profile to use when running on battery power.")
options: [I18n.tr("Don't Change"), Theme.getPowerProfileLabel(0), Theme.getPowerProfileLabel(1), Theme.getPowerProfileLabel(2)]
currentValue: {
const val = SettingsData.batteryProfileName;
const idx = ["", "0", "1", "2"].indexOf(val);
return idx >= 0 ? options[idx] : options[0];
}
onValueChanged: value => {
const idx = options.indexOf(value);
if (idx >= 0) {
SettingsData.set("batteryProfileName", ["", "0", "1", "2"][idx]);
}
}
}
}
}
}
}
@@ -464,6 +464,16 @@ Item {
onToggled: checked => SettingsData.set("clipboardEnterToPaste", checked) onToggled: checked => SettingsData.set("clipboardEnterToPaste", checked)
} }
SettingsToggleRow {
tab: "clipboard"
tags: ["clipboard", "filter", "type", "remember", "behavior"]
settingKey: "clipboardRememberTypeFilter"
text: I18n.tr("Remember Type Filter", "Clipboard behavior setting title")
description: I18n.tr("Keep the clipboard type filter when reopening history", "Clipboard behavior setting description")
checked: SettingsData.clipboardRememberTypeFilter
onToggled: checked => SettingsData.set("clipboardRememberTypeFilter", checked)
}
SettingsButtonGroupRow { SettingsButtonGroupRow {
tab: "clipboard" tab: "clipboard"
tags: ["clipboard", "actions", "buttons", "hide", "density", "pin", "edit", "delete"] tags: ["clipboard", "actions", "buttons", "hide", "density", "pin", "edit", "delete"]
@@ -0,0 +1,183 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Column {
id: root
property string text: ""
property string description: ""
property string settingKey: ""
property string tab: ""
property var tags: []
property var options: []
property string currentMode: "default"
property color customColor: "#6750A4"
property string pickerTitle: text
property int dropdownWidth: 230
property color defaultColor: Theme.primary
readonly property var optionColorMap: {
var map = {};
for (var i = 0; i < options.length; i++)
map[options[i].label] = root.colorForValue(options[i].value);
return map;
}
function colorForValue(value) {
switch (value) {
case "custom":
return root.customColor;
case "none":
return "transparent";
case "default":
return root.defaultColor;
default:
return Theme.roleColor(value);
}
}
signal modeSelected(string mode)
signal customColorSelected(color selectedColor)
width: parent?.width ?? 0
spacing: Theme.spacingS
function optionLabels() {
return options.map(option => option.label);
}
function optionLabel(value) {
for (var i = 0; i < options.length; i++) {
if (options[i].value === value)
return options[i].label;
}
return options.length > 0 ? options[0].label : "";
}
function optionValue(label) {
for (var i = 0; i < options.length; i++) {
if (options[i].label === label)
return options[i].value;
}
return options.length > 0 ? options[0].value : "default";
}
function openCustomColorPicker() {
PopoutService.colorPickerModal.selectedColor = root.customColor;
PopoutService.colorPickerModal.pickerTitle = root.pickerTitle;
PopoutService.colorPickerModal.onColorSelectedCallback = function (selectedColor) {
root.customColorSelected(selectedColor);
root.modeSelected("custom");
};
PopoutService.colorPickerModal.show();
}
SettingsDropdownRow {
text: root.text
description: root.description
tab: root.tab
settingKey: root.settingKey
tags: root.tags
options: root.optionLabels()
optionColorMap: root.optionColorMap
currentValue: root.optionLabel(root.currentMode)
dropdownWidth: root.dropdownWidth
onValueChanged: value => root.modeSelected(root.optionValue(value))
}
Item {
width: parent.width
height: root.currentMode === "custom" ? customChip.height : 0
opacity: root.currentMode === "custom" ? 1 : 0
clip: true
Behavior on height {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Rectangle {
id: customChip
width: parent.width
height: 56
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
spacing: Theme.spacingM
Rectangle {
width: 36
height: 36
radius: 18
color: root.customColor
border.color: Theme.outline
border.width: 1
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: "colorize"
size: 16
color: (root.customColor.r * 0.299 + root.customColor.g * 0.587 + root.customColor.b * 0.114) > 0.5 ? "#000000" : "#ffffff"
}
}
Column {
width: parent.width - 36 - editIcon.width - Theme.spacingM * 2
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Custom Color")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: root.customColor.toString()
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
horizontalAlignment: Text.AlignLeft
}
}
DankIcon {
id: editIcon
name: "edit"
size: Theme.iconSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
StateLayer {
stateColor: Theme.surfaceText
onClicked: root.openCustomColorPicker()
}
}
}
}
@@ -858,13 +858,6 @@ Item {
} }
} }
SettingsControlledByFrame {
visible: dankBarTab.appearanceOnly && SettingsData.frameEnabled
parentModal: dankBarTab.parentModal
settingLabel: I18n.tr("Bar spacing and size")
reason: I18n.tr("Managed by Frame")
}
SettingsCard { SettingsCard {
tab: "appearance" tab: "appearance"
iconName: "space_bar" iconName: "space_bar"
+1 -1
View File
@@ -769,7 +769,7 @@ Item {
spacing: Theme.spacingS spacing: Theme.spacingS
Repeater { Repeater {
model: ["dms_settings", "dms_notepad", "dms_sysmon", "dms_settings_search", "dms_clipboard_search"] model: ["dms_settings", "dms_notepad", "dms_sysmon", "dms_settings_search", "dms_clipboard_search", "dms_colorpicker"]
delegate: Rectangle { delegate: Rectangle {
id: pluginDelegate id: pluginDelegate
+497 -17
View File
@@ -1,8 +1,10 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import qs.Common import qs.Common
import qs.Modules.Network
import qs.Modules.Settings.Widgets import qs.Modules.Settings.Widgets
import qs.Modals.Common import qs.Modals.Common
import qs.Services import qs.Services
@@ -16,6 +18,7 @@ Item {
Component.onCompleted: { Component.onCompleted: {
NetworkService.addRef(); NetworkService.addRef();
Qt.callLater(() => NetworkService.refreshSavedWifiNetworks());
} }
Component.onDestruction: { Component.onDestruction: {
@@ -40,6 +43,7 @@ Item {
id: root id: root
property string expandedWifiSsid: "" property string expandedWifiSsid: ""
property string expandedSavedWifiSsid: ""
property int maxPinnedWifiNetworks: 3 property int maxPinnedWifiNetworks: 3
function normalizePinList(value) { function normalizePinList(value) {
@@ -84,6 +88,79 @@ Item {
settingKey: "networkWifi" settingKey: "networkWifi"
tags: ["wifi", "wi-fi", "wireless", "network", "ssid", "adapter", "radio"] tags: ["wifi", "wi-fi", "wireless", "network", "ssid", "adapter", "radio"]
function visibleWifiBySsid(ssid) {
const networks = NetworkService.wifiNetworks || [];
return networks.find(network => network.ssid === ssid) || null;
}
function mergedSavedWifiNetworks() {
const saved = NetworkService.savedWifiNetworks || [];
const supportsSavedWifiState = DMSService.apiVersion >= NetworkService.savedWifiStateApiVersion;
const result = [];
const seen = new Set();
for (const network of saved) {
if (!network?.ssid || seen.has(network.ssid))
continue;
const isOutOfRange = supportsSavedWifiState ? network.outOfRange === true : false;
const visibleNetwork = !isOutOfRange ? visibleWifiBySsid(network.ssid) : null;
if (visibleNetwork) {
result.push(Object.assign({}, network, visibleNetwork, {
saved: true,
autoconnect: network.autoconnect ?? visibleNetwork.autoconnect,
hidden: (network.hidden || false) || (visibleNetwork.hidden || false),
outOfRange: false
}));
} else {
result.push(Object.assign({}, network, {
saved: true,
outOfRange: isOutOfRange
}));
}
seen.add(network.ssid);
}
return result;
}
function sortedSavedWifiNetworks() {
const ssid = NetworkService.currentWifiSSID;
const pinnedList = root.getPinnedWifiNetworks();
let sorted = root.mergedSavedWifiNetworks();
sorted.sort((a, b) => {
const aPinnedIndex = pinnedList.indexOf(a.ssid);
const bPinnedIndex = pinnedList.indexOf(b.ssid);
if (aPinnedIndex !== -1 || bPinnedIndex !== -1) {
if (aPinnedIndex === -1)
return 1;
if (bPinnedIndex === -1)
return -1;
return aPinnedIndex - bPinnedIndex;
}
if (a.ssid === ssid)
return -1;
if (b.ssid === ssid)
return 1;
if ((a.outOfRange || false) !== (b.outOfRange || false))
return (a.outOfRange || false) ? 1 : -1;
if ((a.signal || 0) !== (b.signal || 0))
return (b.signal || 0) - (a.signal || 0);
return (a.ssid || "").localeCompare(b.ssid || "");
});
return sorted;
}
function showForgetNetworkConfirm(ssid) {
forgetNetworkConfirm.showWithOptions({
title: I18n.tr("Forget Network"),
message: I18n.tr("Forget \"%1\"?").arg(ssid),
confirmText: I18n.tr("Forget"),
confirmColor: Theme.error,
onConfirm: () => NetworkService.forgetWifiNetwork(ssid)
});
}
Column { Column {
id: wifiSection id: wifiSection
@@ -563,7 +640,7 @@ Item {
DankActionButton { DankActionButton {
iconName: "qr_code" iconName: "qr_code"
buttonSize: 28 buttonSize: 28
visible: modelData.secured && modelData.saved visible: modelData.secured && modelData.saved && !(modelData.enterprise || false)
onClicked: { onClicked: {
PopoutService.showWifiQRCodeModal(modelData.ssid); PopoutService.showWifiQRCodeModal(modelData.ssid);
} }
@@ -584,13 +661,7 @@ Item {
iconColor: Theme.error iconColor: Theme.error
visible: modelData.saved || isConnected visible: modelData.saved || isConnected
onClicked: { onClicked: {
forgetNetworkConfirm.showWithOptions({ root.showForgetNetworkConfirm(modelData.ssid);
title: I18n.tr("Forget Network"),
message: I18n.tr("Forget \"%1\"?").arg(modelData.ssid),
confirmText: I18n.tr("Forget"),
confirmColor: Theme.error,
onConfirm: () => NetworkService.forgetWifiNetwork(modelData.ssid)
});
} }
} }
} }
@@ -603,15 +674,10 @@ Item {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
if (isConnected) { WifiConnectionActions.connectToNetwork(modelData, {
NetworkService.disconnectWifi(); connected: isConnected,
return; disconnectWhenConnected: true
} });
if (modelData.secured && !modelData.saved && (DMSService.apiVersion < 7 || modelData.enterprise)) {
PopoutService.showWifiPasswordModal(modelData.ssid);
return;
}
NetworkService.connectToWifi(modelData.ssid);
} }
} }
} }
@@ -756,6 +822,420 @@ Item {
} }
} }
} }
SettingsCard {
id: savedWifiCard
readonly property var savedNetworks: root.sortedSavedWifiNetworks()
width: parent.width
title: I18n.tr("Saved Networks")
iconName: "bookmark"
settingKey: "networkSavedWifi"
tags: ["wifi", "wi-fi", "wireless", "network", "saved", "known", "ssid", "autoconnect", "forget"]
collapsible: true
expanded: false
visible: savedNetworks.length > 0
headerActions: [
StyledText {
text: savedWifiCard.savedNetworks.length
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
verticalAlignment: Text.AlignVCenter
}
]
Column {
width: parent.width
spacing: 4
Repeater {
model: savedWifiCard.expanded ? savedWifiCard.savedNetworks : []
delegate: Rectangle {
id: savedWifiDelegate
required property var modelData
required property int index
readonly property bool isConnected: modelData.ssid === NetworkService.currentWifiSSID
readonly property bool isPinned: root.getPinnedWifiNetworks().includes(modelData.ssid)
readonly property bool isOutOfRange: modelData.outOfRange || false
readonly property bool isExpanded: !isOutOfRange && root.expandedSavedWifiSsid === modelData.ssid
width: parent.width
height: isExpanded ? 56 + savedWifiExpandedContent.height : 56
radius: Theme.cornerRadius
color: savedWifiMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
border.width: isConnected ? 2 : 0
border.color: Theme.primary
clip: true
Behavior on height {
NumberAnimation {
duration: 150
easing.type: Easing.OutQuad
}
}
Column {
anchors.fill: parent
spacing: 0
Item {
width: parent.width
height: 56
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
anchors.right: savedWifiActions.left
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: {
if (isOutOfRange)
return "wifi_off";
const s = modelData.signal || 0;
if (s >= 50)
return "wifi";
if (s >= 25)
return "wifi_2_bar";
return "wifi_1_bar";
}
size: 20
color: isConnected ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
width: parent.width - 20 - Theme.spacingS
Row {
anchors.left: parent.left
spacing: Theme.spacingXS
width: parent.width
StyledText {
text: modelData.ssid || I18n.tr("Unknown")
font.pixelSize: Theme.fontSizeMedium
color: isConnected ? Theme.primary : Theme.surfaceText
font.weight: isConnected ? Font.Medium : Font.Normal
elide: Text.ElideRight
width: Math.max(0, parent.width - (savedWifiHiddenIcon.visible ? savedWifiHiddenIcon.width + Theme.spacingXS : 0))
}
DankIcon {
id: savedWifiHiddenIcon
name: "visibility_off"
size: 14
color: Theme.surfaceVariantText
visible: modelData.hidden || false
anchors.verticalCenter: parent.verticalCenter
}
}
StyledText {
text: {
const parts = [isConnected ? I18n.tr("Connected") : (modelData.secured ? I18n.tr("Secured") : I18n.tr("Open"))];
parts.push(isOutOfRange ? I18n.tr("Unavailable") : (modelData.signal || 0) + "%");
if (modelData.hidden || false)
parts.push(I18n.tr("Hidden"));
return parts.join(" • ");
}
font.pixelSize: Theme.fontSizeSmall
color: isConnected ? Theme.primary : Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
}
}
}
Row {
id: savedWifiActions
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
Rectangle {
width: 28
height: 28
radius: 14
color: savedWifiExpandBtn.containsMouse ? Theme.surfacePressed : "transparent"
visible: !isOutOfRange
DankIcon {
anchors.centerIn: parent
name: isExpanded ? "expand_less" : "expand_more"
size: 18
color: Theme.surfaceText
}
MouseArea {
id: savedWifiExpandBtn
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (isExpanded) {
root.expandedSavedWifiSsid = "";
} else {
root.expandedSavedWifiSsid = modelData.ssid;
}
}
}
}
DankActionButton {
iconName: "qr_code"
buttonSize: 28
visible: modelData.secured && !(modelData.enterprise || false)
onClicked: {
PopoutService.showWifiQRCodeModal(modelData.ssid);
}
}
DankActionButton {
iconName: "push_pin"
buttonSize: 28
iconColor: isPinned ? Theme.primary : Theme.surfaceVariantText
onClicked: {
root.toggleWifiPin(modelData.ssid);
}
}
DankActionButton {
id: savedWifiMoreButton
iconName: "more_horiz"
buttonSize: 28
onClicked: {
if (savedWifiMenu.visible) {
savedWifiMenu.close();
return;
}
savedWifiMenu.popup(savedWifiMoreButton, -savedWifiMenu.width + savedWifiMoreButton.width, savedWifiMoreButton.height + Theme.spacingXS);
}
}
}
MouseArea {
id: savedWifiMouseArea
anchors.fill: parent
anchors.rightMargin: savedWifiActions.width + Theme.spacingM
hoverEnabled: true
cursorShape: isOutOfRange ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: {
if (isOutOfRange)
return;
if (isExpanded) {
root.expandedSavedWifiSsid = "";
} else {
root.expandedSavedWifiSsid = modelData.ssid;
}
}
}
}
Column {
id: savedWifiExpandedContent
width: parent.width
visible: isExpanded
Rectangle {
width: parent.width - Theme.spacingM * 2
height: 1
x: Theme.spacingM
color: Theme.outlineLight
}
Item {
width: parent.width
height: savedWifiDetailsColumn.implicitHeight + Theme.spacingM * 2
Column {
id: savedWifiDetailsColumn
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
Flow {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: {
const fields = [];
const net = modelData;
if (!net)
return fields;
fields.push({
label: I18n.tr("Signal"),
value: (net.signal || 0) + "%"
});
if (net.frequency)
fields.push({
label: I18n.tr("Frequency"),
value: (net.frequency / 1000).toFixed(1) + " GHz"
});
if (net.channel)
fields.push({
label: I18n.tr("Channel"),
value: String(net.channel)
});
if (net.rate)
fields.push({
label: I18n.tr("Rate"),
value: net.rate + " Mbps"
});
if (net.mode)
fields.push({
label: I18n.tr("Mode"),
value: net.mode
});
if (net.bssid)
fields.push({
label: I18n.tr("BSSID"),
value: net.bssid
});
fields.push({
label: I18n.tr("Security"),
value: net.secured ? (net.enterprise ? I18n.tr("Enterprise") : I18n.tr("WPA/WPA2")) : I18n.tr("Open")
});
return fields;
}
delegate: Rectangle {
required property var modelData
required property int index
width: savedWifiFieldContent.width + Theme.spacingM * 2
height: 32
radius: Theme.cornerRadius - 2
color: Theme.surfaceContainerHigh
border.width: 1
border.color: Theme.outlineLight
Row {
id: savedWifiFieldContent
anchors.centerIn: parent
spacing: Theme.spacingXS
StyledText {
text: modelData.label + ":"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData.value
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
}
}
}
}
Menu {
id: savedWifiMenu
width: 170
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
background: Rectangle {
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
border.width: 0
}
MenuItem {
text: isConnected ? I18n.tr("Disconnect") : I18n.tr("Connect")
height: isOutOfRange ? 0 : 32
visible: !isOutOfRange
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
radius: Theme.cornerRadius / 2
}
onTriggered: {
WifiConnectionActions.connectToNetwork(modelData, {
connected: isConnected,
disconnectWhenConnected: true
});
}
}
MenuItem {
text: modelData.autoconnect ? I18n.tr("Disable Autoconnect") : I18n.tr("Enable Autoconnect")
height: DMSService.apiVersion > 13 ? 32 : 0
visible: DMSService.apiVersion > 13
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
radius: Theme.cornerRadius / 2
}
onTriggered: {
NetworkService.setWifiAutoconnect(modelData.ssid, !(modelData.autoconnect || false));
}
}
MenuItem {
text: I18n.tr("Forget Network")
height: 32
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.08) : "transparent"
radius: Theme.cornerRadius / 2
}
onTriggered: {
root.showForgetNetworkConfirm(modelData.ssid);
}
}
}
}
}
}
}
} }
} }
} }
@@ -603,26 +603,6 @@ Item {
} }
} }
SettingsCard {
width: parent.width
iconName: "tune"
title: I18n.tr("Advanced")
settingKey: "powerAdvanced"
collapsible: true
expanded: false
SettingsSliderRow {
settingKey: "batteryChargeLimit"
tags: ["battery", "charge", "limit", "percentage", "power"]
text: I18n.tr("Battery Charge Limit")
description: I18n.tr("Note: this only changes the percentage, it does not actually limit charging.")
value: SettingsData.batteryChargeLimit
minimum: 50
maximum: 100
defaultValue: 100
onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue)
}
}
} }
} }
} }
+17
View File
@@ -131,6 +131,23 @@ Item {
checked: SettingsData.soundPluggedIn checked: SettingsData.soundPluggedIn
onToggled: checked => SettingsData.set("soundPluggedIn", checked) onToggled: checked => SettingsData.set("soundPluggedIn", checked)
} }
Rectangle {
width: parent.width
height: 1
color: Theme.outline
opacity: 0.2
}
SettingsToggleRow {
tab: "sounds"
tags: ["sound", "media", "playback", "mute", "mpris", "music"]
settingKey: "muteSoundsWhenMediaPlaying"
text: I18n.tr("Mute During Playback")
description: I18n.tr("Silence system sounds while media is playing")
checked: SettingsData.muteSoundsWhenMediaPlaying
onToggled: checked => SettingsData.set("muteSoundsWhenMediaPlaying", checked)
}
} }
} }
+71 -31
View File
@@ -20,6 +20,31 @@ Item {
property var cachedMatugenSchemes: Theme.availableMatugenSchemes.map(option => option.label) property var cachedMatugenSchemes: Theme.availableMatugenSchemes.map(option => option.label)
property var installedRegistryThemes: [] property var installedRegistryThemes: []
property var templateDetection: [] property var templateDetection: []
readonly property var widgetBackgroundOptions: [({
"value": "sth",
"label": I18n.tr("Subtle Overlay", "widget background color option")
}), ({
"value": "s",
"label": I18n.tr("Surface", "widget background color option")
}), ({
"value": "sc",
"label": I18n.tr("Surface Container", "widget background color option")
}), ({
"value": "sch",
"label": I18n.tr("Surface High", "widget background color option")
}), ({
"value": "primaryContainer",
"label": I18n.tr("Primary Container", "widget background color option")
}), ({
"value": "secondaryContainer",
"label": I18n.tr("Secondary Container", "widget background color option")
}), ({
"value": "tertiaryContainer",
"label": I18n.tr("Tertiary Container", "widget background color option")
}), ({
"value": "custom",
"label": I18n.tr("Custom", "widget background color option")
})]
property var cursorIncludeStatus: ({ property var cursorIncludeStatus: ({
"exists": false, "exists": false,
@@ -1524,10 +1549,10 @@ Item {
SettingsButtonGroupRow { SettingsButtonGroupRow {
tab: "theme" tab: "theme"
tags: ["widget", "style", "colorful", "default"] tags: ["widget", "text", "style", "colorful", "default"]
settingKey: "widgetColorMode" settingKey: "widgetColorMode"
text: I18n.tr("Widget Style") text: I18n.tr("Widget Text Style")
description: I18n.tr("Change bar appearance") description: I18n.tr("Choose neutral or accent-colored widget text")
model: [I18n.tr("Default", "widget style option"), I18n.tr("Colorful", "widget style option")] model: [I18n.tr("Default", "widget style option"), I18n.tr("Colorful", "widget style option")]
currentIndex: SettingsData.widgetColorMode === "colorful" ? 1 : 0 currentIndex: SettingsData.widgetColorMode === "colorful" ? 1 : 0
onSelectionChanged: (index, selected) => { onSelectionChanged: (index, selected) => {
@@ -1537,38 +1562,41 @@ Item {
} }
} }
SettingsButtonGroupRow { ColorDropdownRow {
tab: "theme" tab: "theme"
tags: ["widget", "background", "color"] tags: ["widget", "background", "color", "surface", "material"]
settingKey: "widgetBackgroundColor" settingKey: "widgetBackgroundColor"
text: I18n.tr("Widget Background Color") text: I18n.tr("Widget Background Color")
description: I18n.tr("Choose the background color for widgets") description: I18n.tr("Choose the background color for widgets")
model: ["sth", "s", "sc", "sch"] dropdownWidth: 220
buttonHeight: 20 options: themeColorsTab.widgetBackgroundOptions
minButtonWidth: 32 currentMode: SettingsData.widgetBackgroundColor
buttonPadding: Theme.spacingS customColor: SettingsData.widgetBackgroundCustomColor || "#6750A4"
checkIconSize: Theme.iconSizeSmall - 2 pickerTitle: I18n.tr("Widget Background Color")
textSize: Theme.fontSizeSmall - 2 onModeSelected: mode => SettingsData.set("widgetBackgroundColor", mode)
spacing: 1 onCustomColorSelected: selectedColor => SettingsData.set("widgetBackgroundCustomColor", selectedColor.toString())
currentIndex: { }
switch (SettingsData.widgetBackgroundColor) {
case "sth": SettingsSliderRow {
return 0; id: widgetBackgroundCustomStrengthSlider
case "s": visible: SettingsData.widgetBackgroundColor === "custom"
return 1; tab: "theme"
case "sc": tags: ["widget", "background", "color", "custom", "blend"]
return 2; settingKey: "widgetBackgroundCustomStrength"
case "sch": text: I18n.tr("Custom Blend")
return 3; description: I18n.tr("Blend between Surface High and the selected custom color")
default: value: Math.round(SettingsData.widgetBackgroundCustomStrength * 100)
return 0; minimum: 0
} maximum: 100
} unit: "%"
onSelectionChanged: (index, selected) => { defaultValue: 40
if (!selected) onSliderValueChanged: newValue => SettingsData.set("widgetBackgroundCustomStrength", newValue / 100)
return;
const colorOptions = ["sth", "s", "sc", "sch"]; Binding {
SettingsData.set("widgetBackgroundColor", colorOptions[index]); target: widgetBackgroundCustomStrengthSlider
property: "value"
value: Math.round(SettingsData.widgetBackgroundCustomStrength * 100)
restoreMode: Binding.RestoreBinding
} }
} }
@@ -1579,6 +1607,12 @@ Item {
text: I18n.tr("Control Center Tile Color") text: I18n.tr("Control Center Tile Color")
description: I18n.tr("Active tile background and icon color", "control center tile color setting description") description: I18n.tr("Active tile background and icon color", "control center tile color setting description")
options: [I18n.tr("Primary", "tile color option"), I18n.tr("Primary Container", "tile color option"), I18n.tr("Secondary", "tile color option"), I18n.tr("Surface Variant", "tile color option")] options: [I18n.tr("Primary", "tile color option"), I18n.tr("Primary Container", "tile color option"), I18n.tr("Secondary", "tile color option"), I18n.tr("Surface Variant", "tile color option")]
optionColorMap: ({
[I18n.tr("Primary", "tile color option")]: Theme.roleColor("primary"),
[I18n.tr("Primary Container", "tile color option")]: Theme.roleColor("primaryContainer"),
[I18n.tr("Secondary", "tile color option")]: Theme.roleColor("secondary"),
[I18n.tr("Surface Variant", "tile color option")]: Theme.roleColor("surfaceVariant")
})
currentValue: { currentValue: {
switch (SettingsData.controlCenterTileColorMode) { switch (SettingsData.controlCenterTileColorMode) {
case "primaryContainer": case "primaryContainer":
@@ -1611,6 +1645,12 @@ Item {
text: I18n.tr("Button Color") text: I18n.tr("Button Color")
description: I18n.tr("Color for primary action buttons") description: I18n.tr("Color for primary action buttons")
options: [I18n.tr("Primary", "button color option"), I18n.tr("Primary Container", "button color option"), I18n.tr("Secondary", "button color option"), I18n.tr("Surface Variant", "button color option")] options: [I18n.tr("Primary", "button color option"), I18n.tr("Primary Container", "button color option"), I18n.tr("Secondary", "button color option"), I18n.tr("Surface Variant", "button color option")]
optionColorMap: ({
[I18n.tr("Primary", "button color option")]: Theme.roleColor("primary"),
[I18n.tr("Primary Container", "button color option")]: Theme.roleColor("primaryContainer"),
[I18n.tr("Secondary", "button color option")]: Theme.roleColor("secondary"),
[I18n.tr("Surface Variant", "button color option")]: Theme.roleColor("surfaceVariant")
})
currentValue: { currentValue: {
switch (SettingsData.buttonColorMode) { switch (SettingsData.buttonColorMode) {
case "primaryContainer": case "primaryContainer":
@@ -143,15 +143,6 @@ Item {
} }
} }
DankButton {
text: I18n.tr("Launch DankCalendar")
iconName: "calendar_month"
backgroundColor: Theme.primary
textColor: Theme.primaryText
visible: CalendarService.dankNeedsLaunch && CalendarService.dankBinaryExists
onClicked: CalendarService.launchDankCalendar()
}
Rectangle { Rectangle {
width: parent.width width: parent.width
height: 1 height: 1
@@ -4,41 +4,195 @@ import qs.Services
import qs.Modules.Settings.Widgets import qs.Modules.Settings.Widgets
SettingsCard { SettingsCard {
id: root
iconName: "palette" iconName: "palette"
title: I18n.tr("Workspace Appearance") title: I18n.tr("Workspace Appearance")
settingKey: "workspaceAppearance" settingKey: "workspaceAppearance"
collapsible: true collapsible: true
expanded: false expanded: false
SettingsButtonGroupRow { readonly property var focusedColorOptions: [({
"value": "default",
"label": I18n.tr("Primary", "workspace color option")
}), ({
"value": "primaryContainer",
"label": I18n.tr("Primary Container", "workspace color option")
}), ({
"value": "secondary",
"label": I18n.tr("Secondary", "workspace color option")
}), ({
"value": "secondaryContainer",
"label": I18n.tr("Secondary Container", "workspace color option")
}), ({
"value": "tertiary",
"label": I18n.tr("Tertiary", "workspace color option")
}), ({
"value": "tertiaryContainer",
"label": I18n.tr("Tertiary Container", "workspace color option")
}), ({
"value": "s",
"label": I18n.tr("Surface", "workspace color option")
}), ({
"value": "sc",
"label": I18n.tr("Surface Container", "workspace color option")
}), ({
"value": "sch",
"label": I18n.tr("Surface High", "workspace color option")
}), ({
"value": "schh",
"label": I18n.tr("Surface Highest", "workspace color option")
}), ({
"value": "none",
"label": I18n.tr("None", "workspace color option")
}), ({
"value": "custom",
"label": I18n.tr("Custom", "workspace color option")
})]
readonly property var occupiedColorOptions: [({
"value": "none",
"label": I18n.tr("None", "workspace color option")
}), ({
"value": "primary",
"label": I18n.tr("Primary", "workspace color option")
}), ({
"value": "primaryContainer",
"label": I18n.tr("Primary Container", "workspace color option")
}), ({
"value": "sec",
"label": I18n.tr("Secondary", "workspace color option")
}), ({
"value": "secondaryContainer",
"label": I18n.tr("Secondary Container", "workspace color option")
}), ({
"value": "tertiary",
"label": I18n.tr("Tertiary", "workspace color option")
}), ({
"value": "tertiaryContainer",
"label": I18n.tr("Tertiary Container", "workspace color option")
}), ({
"value": "s",
"label": I18n.tr("Surface", "workspace color option")
}), ({
"value": "sc",
"label": I18n.tr("Surface Container", "workspace color option")
}), ({
"value": "sch",
"label": I18n.tr("Surface High", "workspace color option")
}), ({
"value": "schh",
"label": I18n.tr("Surface Highest", "workspace color option")
}), ({
"value": "custom",
"label": I18n.tr("Custom", "workspace color option")
})]
readonly property var unfocusedColorOptions: [({
"value": "default",
"label": I18n.tr("Default", "workspace color option")
}), ({
"value": "surfaceText",
"label": I18n.tr("Surface Text", "workspace color option")
}), ({
"value": "primary",
"label": I18n.tr("Primary", "workspace color option")
}), ({
"value": "secondary",
"label": I18n.tr("Secondary", "workspace color option")
}), ({
"value": "tertiary",
"label": I18n.tr("Tertiary", "workspace color option")
}), ({
"value": "s",
"label": I18n.tr("Surface", "workspace color option")
}), ({
"value": "sc",
"label": I18n.tr("Surface Container", "workspace color option")
}), ({
"value": "sch",
"label": I18n.tr("Surface High", "workspace color option")
}), ({
"value": "schh",
"label": I18n.tr("Surface Highest", "workspace color option")
}), ({
"value": "custom",
"label": I18n.tr("Custom", "workspace color option")
})]
readonly property var urgentColorOptions: [({
"value": "default",
"label": I18n.tr("Error", "workspace color option")
}), ({
"value": "primary",
"label": I18n.tr("Primary", "workspace color option")
}), ({
"value": "primaryContainer",
"label": I18n.tr("Primary Container", "workspace color option")
}), ({
"value": "secondary",
"label": I18n.tr("Secondary", "workspace color option")
}), ({
"value": "secondaryContainer",
"label": I18n.tr("Secondary Container", "workspace color option")
}), ({
"value": "tertiary",
"label": I18n.tr("Tertiary", "workspace color option")
}), ({
"value": "tertiaryContainer",
"label": I18n.tr("Tertiary Container", "workspace color option")
}), ({
"value": "s",
"label": I18n.tr("Surface", "workspace color option")
}), ({
"value": "sc",
"label": I18n.tr("Surface Container", "workspace color option")
}), ({
"value": "sch",
"label": I18n.tr("Surface High", "workspace color option")
}), ({
"value": "custom",
"label": I18n.tr("Custom", "workspace color option")
})]
readonly property var borderColorOptions: [({
"value": "surfaceText",
"label": I18n.tr("Surface Text", "workspace color option")
}), ({
"value": "primary",
"label": I18n.tr("Primary", "workspace color option")
}), ({
"value": "primaryContainer",
"label": I18n.tr("Primary Container", "workspace color option")
}), ({
"value": "secondary",
"label": I18n.tr("Secondary", "workspace color option")
}), ({
"value": "secondaryContainer",
"label": I18n.tr("Secondary Container", "workspace color option")
}), ({
"value": "tertiary",
"label": I18n.tr("Tertiary", "workspace color option")
}), ({
"value": "tertiaryContainer",
"label": I18n.tr("Tertiary Container", "workspace color option")
}), ({
"value": "custom",
"label": I18n.tr("Custom", "workspace color option")
})]
readonly property bool workspaceStateColorsVisible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
readonly property bool urgentWorkspaceColorsVisible: workspaceStateColorsVisible || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
ColorDropdownRow {
text: I18n.tr("Focused Color") text: I18n.tr("Focused Color")
model: ["pri", "s", "sc", "sch", "none"] settingKey: "workspaceColorMode"
buttonHeight: 22 tags: ["workspace", "focused", "color", "custom"]
minButtonWidth: 36 options: root.focusedColorOptions
buttonPadding: Theme.spacingS currentMode: SettingsData.workspaceColorMode
checkIconSize: Theme.iconSizeSmall - 2 customColor: SettingsData.workspaceFocusedCustomColor || "#6750A4"
textSize: Theme.fontSizeSmall - 1 onModeSelected: mode => SettingsData.set("workspaceColorMode", mode)
spacing: 1 onCustomColorSelected: selectedColor => SettingsData.set("workspaceFocusedCustomColor", selectedColor.toString())
currentIndex: {
switch (SettingsData.workspaceColorMode) {
case "s":
return 1;
case "sc":
return 2;
case "sch":
return 3;
case "none":
return 4;
default:
return 0;
}
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
const modes = ["default", "s", "sc", "sch", "none"];
SettingsData.set("workspaceColorMode", modes[index]);
}
} }
Rectangle { Rectangle {
@@ -48,38 +202,16 @@ SettingsCard {
opacity: 0.15 opacity: 0.15
} }
SettingsButtonGroupRow { ColorDropdownRow {
text: I18n.tr("Occupied Color") text: I18n.tr("Occupied Color")
model: ["none", "sec", "s", "sc", "sch", "schh"] settingKey: "workspaceOccupiedColorMode"
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango tags: ["workspace", "occupied", "color", "custom"]
buttonHeight: 22 visible: root.workspaceStateColorsVisible
minButtonWidth: 36 options: root.occupiedColorOptions
buttonPadding: Theme.spacingS currentMode: SettingsData.workspaceOccupiedColorMode
checkIconSize: Theme.iconSizeSmall - 2 customColor: SettingsData.workspaceOccupiedCustomColor || "#625B71"
textSize: Theme.fontSizeSmall - 1 onModeSelected: mode => SettingsData.set("workspaceOccupiedColorMode", mode)
spacing: 1 onCustomColorSelected: selectedColor => SettingsData.set("workspaceOccupiedCustomColor", selectedColor.toString())
currentIndex: {
switch (SettingsData.workspaceOccupiedColorMode) {
case "sec":
return 1;
case "s":
return 2;
case "sc":
return 3;
case "sch":
return 4;
case "schh":
return 5;
default:
return 0;
}
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
const modes = ["none", "sec", "s", "sc", "sch", "schh"];
SettingsData.set("workspaceOccupiedColorMode", modes[index]);
}
} }
Rectangle { Rectangle {
@@ -90,33 +222,16 @@ SettingsCard {
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
} }
SettingsButtonGroupRow { ColorDropdownRow {
text: I18n.tr("Unfocused Color") text: I18n.tr("Unfocused Color")
model: ["def", "s", "sc", "sch"] settingKey: "workspaceUnfocusedColorMode"
buttonHeight: 22 tags: ["workspace", "unfocused", "color", "custom"]
minButtonWidth: 36 options: root.unfocusedColorOptions
buttonPadding: Theme.spacingS defaultColor: Theme.surfaceText
checkIconSize: Theme.iconSizeSmall - 2 currentMode: SettingsData.workspaceUnfocusedColorMode
textSize: Theme.fontSizeSmall - 1 customColor: SettingsData.workspaceUnfocusedCustomColor || "#49454E"
spacing: 1 onModeSelected: mode => SettingsData.set("workspaceUnfocusedColorMode", mode)
currentIndex: { onCustomColorSelected: selectedColor => SettingsData.set("workspaceUnfocusedCustomColor", selectedColor.toString())
switch (SettingsData.workspaceUnfocusedColorMode) {
case "s":
return 1;
case "sc":
return 2;
case "sch":
return 3;
default:
return 0;
}
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
const modes = ["default", "s", "sc", "sch"];
SettingsData.set("workspaceUnfocusedColorMode", modes[index]);
}
} }
Rectangle { Rectangle {
@@ -127,36 +242,17 @@ SettingsCard {
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
} }
SettingsButtonGroupRow { ColorDropdownRow {
text: I18n.tr("Urgent Color") text: I18n.tr("Urgent Color")
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle settingKey: "workspaceUrgentColorMode"
model: ["err", "pri", "sec", "s", "sc"] tags: ["workspace", "urgent", "color", "custom"]
buttonHeight: 22 visible: root.urgentWorkspaceColorsVisible
minButtonWidth: 36 options: root.urgentColorOptions
buttonPadding: Theme.spacingS defaultColor: Theme.error
checkIconSize: Theme.iconSizeSmall - 2 currentMode: SettingsData.workspaceUrgentColorMode
textSize: Theme.fontSizeSmall - 1 customColor: SettingsData.workspaceUrgentCustomColor || "#B3261E"
spacing: 1 onModeSelected: mode => SettingsData.set("workspaceUrgentColorMode", mode)
currentIndex: { onCustomColorSelected: selectedColor => SettingsData.set("workspaceUrgentCustomColor", selectedColor.toString())
switch (SettingsData.workspaceUrgentColorMode) {
case "primary":
return 1;
case "secondary":
return 2;
case "s":
return 3;
case "sc":
return 4;
default:
return 0;
}
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
const modes = ["default", "primary", "secondary", "s", "sc"];
SettingsData.set("workspaceUrgentColorMode", modes[index]);
}
} }
Rectangle { Rectangle {
@@ -181,39 +277,16 @@ SettingsCard {
visible: SettingsData.workspaceFocusedBorderEnabled visible: SettingsData.workspaceFocusedBorderEnabled
leftPadding: Theme.spacingM leftPadding: Theme.spacingM
SettingsButtonGroupRow { ColorDropdownRow {
width: parent.width - parent.leftPadding width: parent.width - parent.leftPadding
text: I18n.tr("Border Color") text: I18n.tr("Border Color")
model: [I18n.tr("Surface"), I18n.tr("Secondary"), I18n.tr("Primary")] settingKey: "workspaceFocusedBorderColor"
currentIndex: { tags: ["workspace", "focused", "border", "color", "custom"]
switch (SettingsData.workspaceFocusedBorderColor) { options: root.borderColorOptions
case "surfaceText": currentMode: SettingsData.workspaceFocusedBorderColor
return 0; customColor: SettingsData.workspaceFocusedBorderCustomColor || "#6750A4"
case "secondary": onModeSelected: mode => SettingsData.set("workspaceFocusedBorderColor", mode)
return 1; onCustomColorSelected: selectedColor => SettingsData.set("workspaceFocusedBorderCustomColor", selectedColor.toString())
case "primary":
return 2;
default:
return 2;
}
}
onSelectionChanged: (index, selected) => {
if (!selected)
return;
let newColor = "primary";
switch (index) {
case 0:
newColor = "surfaceText";
break;
case 1:
newColor = "secondary";
break;
case 2:
newColor = "primary";
break;
}
SettingsData.set("workspaceFocusedBorderColor", newColor);
}
} }
SettingsSliderRow { SettingsSliderRow {
+10
View File
@@ -441,4 +441,14 @@ PanelWindow {
mask: Region { mask: Region {
item: toast item: toast
} }
WindowBlur {
targetWindow: root
blurEnabled: root.shouldBeVisible
blurX: toast.x
blurY: toast.y
blurWidth: root.shouldBeVisible ? toast.width : 0
blurHeight: root.shouldBeVisible ? toast.height : 0
blurRadius: toast.radius
}
} }
+10 -6
View File
@@ -589,38 +589,42 @@ EOFCONFIG
return MprisController.activePlayer?.isPlaying ?? false; return MprisController.activePlayer?.isPlaying ?? false;
} }
function shouldMuteForMedia() {
return SettingsData.muteSoundsWhenMediaPlaying && isMediaPlaying();
}
function playVolumeChangeSound() { function playVolumeChangeSound() {
if (!soundsAvailable || !volumeChangeSound || notificationsAudioMuted || isMediaPlaying()) if (!soundsAvailable || !volumeChangeSound || notificationsAudioMuted || shouldMuteForMedia())
return; return;
volumeChangeSound.play(); volumeChangeSound.play();
} }
function playPowerPlugSound() { function playPowerPlugSound() {
if (!soundsAvailable || !powerPlugSound || notificationsAudioMuted || isMediaPlaying()) if (!soundsAvailable || !powerPlugSound || notificationsAudioMuted || shouldMuteForMedia())
return; return;
powerPlugSound.play(); powerPlugSound.play();
} }
function playPowerUnplugSound() { function playPowerUnplugSound() {
if (!soundsAvailable || !powerUnplugSound || notificationsAudioMuted || isMediaPlaying()) if (!soundsAvailable || !powerUnplugSound || notificationsAudioMuted || shouldMuteForMedia())
return; return;
powerUnplugSound.play(); powerUnplugSound.play();
} }
function playNormalNotificationSound() { function playNormalNotificationSound() {
if (!soundsAvailable || !normalNotificationSound || SessionData.doNotDisturb || notificationsAudioMuted || isMediaPlaying()) if (!soundsAvailable || !normalNotificationSound || SessionData.doNotDisturb || notificationsAudioMuted || shouldMuteForMedia())
return; return;
normalNotificationSound.play(); normalNotificationSound.play();
} }
function playCriticalNotificationSound() { function playCriticalNotificationSound() {
if (!soundsAvailable || !criticalNotificationSound || SessionData.doNotDisturb || notificationsAudioMuted || isMediaPlaying()) if (!soundsAvailable || !criticalNotificationSound || SessionData.doNotDisturb || notificationsAudioMuted || shouldMuteForMedia())
return; return;
criticalNotificationSound.play(); criticalNotificationSound.play();
} }
function playLoginSound() { function playLoginSound() {
if (!soundsAvailable || !loginSound || notificationsAudioMuted || isMediaPlaying()) { if (!soundsAvailable || !loginSound || notificationsAudioMuted || shouldMuteForMedia()) {
return; return;
} }
loginSound.play(); loginSound.play();
+75 -1
View File
@@ -102,7 +102,81 @@ Singleton {
// Is the system plugged in (Is not running on battery) // Is the system plugged in (Is not running on battery)
readonly property bool isPluggedIn: !UPower.onBattery readonly property bool isPluggedIn: !UPower.onBattery
readonly property bool isLowBattery: batteryAvailable && batteryLevel <= 20 readonly property bool isLowBattery: batteryAvailable && batteryLevel <= SettingsData.batteryLowThreshold
readonly property bool isCriticalBattery: batteryAvailable && batteryLevel <= SettingsData.batteryCriticalThreshold
property bool _hasNotifiedLowBattery: false
property bool _hasNotifiedCriticalBattery: false
property bool _hasNotifiedChargeLimit: false
function sendAlert(title, message, isWarning, category) {
if (SettingsData.batteryNotificationType === 1) {
Quickshell.execDetached(["notify-send", "-u", isWarning ? "critical" : "normal", "-a", "DMS", "-i", isWarning ? "battery-caution" : "battery-charging", title, message]);
} else {
if (isWarning) {
ToastService.showWarning(title, message, "", category);
} else {
ToastService.showInfo(title, message, "", category);
}
}
}
onBatteryLevelChanged: {
if (isCharging && batteryLevel >= SettingsData.batteryChargeLimit) {
if (!_hasNotifiedChargeLimit && SettingsData.batteryNotifyChargeLimit) {
_hasNotifiedChargeLimit = true;
sendAlert(I18n.tr("Charge Limit Reached"), I18n.tr("Battery has charged to your set limit of %1%").arg(SettingsData.batteryChargeLimit), false, "battery-charge-limit");
}
} else if (!isCharging || batteryLevel < SettingsData.batteryChargeLimit - 2) {
_hasNotifiedChargeLimit = false;
}
if (isCharging) {
_hasNotifiedLowBattery = false;
_hasNotifiedCriticalBattery = false;
return;
}
// Critical battery check (higher priority)
if (isCriticalBattery) {
if (!_hasNotifiedCriticalBattery && SettingsData.batteryNotifyCritical) {
_hasNotifiedCriticalBattery = true;
sendAlert(I18n.tr("Critical Battery"), I18n.tr("Battery is at %1% - Connect charger immediately!").arg(batteryLevel), true, "battery-critical");
}
return;
}
if (batteryLevel > SettingsData.batteryCriticalThreshold) {
_hasNotifiedCriticalBattery = false;
}
// Low battery check
if (isLowBattery) {
if (!_hasNotifiedLowBattery && SettingsData.batteryNotifyLow) {
_hasNotifiedLowBattery = true;
sendAlert(I18n.tr("Low Battery"), I18n.tr("Battery is at %1% - Consider charging soon").arg(batteryLevel), true, "battery-low");
}
if (SettingsData.batteryAutoPowerSaver && PowerProfileWatcher.available) {
if (PowerProfileWatcher.currentProfile !== PowerProfile.PowerSaver) {
PowerProfileWatcher.applyProfile(PowerProfile.PowerSaver);
}
}
}
if (batteryLevel > SettingsData.batteryLowThreshold) {
_hasNotifiedLowBattery = false;
}
}
onIsChargingChanged: {
if (isCharging) {
_hasNotifiedLowBattery = false;
_hasNotifiedCriticalBattery = false;
} else {
_hasNotifiedChargeLimit = false;
}
}
onIsPluggedInChanged: { onIsPluggedInChanged: {
if (suppressSound || !batteryAvailable) { if (suppressSound || !batteryAvailable) {
+9 -6
View File
@@ -103,15 +103,18 @@ Item {
} }
function _applySocketPath(path) { function _applySocketPath(path) {
if (path === socketPath) { const changed = path !== socketPath;
if (socketFound && !connected) if (changed)
requestSocket.connected = true; log.info("dankcal socket discovered:", path);
if (!changed && connected)
return; return;
} socketPath = path;
log.info("dankcal socket discovered:", path); _reconnect();
}
function _reconnect() {
requestSocket.connected = false; requestSocket.connected = false;
subscribeSocket.connected = false; subscribeSocket.connected = false;
socketPath = path;
Qt.callLater(() => requestSocket.connected = true); Qt.callLater(() => requestSocket.connected = true);
} }
+1 -1
View File
@@ -34,7 +34,7 @@ Singleton {
readonly property bool dankConnected: dankBackend.connected readonly property bool dankConnected: dankBackend.connected
readonly property bool dankBinaryExists: dankBackend.binaryExists readonly property bool dankBinaryExists: dankBackend.binaryExists
readonly property bool dankNeedsLaunch: backendPref === "dankcal" && !dankBackend.connected readonly property bool dankNeedsLaunch: backendPref === "dankcal" && !dankBackend.connected && !dankBackend.socketFound
property var calendars: dankBackend.calendars property var calendars: dankBackend.calendars
property var eventsByDate: ({}) property var eventsByDate: ({})
+19 -6
View File
@@ -23,6 +23,7 @@ Singleton {
property int pinnedCount: 0 property int pinnedCount: 0
property int totalCount: 0 property int totalCount: 0
property string searchText: "" property string searchText: ""
property string activeFilter: "all"
property int selectedIndex: 0 property int selectedIndex: 0
property bool keyboardNavigationActive: false property bool keyboardNavigationActive: false
property int refCount: 0 property int refCount: 0
@@ -38,6 +39,9 @@ Singleton {
Process { Process {
id: wtypeProcess id: wtypeProcess
// TODO: This is only a paste shortcut fallback. It assumes the target
// application accepts Ctrl+V, which is false for many terminals.
// Replace with a more reliable target-aware paste strategy.
command: ["wtype", "-M", "ctrl", "-P", "v", "-p", "v", "-m", "ctrl"] command: ["wtype", "-M", "ctrl", "-P", "v", "-p", "v", "-m", "ctrl"]
running: false running: false
} }
@@ -50,14 +54,21 @@ Singleton {
} }
function updateFilteredModel() { function updateFilteredModel() {
const query = searchText.trim(); let filtered = internalEntries;
let filtered = [];
if (query.length === 0) { if (activeFilter !== "all") {
filtered = internalEntries; filtered = filtered.filter(entry =>
} else { getEntryType(entry) === activeFilter
);
}
const query = searchText.trim();
if (query.length > 0) {
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
filtered = internalEntries.filter(entry => entry.preview.toLowerCase().includes(lowerQuery)); filtered = filtered.filter(entry =>
entry.preview.toLowerCase().includes(lowerQuery)
);
} }
filtered.sort((a, b) => { filtered.sort((a, b) => {
@@ -72,11 +83,13 @@ Singleton {
totalCount = clipboardEntries.length; totalCount = clipboardEntries.length;
const activeCount = Math.max(unpinnedEntries.length, pinnedEntries.length); const activeCount = Math.max(unpinnedEntries.length, pinnedEntries.length);
if (activeCount === 0) { if (activeCount === 0) {
keyboardNavigationActive = false; keyboardNavigationActive = false;
selectedIndex = 0; selectedIndex = 0;
return; return;
} }
if (selectedIndex >= activeCount) { if (selectedIndex >= activeCount) {
selectedIndex = activeCount - 1; selectedIndex = activeCount - 1;
} }
+20 -7
View File
@@ -69,6 +69,7 @@ Singleton {
property bool changingPreference: false property bool changingPreference: false
property string targetPreference: "" property string targetPreference: ""
property var savedWifiNetworks: [] property var savedWifiNetworks: []
readonly property int savedWifiStateApiVersion: 26
property string connectionStatus: "" property string connectionStatus: ""
property string lastConnectionError: "" property string lastConnectionError: ""
property bool passwordDialogShouldReopen: false property bool passwordDialogShouldReopen: false
@@ -309,17 +310,21 @@ Singleton {
if (state.wifiNetworks) { if (state.wifiNetworks) {
wifiNetworks = state.wifiNetworks; wifiNetworks = state.wifiNetworks;
}
if (state.wifiNetworks || state.savedWifiNetworks) {
const hasSavedWifiState = DMSService.apiVersion >= savedWifiStateApiVersion && Array.isArray(state.savedWifiNetworks);
const sourceSavedNetworks = hasSavedWifiState ? state.savedWifiNetworks : (state.wifiNetworks || []).filter(network => network.saved);
const saved = []; const saved = [];
const mapping = {}; const mapping = {};
for (const network of state.wifiNetworks) { for (const network of sourceSavedNetworks) {
if (network.saved) { const normalized = Object.assign({}, network, {
saved.push({ saved: true,
ssid: network.ssid, outOfRange: hasSavedWifiState ? network.outOfRange === true : false
saved: true });
}); saved.push(normalized);
if (network?.ssid)
mapping[network.ssid] = network.ssid; mapping[network.ssid] = network.ssid;
}
} }
savedConnections = saved; savedConnections = saved;
savedWifiNetworks = saved; savedWifiNetworks = saved;
@@ -596,6 +601,7 @@ Singleton {
} }
wifiNetworks = updated; wifiNetworks = updated;
networksUpdated(); networksUpdated();
Qt.callLater(() => refreshSavedWifiNetworks());
} }
forgetSSID = ""; forgetSSID = "";
}); });
@@ -985,4 +991,11 @@ Singleton {
} }
}); });
} }
function refreshSavedWifiNetworks() {
if (!networkAvailable)
return;
getState();
}
} }
+1
View File
@@ -53,6 +53,7 @@ Singleton {
signal lockRequested signal lockRequested
signal fadeToLockRequested signal fadeToLockRequested
signal cancelFadeToLock signal cancelFadeToLock
signal dismissFadeToLock
signal fadeToDpmsRequested signal fadeToDpmsRequested
signal cancelFadeToDpms signal cancelFadeToDpms
signal requestMonitorOff signal requestMonitorOff
+3 -1
View File
@@ -142,9 +142,11 @@ Singleton {
readonly property var savedConnections: wifiNetworks.filter(n => n.saved).map(n => ({ readonly property var savedConnections: wifiNetworks.filter(n => n.saved).map(n => ({
"ssid": n.ssid, "ssid": n.ssid,
"saved": true "saved": true,
"outOfRange": false
})) }))
readonly property var savedWifiNetworks: savedConnections readonly property var savedWifiNetworks: savedConnections
readonly property int savedWifiStateApiVersion: 26
readonly property var ssidToConnectionName: { readonly property var ssidToConnectionName: {
const map = {}; const map = {};
for (const n of wifiNetworks) { for (const n of wifiNetworks) {
+7
View File
@@ -54,6 +54,7 @@ Singleton {
property bool changingPreference: activeService?.changingPreference ?? false property bool changingPreference: activeService?.changingPreference ?? false
property string targetPreference: activeService?.targetPreference ?? "" property string targetPreference: activeService?.targetPreference ?? ""
property var savedWifiNetworks: activeService?.savedWifiNetworks ?? [] property var savedWifiNetworks: activeService?.savedWifiNetworks ?? []
readonly property int savedWifiStateApiVersion: activeService?.savedWifiStateApiVersion ?? 26
property string connectionStatus: activeService?.connectionStatus ?? "" property string connectionStatus: activeService?.connectionStatus ?? ""
property string lastConnectionError: activeService?.lastConnectionError ?? "" property string lastConnectionError: activeService?.lastConnectionError ?? ""
property bool passwordDialogShouldReopen: activeService?.passwordDialogShouldReopen ?? false property bool passwordDialogShouldReopen: activeService?.passwordDialogShouldReopen ?? false
@@ -180,6 +181,12 @@ Singleton {
} }
} }
function refreshSavedWifiNetworks() {
if (activeService && activeService.refreshSavedWifiNetworks) {
activeService.refreshSavedWifiNetworks();
}
}
function connectToWifi(ssid, password = "", username = "", anonymousIdentity = "", domainSuffixMatch = "") { function connectToWifi(ssid, password = "", username = "", anonymousIdentity = "", domainSuffixMatch = "") {
if (activeService && activeService.connectToWifi) { if (activeService && activeService.connectToWifi) {
activeService.connectToWifi(ssid, password, username, anonymousIdentity, domainSuffixMatch); activeService.connectToWifi(ssid, password, username, anonymousIdentity, domainSuffixMatch);
+9 -3
View File
@@ -480,7 +480,7 @@ Singleton {
} }
function closeClipboardHistory() { function closeClipboardHistory() {
clipboardHistoryModal?.close(); clipboardHistoryModal?.hide();
} }
function unloadClipboardHistoryPopout() { function unloadClipboardHistoryPopout() {
@@ -756,8 +756,11 @@ Singleton {
function showWifiPasswordModal(ssid) { function showWifiPasswordModal(ssid) {
if (wifiPasswordModalLoader) if (wifiPasswordModalLoader)
wifiPasswordModalLoader.active = true; wifiPasswordModalLoader.active = true;
if (wifiPasswordModal) if (wifiPasswordModal) {
wifiPasswordModal.show(ssid); wifiPasswordModal.show(ssid);
} else {
Qt.callLater(() => wifiPasswordModal?.show(ssid));
}
} }
function showWifiQRCodeModal(ssid) { function showWifiQRCodeModal(ssid) {
@@ -770,8 +773,11 @@ Singleton {
function showHiddenNetworkModal() { function showHiddenNetworkModal() {
if (wifiPasswordModalLoader) if (wifiPasswordModalLoader)
wifiPasswordModalLoader.active = true; wifiPasswordModalLoader.active = true;
if (wifiPasswordModal) if (wifiPasswordModal) {
wifiPasswordModal.showHidden(); wifiPasswordModal.showHidden();
} else {
Qt.callLater(() => wifiPasswordModal?.showHidden());
}
} }
function hideWifiPasswordModal() { function hideWifiPasswordModal() {
+54
View File
@@ -41,6 +41,7 @@ Singleton {
property string tailnetName: "" property string tailnetName: ""
property var selfNode: null property var selfNode: null
property var peers: [] property var peers: []
property bool exitNodeAllowLanAccess: false
property bool available: false property bool available: false
property bool stateInitialized: false property bool stateInitialized: false
@@ -56,6 +57,19 @@ Singleton {
readonly property var onlinePeers: allPeersList.filter(p => p.online) readonly property var onlinePeers: allPeersList.filter(p => p.online)
// Peers that may be used as an exit node (offered && approved). Self is
// excluded: a node can never route through itself, and tailscaled rejects it.
readonly property var exitNodeOptions: allPeersList.filter(p => p && p.exitNodeOption && p !== selfNode)
// The currently selected exit node, or null if none is in use.
readonly property var currentExitNode: {
for (const p of allPeersList) {
if (p && p.exitNode)
return p;
}
return null;
}
readonly property var myPeers: { readonly property var myPeers: {
if (!selfNode) if (!selfNode)
return allPeersList; return allPeersList;
@@ -141,6 +155,7 @@ Singleton {
tailnetName = data.tailnetName || ""; tailnetName = data.tailnetName || "";
selfNode = data.self || null; selfNode = data.self || null;
peers = data.peers || []; peers = data.peers || [];
exitNodeAllowLanAccess = data.exitNodeAllowLanAccess || false;
} }
function refresh(callback) { function refresh(callback) {
@@ -152,6 +167,45 @@ Singleton {
}); });
} }
// sendAction issues a state-changing request. The backend refreshes and
// broadcasts on success, so subscribers update without an extra getStatus.
function sendAction(method, params, callback) {
if (!available)
return;
DMSService.sendRequest(method, params, response => {
if (response.error) {
root.log.warn(method + " failed: " + response.error);
ToastService.showError(I18n.tr("Tailscale action failed", "Toast shown when a Tailscale write action is rejected"), response.error);
}
if (callback)
callback(response);
});
}
function connectTailscale(callback) {
sendAction("tailscale.connect", null, callback);
}
function disconnectTailscale(callback) {
sendAction("tailscale.disconnect", null, callback);
}
function setExitNode(id, callback) {
sendAction("tailscale.setExitNode", {
"id": id || ""
}, callback);
}
function clearExitNode(callback) {
setExitNode("", callback);
}
function setAllowLanAccess(enabled, callback) {
sendAction("tailscale.setAllowLanAccess", {
"enabled": enabled
}, callback);
}
function isMine(peer) { function isMine(peer) {
const myOwner = selfNode ? (selfNode.owner || "") : ""; const myOwner = selfNode ? (selfNode.owner || "") : "";
if (peer.owner === myOwner && myOwner !== "") if (peer.owner === myOwner && myOwner !== "")
+256 -26
View File
@@ -43,6 +43,8 @@ Singleton {
property int lastFetchTime: 0 property int lastFetchTime: 0
property int minFetchInterval: 30000 property int minFetchInterval: 30000
property int persistentRetryCount: 0 property int persistentRetryCount: 0
property int _geocodeReqId: 0
property var _pendingCoords: null
readonly property var lowPriorityCmd: ["nice", "-n", "19", "ionice", "-c3"] readonly property var lowPriorityCmd: ["nice", "-n", "19", "ionice", "-c3"]
readonly property var curlBaseCmd: ["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "100k", "--compressed"] readonly property var curlBaseCmd: ["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "100k", "--compressed"]
@@ -452,16 +454,54 @@ Singleton {
if (!location) { if (!location) {
return null; return null;
} }
return getWeatherApiUrlForCoords(location.latitude, location.longitude);
const params = ["latitude=" + location.latitude, "longitude=" + location.longitude, "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,surface_pressure,wind_speed_10m", "daily=sunrise,sunset,temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max", "hourly=temperature_2m,weather_code,precipitation_probability,wind_speed_10m,apparent_temperature,relative_humidity_2m,surface_pressure,visibility,cloud_cover", "timezone=auto", "forecast_days=7"];
return "https://api.open-meteo.com/v1/forecast?" + params.join('&');
} }
function getGeocodingUrl(query) { function getGeocodingUrl(query) {
return "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(query) + "&count=1&language=en&format=json"; return "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(query) + "&count=1&language=en&format=json";
} }
function getConfiguredLocationName() {
return SessionData.isGreeterMode ? GreetdSettings.weatherLocation : SettingsData.weatherLocation;
}
function setLocation(lat, lon, city, country) {
root.location = {
city: city || I18n.tr("Local Weather"),
country: country || "",
latitude: lat,
longitude: lon
};
}
function updateLocationCity(city, country) {
if (!root.location)
return;
root.location = {
latitude: root.location.latitude,
longitude: root.location.longitude,
city: city || root.location.city,
country: country || root.location.country
};
if (root.weather.available) {
root.weather = Object.assign({}, root.weather, {
city: city || root.weather.city,
country: country || root.weather.country
});
}
}
function getWeatherApiUrlForCoords(lat, lon) {
if (lat == null || lon == null)
return null;
const params = ["latitude=" + lat, "longitude=" + lon, "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,surface_pressure,wind_speed_10m", "daily=sunrise,sunset,temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max", "hourly=temperature_2m,weather_code,precipitation_probability,wind_speed_10m,apparent_temperature,relative_humidity_2m,surface_pressure,visibility,cloud_cover", "timezone=auto", "forecast_days=7"];
return "https://api.open-meteo.com/v1/forecast?" + params.join('&');
}
function addRef() { function addRef() {
refCount++; refCount++;
@@ -490,20 +530,30 @@ Singleton {
const lat = parseFloat(parts[0]); const lat = parseFloat(parts[0]);
const lon = parseFloat(parts[1]); const lon = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lon)) { if (!isNaN(lat) && !isNaN(lon)) {
getLocationFromCoords(lat, lon); if (cityName) {
// User provided both: trust the configured name and coordinates, skip geocoding
setLocation(lat, lon, cityName, "");
fetchWeather(lat, lon);
} else {
getLocationFromCoords(lat, lon);
}
return; return;
} }
} }
} }
if (cityName) if (cityName) {
getLocationFromCity(cityName); getLocationFromCity(cityName);
} else {
root.handleWeatherFailure();
}
} }
function getLocationFromCoords(lat, lon) { function getLocationFromCoords(lat, lon) {
const url = "https://nominatim.openstreetmap.org/reverse?lat=" + lat + "&lon=" + lon + "&format=json&addressdetails=1&accept-language=en"; // Use coordinates immediately for weather; resolve city name in parallel with fallbacks
reverseGeocodeFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat(["-H", "User-Agent: DankMaterialShell Weather Widget", url]); setLocation(lat, lon, I18n.tr("Local Weather"), "");
reverseGeocodeFetcher.running = true; fetchWeather(lat, lon);
resolveCityName(lat, lon);
} }
function getLocationFromCity(city) { function getLocationFromCity(city) {
@@ -512,19 +562,78 @@ Singleton {
} }
function getLocationFromService() { function getLocationFromService() {
if (!LocationService.valid) if (!LocationService.valid) {
getLocationFromIP();
return; return;
getLocationFromCoords(LocationService.latitude, LocationService.longitude); }
const lat = LocationService.latitude;
const lon = LocationService.longitude;
if (lat === 0 && lon === 0) {
getLocationFromIP();
return;
}
getLocationFromCoords(lat, lon);
} }
function fetchWeather() { function getLocationFromIP() {
ipLocationFetcher.running = true;
}
function resolveCityName(lat, lon) {
// Cancel any in-flight city resolution to avoid stale updates
if (nominatimFetcher.running)
nominatimFetcher.running = false;
if (photonFetcher.running)
photonFetcher.running = false;
if (bigDataCloudFetcher.running)
bigDataCloudFetcher.running = false;
root._geocodeReqId++;
root._pendingCoords = {
latitude: lat,
longitude: lon,
reqId: root._geocodeReqId
};
tryNominatim(lat, lon, root._geocodeReqId);
}
function tryNominatim(lat, lon, reqId) {
const url = "https://nominatim.openstreetmap.org/reverse?lat=" + lat + "&lon=" + lon + "&format=json&addressdetails=1&accept-language=en";
nominatimFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat(["-H", "User-Agent: DankMaterialShell Weather Widget", url]);
nominatimFetcher.reqId = reqId;
nominatimFetcher.running = true;
}
function tryPhoton(lat, lon, reqId) {
const url = "https://photon.komoot.io/reverse?lat=" + lat + "&lon=" + lon + "&lang=en";
photonFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat([url]);
photonFetcher.reqId = reqId;
photonFetcher.running = true;
}
function tryBigDataCloud(lat, lon, reqId) {
const url = "https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=" + lat + "&longitude=" + lon + "&localityLanguage=zh";
bigDataCloudFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat([url]);
bigDataCloudFetcher.reqId = reqId;
bigDataCloudFetcher.running = true;
}
function fetchWeather(lat, lon) {
if (root.refCount === 0 || !SettingsData.weatherEnabled) { if (root.refCount === 0 || !SettingsData.weatherEnabled) {
return; return;
} }
if (!location) { if (lat == null || lon == null) {
updateLocation(); if (!location) {
return; updateLocation();
return;
}
lat = location.latitude;
lon = location.longitude;
} }
if (weatherFetcher.running) { if (weatherFetcher.running) {
@@ -536,7 +645,7 @@ Singleton {
return; return;
} }
const apiUrl = getWeatherApiUrl(); const apiUrl = getWeatherApiUrlForCoords(lat, lon);
if (!apiUrl) { if (!apiUrl) {
return; return;
} }
@@ -586,9 +695,123 @@ Singleton {
} }
Process { Process {
id: reverseGeocodeFetcher id: nominatimFetcher
property int reqId: 0
running: false running: false
stdout: StdioCollector {
onStreamFinished: {
if (nominatimFetcher.reqId !== root._geocodeReqId)
return;
const raw = text.trim();
if (!raw || raw[0] !== "{") {
root.tryPhoton(root._pendingCoords.latitude, root._pendingCoords.longitude, root._geocodeReqId);
return;
}
try {
const data = JSON.parse(raw);
const address = data.address || {};
const city = address.hamlet || address.city || address.town || address.village || I18n.tr("Unknown");
const country = address.country || I18n.tr("Unknown");
root.updateLocationCity(city, country);
} catch (e) {
root.tryPhoton(root._pendingCoords.latitude, root._pendingCoords.longitude, root._geocodeReqId);
}
}
}
onExited: exitCode => {
if (nominatimFetcher.reqId !== root._geocodeReqId)
return;
if (exitCode !== 0) {
root.tryPhoton(root._pendingCoords.latitude, root._pendingCoords.longitude, root._geocodeReqId);
}
}
}
Process {
id: photonFetcher
property int reqId: 0
running: false
stdout: StdioCollector {
onStreamFinished: {
if (photonFetcher.reqId !== root._geocodeReqId)
return;
const raw = text.trim();
if (!raw || raw[0] !== "{") {
root.tryBigDataCloud(root._pendingCoords.latitude, root._pendingCoords.longitude, root._geocodeReqId);
return;
}
try {
const data = JSON.parse(raw);
const features = data.features;
if (!features || features.length === 0) {
throw new Error("No Photon results");
}
const props = features[0].properties || {};
const city = props.city || props.town || props.village || props.locality || props.name || I18n.tr("Unknown");
const country = props.country || I18n.tr("Unknown");
root.updateLocationCity(city, country);
} catch (e) {
root.tryBigDataCloud(root._pendingCoords.latitude, root._pendingCoords.longitude, root._geocodeReqId);
}
}
}
onExited: exitCode => {
if (photonFetcher.reqId !== root._geocodeReqId)
return;
if (exitCode !== 0) {
root.tryBigDataCloud(root._pendingCoords.latitude, root._pendingCoords.longitude, root._geocodeReqId);
}
}
}
Process {
id: bigDataCloudFetcher
property int reqId: 0
running: false
stdout: StdioCollector {
onStreamFinished: {
if (bigDataCloudFetcher.reqId !== root._geocodeReqId)
return;
const raw = text.trim();
if (!raw || raw[0] !== "{") {
// All city resolution fallbacks failed; weather is already displayed
return;
}
try {
const data = JSON.parse(raw);
const city = data.city || data.locality || I18n.tr("Unknown");
const country = data.countryName || I18n.tr("Unknown");
root.updateLocationCity(city, country);
} catch (e) {
// All fallbacks failed; keep placeholder city name
}
}
}
onExited: exitCode => {
if (bigDataCloudFetcher.reqId !== root._geocodeReqId)
return;
// Final fallback; no further action needed
}
}
Process {
id: ipLocationFetcher
running: false
command: lowPriorityCmd.concat(curlBaseCmd).concat(["http://ip-api.com/json/"])
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: {
const raw = text.trim(); const raw = text.trim();
@@ -599,16 +822,21 @@ Singleton {
try { try {
const data = JSON.parse(raw); const data = JSON.parse(raw);
const address = data.address || {};
root.location = { if (data.status === "fail") {
city: address.hamlet || address.city || address.town || address.village || I18n.tr("Unknown"), throw new Error("IP location lookup failed");
country: address.country || I18n.tr("Unknown"), }
latitude: parseFloat(data.lat),
longitude: parseFloat(data.lon)
};
fetchWeather(); const lat = parseFloat(data.lat);
const lon = parseFloat(data.lon);
const city = data.city;
if (!city || isNaN(lat) || isNaN(lon)) {
throw new Error("Missing or invalid location data");
}
setLocation(lat, lon, city, data.countryName || "");
fetchWeather(lat, lon);
} catch (e) { } catch (e) {
root.handleWeatherFailure(); root.handleWeatherFailure();
} }
@@ -833,8 +1061,10 @@ Singleton {
function onLocationChanged(data) { function onLocationChanged(data) {
if (!SettingsData.useAutoLocation) if (!SettingsData.useAutoLocation)
return; return;
if (data.latitude === 0 && data.longitude === 0) if (data.latitude === 0 && data.longitude === 0) {
root.getLocationFromIP();
return; return;
}
root.getLocationFromCoords(data.latitude, data.longitude); root.getLocationFromCoords(data.latitude, data.longitude);
} }
} }
+45
View File
@@ -0,0 +1,45 @@
import QtQuick
import qs.Common
Item {
id: root
property color swatchColor: "transparent"
property color ringColor: Theme.outline
property real minPreviewAlpha: 0.4
readonly property bool translucent: swatchColor.a > 0 && swatchColor.a < 1
readonly property color displayColor: translucent ? Qt.rgba(swatchColor.r, swatchColor.g, swatchColor.b, Math.max(swatchColor.a, minPreviewAlpha)) : swatchColor
Loader {
anchors.fill: parent
active: root.translucent
sourceComponent: Component {
Canvas {
onPaint: {
const ctx = getContext("2d");
ctx.reset();
ctx.beginPath();
ctx.arc(width / 2, height / 2, width / 2, 0, 2 * Math.PI);
ctx.clip();
const s = Math.max(2, Math.round(width / 4));
for (let y = 0; y < height; y += s) {
for (let x = 0; x < width; x += s) {
ctx.fillStyle = (((x / s) + (y / s)) % 2 === 0) ? "#ffffff" : "#bdbdbd";
ctx.fillRect(x, y, s, s);
}
}
}
onVisibleChanged: if (visible)
requestPaint()
}
}
}
Rectangle {
anchors.fill: parent
radius: width / 2
color: root.displayColor
border.color: root.ringColor
border.width: 1
}
}
+102 -20
View File
@@ -28,6 +28,7 @@ Item {
property var optionIcons: [] property var optionIcons: []
property bool enableFuzzySearch: false property bool enableFuzzySearch: false
property var optionIconMap: ({}) property var optionIconMap: ({})
property var optionColorMap: ({})
function rebuildIconMap() { function rebuildIconMap() {
const map = {}; const map = {};
@@ -48,40 +49,59 @@ Item {
property bool alignPopupRight: false property bool alignPopupRight: false
property int dropdownWidth: 200 property int dropdownWidth: 200
property bool compactMode: text === "" && description === "" property bool compactMode: text === "" && description === ""
property bool showTrigger: true
property Item popupAnchorItem: null
property bool addHorizontalPadding: false property bool addHorizontalPadding: false
property string emptyText: "" property string emptyText: ""
property bool usePopupTransparency: !checkParentDisablesTransparency() property bool usePopupTransparency: !checkParentDisablesTransparency()
signal valueChanged(string value) signal valueChanged(string value)
property bool menuOpen: false
function closeDropdownMenu() { function closeDropdownMenu() {
if (!root.menuOpen && !dropdownMenu.opened && !dropdownMenu.visible)
return;
root.menuOpen = false;
dropdownMenu.close(); dropdownMenu.close();
} }
function openDropdownMenu() { function positionDropdownMenu() {
if (dropdownMenu.visible) {
dropdownMenu.close();
return;
}
if (root.options.length === 0)
return;
dropdownMenu.open();
let currentIndex = root.options.indexOf(root.currentValue); let currentIndex = root.options.indexOf(root.currentValue);
listView.positionViewAtIndex(currentIndex >= 0 ? currentIndex : 0, ListView.Beginning); listView.positionViewAtIndex(currentIndex >= 0 ? currentIndex : 0, ListView.Beginning);
const pos = dropdown.mapToItem(Overlay.overlay, 0, 0); const anchorItem = root.popupAnchorItem || dropdown;
const pos = anchorItem.mapToItem(Overlay.overlay, 0, 0);
const popupW = dropdownMenu.width; const popupW = dropdownMenu.width;
const popupH = dropdownMenu.height; const popupH = dropdownMenu.height;
const overlayH = Overlay.overlay.height; const overlayH = Overlay.overlay.height;
const goUp = root.openUpwards || pos.y + dropdown.height + popupH + 4 > overlayH; const goUp = root.openUpwards || pos.y + anchorItem.height + popupH + 4 > overlayH;
dropdownMenu.x = root.alignPopupRight ? pos.x + dropdown.width - popupW : pos.x - (root.popupWidthOffset / 2); dropdownMenu.x = root.alignPopupRight ? pos.x + anchorItem.width - popupW : pos.x - (root.popupWidthOffset / 2);
dropdownMenu.y = goUp ? pos.y - popupH - 4 : pos.y + dropdown.height + 4; dropdownMenu.y = goUp ? pos.y - popupH - 4 : pos.y + anchorItem.height + 4;
}
function showDropdownMenu() {
if (root.options.length === 0)
return;
if (root.menuOpen)
return;
root.menuOpen = true;
dropdownMenu.open();
positionDropdownMenu();
if (root.enableFuzzySearch) if (root.enableFuzzySearch)
searchField.forceActiveFocus(); searchField.forceActiveFocus();
} }
function openDropdownMenu() {
if (root.menuOpen) {
closeDropdownMenu();
return;
}
showDropdownMenu();
}
function resetSearch() { function resetSearch() {
searchField.text = ""; searchField.text = "";
dropdownMenu.fzfFinder = null; dropdownMenu.fzfFinder = null;
@@ -89,11 +109,11 @@ Item {
dropdownMenu.selectedIndex = -1; dropdownMenu.selectedIndex = -1;
} }
width: compactMode ? dropdownWidth : parent.width width: !showTrigger ? 0 : (compactMode ? dropdownWidth : parent.width)
implicitHeight: compactMode ? 40 : Math.max(60, labelColumn.implicitHeight + Theme.spacingM) implicitHeight: !showTrigger ? 0 : (compactMode ? 40 : Math.max(60, labelColumn.implicitHeight + Theme.spacingM))
Component.onDestruction: { Component.onDestruction: {
if (dropdownMenu.visible) if (root.menuOpen || dropdownMenu.opened || dropdownMenu.visible)
dropdownMenu.close(); dropdownMenu.close();
} }
@@ -106,7 +126,7 @@ Item {
anchors.leftMargin: root.addHorizontalPadding ? Theme.spacingM : 0 anchors.leftMargin: root.addHorizontalPadding ? Theme.spacingM : 0
anchors.rightMargin: Theme.spacingL anchors.rightMargin: Theme.spacingL
spacing: Theme.spacingXS spacing: Theme.spacingXS
visible: !root.compactMode visible: !root.compactMode && root.showTrigger
StyledText { StyledText {
text: root.text text: root.text
@@ -131,6 +151,7 @@ Item {
Rectangle { Rectangle {
id: dropdown id: dropdown
visible: root.showTrigger
width: root.compactMode ? parent.width : (root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : root.dropdownWidth)) width: root.compactMode ? parent.width : (root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : root.dropdownWidth))
height: 40 height: 40
anchors.right: parent.right anchors.right: parent.right
@@ -160,7 +181,19 @@ Item {
anchors.rightMargin: Theme.spacingS anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingS spacing: Theme.spacingS
DankColorSwatch {
id: triggerSwatch
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
visible: root.optionColorMap[root.currentValue] !== undefined
swatchColor: visible ? root.optionColorMap[root.currentValue] : "transparent"
}
DankIcon { DankIcon {
id: triggerIcon
name: root.optionIconMap[root.currentValue] ?? "" name: root.optionIconMap[root.currentValue] ?? ""
size: 18 size: 18
color: Theme.surfaceText color: Theme.surfaceText
@@ -173,7 +206,7 @@ Item {
text: root.currentValue !== "" ? root.currentValue : root.emptyText text: root.currentValue !== "" ? root.currentValue : root.emptyText
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: root.currentValue !== "" ? Theme.surfaceText : Theme.outline color: root.currentValue !== "" ? Theme.surfaceText : Theme.outline
width: contentRow.width - (contentRow.children[0].visible ? contentRow.children[0].width + contentRow.spacing : 0) width: contentRow.width - (triggerSwatch.visible ? triggerSwatch.width + contentRow.spacing : 0) - (triggerIcon.visible ? triggerIcon.width + contentRow.spacing : 0)
elide: Text.ElideRight elide: Text.ElideRight
wrapMode: Text.NoWrap wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
@@ -246,6 +279,7 @@ Item {
} }
onOpened: { onOpened: {
root.menuOpen = true;
selectedIndex = -1; selectedIndex = -1;
if (searchField.text.length > 0) { if (searchField.text.length > 0) {
initFinder(); initFinder();
@@ -256,6 +290,8 @@ Item {
} }
} }
onClosed: root.menuOpen = false
parent: Overlay.overlay parent: Overlay.overlay
width: root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : (dropdown.width + root.popupWidthOffset)) width: root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : (dropdown.width + root.popupWidthOffset))
height: { height: {
@@ -271,6 +307,40 @@ Item {
dim: false dim: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
enter: Transition {
NumberAnimation {
property: "scale"
from: 0.9
to: 1
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
exit: Transition {
NumberAnimation {
property: "scale"
from: 1
to: 0.9
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
NumberAnimation {
property: "opacity"
from: 1
to: 0
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
background: Rectangle { background: Rectangle {
color: "transparent" color: "transparent"
} }
@@ -406,6 +476,7 @@ Item {
property bool isSelected: dropdownMenu.selectedIndex === index property bool isSelected: dropdownMenu.selectedIndex === index
property bool isCurrentValue: root.currentValue === modelData property bool isCurrentValue: root.currentValue === modelData
property string iconName: root.optionIconMap[modelData] ?? "" property string iconName: root.optionIconMap[modelData] ?? ""
property var swatchColor: root.optionColorMap[modelData]
width: ListView.view.width width: ListView.view.width
height: 32 height: 32
@@ -420,6 +491,17 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS spacing: Theme.spacingS
DankColorSwatch {
id: optionSwatch
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
visible: delegateRoot.swatchColor !== undefined
swatchColor: visible ? delegateRoot.swatchColor : "transparent"
ringColor: delegateRoot.isCurrentValue ? Theme.primary : Theme.outline
}
DankIcon { DankIcon {
name: delegateRoot.iconName name: delegateRoot.iconName
size: 18 size: 18
@@ -433,7 +515,7 @@ Item {
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: delegateRoot.isCurrentValue ? Theme.primary : Theme.surfaceText color: delegateRoot.isCurrentValue ? Theme.primary : Theme.surfaceText
font.weight: delegateRoot.isCurrentValue ? Font.Medium : Font.Normal font.weight: delegateRoot.isCurrentValue ? Font.Medium : Font.Normal
width: root.popupWidth > 0 ? undefined : (delegateRoot.width - parent.x - Theme.spacingS * 2) width: root.popupWidth > 0 ? undefined : (delegateRoot.width - parent.x - Theme.spacingS * 2 - (optionSwatch.visible ? optionSwatch.width + parent.spacing : 0))
elide: root.popupWidth > 0 ? Text.ElideNone : Text.ElideRight elide: root.popupWidth > 0 ? Text.ElideNone : Text.ElideRight
wrapMode: Text.NoWrap wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
-7
View File
@@ -37,13 +37,6 @@ Item {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
renderType: root.smoothTransform ? Text.QtRendering : Text.NativeRendering renderType: root.smoothTransform ? Text.QtRendering : Text.NativeRendering
Behavior on color {
enabled: Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None
DankColorAnim {
duration: Theme.shorterDuration
easing.bezierCurve: Theme.expressiveCurves.standard
}
}
font.variableAxes: { font.variableAxes: {
"FILL": root.fill.toFixed(1), "FILL": root.fill.toFixed(1),
"GRAD": root.grade, "GRAD": root.grade,
+1 -1
View File
@@ -63,7 +63,7 @@ Item {
list.push(root.backgroundWindow); list.push(root.backgroundWindow);
return list.concat(KeyboardFocus.barWindows); return list.concat(KeyboardFocus.barWindows);
} }
active: KeyboardFocus.wantsGrab(root.shouldBeVisible, root.customKeyboardFocus) active: KeyboardFocus.wantsGrab(root.shouldBeVisible || root.isClosing, root.customKeyboardFocus)
} }
Loader { Loader {
+2 -2
View File
@@ -493,8 +493,8 @@ Item {
interval: Theme.variantCloseInterval(animationDuration) interval: Theme.variantCloseInterval(animationDuration)
onTriggered: { onTriggered: {
if (!shouldBeVisible) { if (!shouldBeVisible) {
isClosing = false;
contentWindow.visible = false; contentWindow.visible = false;
isClosing = false;
PopoutManager.hidePopout(popoutHandle); PopoutManager.hidePopout(popoutHandle);
popoutClosed(); popoutClosed();
} }
@@ -782,7 +782,7 @@ Item {
WlrLayershell.namespace: root.layerNamespace WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: root.effectivePopoutLayer WlrLayershell.layer: root.effectivePopoutLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldBeVisible, customKeyboardFocus) WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldBeVisible || isClosing, customKeyboardFocus)
readonly property bool _fullHeight: root.fullHeightSurface readonly property bool _fullHeight: root.fullHeightSurface
anchors { anchors {
+2 -2
View File
@@ -376,9 +376,9 @@ Item {
interval: Theme.variantCloseInterval(animationDuration) interval: Theme.variantCloseInterval(animationDuration)
onTriggered: { onTriggered: {
if (!shouldBeVisible) { if (!shouldBeVisible) {
isClosing = false;
contentWindow.visible = false; contentWindow.visible = false;
backgroundWindow.visible = false; backgroundWindow.visible = false;
isClosing = false;
PopoutManager.hidePopout(popoutHandle); PopoutManager.hidePopout(popoutHandle);
popoutClosed(); popoutClosed();
} }
@@ -607,7 +607,7 @@ Item {
WlrLayershell.namespace: root.layerNamespace WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: root.effectivePopoutLayer WlrLayershell.layer: root.effectivePopoutLayer
WlrLayershell.exclusiveZone: -1 WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldBeVisible, customKeyboardFocus) WlrLayershell.keyboardFocus: KeyboardFocus.keyboardFocus(shouldBeVisible || isClosing, customKeyboardFocus)
anchors { anchors {
left: true left: true
+2 -1
View File
@@ -35,6 +35,7 @@ StyledRect {
property color leftIconFocusedColor: Theme.primary property color leftIconFocusedColor: Theme.primary
property bool showClearButton: false property bool showClearButton: false
property bool showPasswordToggle: false property bool showPasswordToggle: false
property real rightAccessoryWidth: 0
property bool passwordVisible: false property bool passwordVisible: false
property bool usePopupTransparency: !checkParentDisablesTransparency() property bool usePopupTransparency: !checkParentDisablesTransparency()
property color backgroundColor: usePopupTransparency ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.surfaceContainerHigh property color backgroundColor: usePopupTransparency ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.surfaceContainerHigh
@@ -46,7 +47,7 @@ StyledRect {
property real cornerRadius: Theme.cornerRadius property real cornerRadius: Theme.cornerRadius
readonly property real leftPadding: Theme.spacingM + (leftIconName ? leftIconSize + Theme.spacingM : 0) readonly property real leftPadding: Theme.spacingM + (leftIconName ? leftIconSize + Theme.spacingM : 0)
readonly property real rightPadding: { readonly property real rightPadding: {
let p = Theme.spacingS; let p = Theme.spacingS + rightAccessoryWidth;
if (showPasswordToggle) if (showPasswordToggle)
p += 20 + Theme.spacingXS; p += 20 + Theme.spacingXS;
if (showClearButton && text.length > 0) if (showClearButton && text.length > 0)
+15 -10
View File
@@ -2,6 +2,8 @@ import QtQuick
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import qs.Common import qs.Common
import qs.Services
import qs.Widgets
PanelWindow { PanelWindow {
id: root id: root
@@ -17,14 +19,8 @@ PanelWindow {
function show(text, x, y, screen, leftAlign, rightAlign) { function show(text, x, y, screen, leftAlign, rightAlign) {
root.text = text; root.text = text;
if (screen) { targetScreen = screen ?? null;
targetScreen = screen; targetX = x;
const screenX = screen.x || 0;
targetX = x - screenX;
} else {
targetScreen = null;
targetX = x;
}
targetY = y; targetY = y;
alignLeft = leftAlign ?? false; alignLeft = leftAlign ?? false;
alignRight = rightAlign ?? false; alignRight = rightAlign ?? false;
@@ -69,12 +65,21 @@ PanelWindow {
} }
} }
WindowBlur {
targetWindow: root
blurX: 0
blurY: 0
blurWidth: root.visible ? root.width : 0
blurHeight: root.visible ? root.height : 0
blurRadius: Theme.cornerRadius
}
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
radius: Theme.cornerRadius radius: Theme.cornerRadius
border.width: 1 border.width: BlurService.enabled ? BlurService.borderWidth : 1
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
StyledText { StyledText {
id: textContent id: textContent

Some files were not shown because too many files have changed in this diff Show More