1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-28 14:05:21 -04:00

Compare commits

...

15 Commits

Author SHA1 Message Date
purian23 aed731efb0 fix(clipboard): restore Save button targets in editor 2026-05-25 23:19:42 -04:00
purian23 cf0632c077 feat(Clipboard): Revive ClipboardEditor PR
- Original PR #1916 by @nabaco
2026-05-24 23:28:21 -04:00
Nachum Barcohen e92da4a15f Show full clipboard text in editor 2026-05-24 22:34:24 -04:00
Nachum Barcohen 8abdff3220 Add clipboard editor shortcuts and hints 2026-05-24 22:34:24 -04:00
Nachum Barcohen 584d57a8de Add split save menu for clipboard editor 2026-05-24 22:34:05 -04:00
Nachum Barcohen afb5e59c29 feat(clipboard): Add editing capability to clipboard entries 2026-05-24 22:34:05 -04:00
purian23 d9525908f1 refactor(Notifications): further support for duplicate notification logic
- New setting to stack or suppress identical alerts (on by default)
Closes #2334
2026-05-24 22:22:34 -04:00
Lucas 6093c37b41 settings: add descriptions for DankBar menu (#2490) 2026-05-24 18:56:42 -04:00
purian23 bb05cbb6c5 feat(sessions): implement local user session switching functionality
- Core user is logged in tty1 while user two is in tty3, you can now seamlessly switch bewteen them
New IPC options:
- `dms ipc call sessions list`
- `dms switch-user [target]`
- New Powermenu switch users option
2026-05-24 18:33:38 -04:00
purian23 4d4af8f549 feat(Users): add user management UI in DMS Settings 2026-05-24 18:15:41 -04:00
Feng Yu 0b55fbcb15 fix(DankBar): Resolve tray freeze and wallpaper loss after DPMS resume (#2457)
Fixes #2354

Root cause (tray freeze): In clickThrough mode, the PanelWindow mask uses
sectionRect() with mapToItem() to compute input regions. After DPMS resume,
the PanelWindow is recreated with width=0, and mapToItem() returns wrong
positions. The right section's implicitWidth doesn't change after creation
(fixed-size tray icons), so the mask binding is never re-evaluated when the
compositor sets the actual screen width. Adding barWindow.width as a binding
dependency ensures the mask recalculates on resize.

Root cause (wallpaper loss): Wallpaper PanelWindows are recreated by Variants
during screen reconnection before the compositor finishes output initialization.
The wallpaper Image renders at 0x0 dimensions, resulting in a black screen.

Changes:
- DankBarWindow: add barWindow.width dependency to clickThrough mask bindings
- DMSShell: add surface recovery mechanism (screen reconnect + session resume)
  with progressive 2-pass timer (800ms + 2800ms) that recreates bar, Frame,
  wallpaper, and dock surfaces after the compositor is ready
- WlrOutputService: re-request output state on session resume
2026-05-22 09:05:41 -04:00
Domen Kožar 7476a220b5 feat: Blink WiFi/Bluetooth icons while connecting (#2448)
Pulses the WiFi and Bluetooth status icons while a connection is in
progress (lock screen, DankBar control center button, control center
compound pill). The pulse is implemented as a reusable Widgets/DankBlink
component, and the wifi-connecting condition is centralized as
NetworkService.isWifiConnecting.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:03:25 -04:00
Huỳnh Thiện Lộc aaff1ab61e feat: implement interactive microphone volume OSD and IPC controls (#2406)
* feat: implement interactive microphone volume OSD and persistence

Addresses #2388

* refactor: reduce scope to interactive microphone OSD and IPC controls only
2026-05-22 09:00:12 -04:00
Cloud 39622eb62a fix(lock): avoid U2F PAM polling in OR mode (#2459) 2026-05-22 08:59:11 -04:00
Lucas eea039f575 feat(Launcher/Spotlight): improve context keyboard navigation and mode persistence (#2467)
* feat(Spotlight): fix submenu keyboard navigation

* feat(Launcher/Spotlight): disable persisting last mode when changed by triggers

* feat(Launcher/Spotlight): add option to disable last mode being persisted

* fix(Launcher/Spotlight): fix context menu keys navigation

* fix(NiriOverviewOverlay): fix context menu keys navigation and position
2026-05-22 08:53:45 -04:00
54 changed files with 3203 additions and 147 deletions
+1
View File
@@ -541,5 +541,6 @@ func getCommonCommands() []*cobra.Command {
blurCmd, blurCmd,
trashCmd, trashCmd,
systemCmd, systemCmd,
switchUserCmd,
} }
} }
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
var switchUserCmd = &cobra.Command{
Use: "switch-user [target]",
Short: "Switch to another active session on this seat",
Long: `Switch the active VT to another running session.
With no target, prints the list of switchable sessions. Pass a username or a
numeric session ID to switch directly. Requires the target to already be a
running session on the same seat (use the greeter for a fresh login).`,
Args: cobra.MaximumNArgs(1),
Run: runSwitchUser,
}
type sessionInfo struct {
ID string
Name string
Seat string
TTY string
Type string
Class string
Active bool
State string
Current bool
}
func runSwitchUser(cmd *cobra.Command, args []string) {
currentID := os.Getenv("XDG_SESSION_ID")
sessions, err := listSessions(currentID)
if err != nil {
log.Fatalf("%v", err)
}
switchable := make([]sessionInfo, 0, len(sessions))
for _, s := range sessions {
if s.Class != "user" || s.State == "closing" || s.Current {
continue
}
switchable = append(switchable, s)
}
if len(args) == 0 {
if len(switchable) == 0 {
fmt.Println("No other active sessions on this seat.")
return
}
printSessions(switchable)
return
}
target := args[0]
picked, err := pickSession(switchable, target)
if err != nil {
fmt.Fprintln(os.Stderr, err)
if len(switchable) == 0 {
fmt.Fprintln(os.Stderr, "No other active sessions on this seat. Only already-running sessions can be switched to.")
} else {
fmt.Fprintln(os.Stderr, "\nSwitchable sessions:")
printSessions(switchable)
}
os.Exit(1)
}
if err := activateSession(picked.ID); err != nil {
log.Fatalf("loginctl activate %s: %v", picked.ID, err)
}
}
func listSessions(currentID string) ([]sessionInfo, error) {
listOut, err := exec.Command("loginctl", "list-sessions", "--no-legend").Output()
if err != nil {
return nil, fmt.Errorf("loginctl list-sessions: %w", err)
}
var ids []string
scanner := bufio.NewScanner(strings.NewReader(string(listOut)))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) == 0 {
continue
}
ids = append(ids, fields[0])
}
out := make([]sessionInfo, 0, len(ids))
for _, id := range ids {
s, err := showSession(id)
if err != nil {
continue
}
s.Current = currentID != "" && s.ID == currentID
out = append(out, s)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].ID < out[j].ID
})
return out, nil
}
func showSession(id string) (sessionInfo, error) {
out, err := exec.Command("loginctl", "show-session", id,
"-p", "Id", "-p", "Name", "-p", "Seat", "-p", "TTY",
"-p", "Type", "-p", "Class", "-p", "Active", "-p", "State").Output()
if err != nil {
return sessionInfo{}, err
}
fields := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
idx := strings.IndexByte(line, '=')
if idx <= 0 {
continue
}
fields[line[:idx]] = line[idx+1:]
}
if fields["Id"] == "" {
return sessionInfo{}, fmt.Errorf("session %s: no Id", id)
}
return sessionInfo{
ID: fields["Id"],
Name: fields["Name"],
Seat: fields["Seat"],
TTY: fields["TTY"],
Type: fields["Type"],
Class: fields["Class"],
Active: fields["Active"] == "yes",
State: fields["State"],
}, nil
}
func pickSession(sessions []sessionInfo, target string) (sessionInfo, error) {
for _, s := range sessions {
if s.ID == target {
return s, nil
}
}
matches := make([]sessionInfo, 0, 2)
for _, s := range sessions {
if s.Name == target {
matches = append(matches, s)
}
}
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.ID
}
return sessionInfo{}, fmt.Errorf("%s has multiple active sessions (%s); pass a session ID instead", target, strings.Join(ids, ", "))
}
return sessionInfo{}, fmt.Errorf("no switchable session matches %q", target)
}
func activateSession(id string) error {
return exec.Command("loginctl", "activate", id).Run()
}
func printSessions(sessions []sessionInfo) {
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", "ID", "USER", "TYPE", "SEAT", "TTY")
for _, s := range sessions {
tty := s.TTY
if tty == "" {
tty = "-"
}
seat := s.Seat
if seat == "" {
seat = "-"
}
fmt.Printf("%-6s %-12s %-8s %-8s %-8s\n", s.ID, s.Name, s.Type, seat, tty)
}
}
+46
View File
@@ -212,6 +212,52 @@ dms ipc call lock lock
dms ipc call lock isLocked dms ipc call lock isLocked
``` ```
## Target: `sessions`
Logind session enumeration and seat-local session switching. Wraps `loginctl list-sessions` and `loginctl activate`. Only switches between sessions that are *already running* on the current seat — creating a fresh login as another user requires a multi-session greeter setup (greetd-flexiserver / GDM / LightDM) and is out of scope.
### Functions
**`list`**
- Print every session DMS knows about as tab-separated columns: `sessionId\tusername\tseat\ttty\ttype\tcurrent-marker`
- Returns: Multi-line string. The current session is marked with `*current*`.
**`refresh`**
- Re-enumerate sessions in the background (the picker also refreshes itself on open)
- Returns: `"ok"`
**`open`**
- Refresh and open the Switch User picker on the focused screen
- Returns: `"ok"`
**`activate <sessionId>`**
- Activate a session by its numeric logind ID (the `Id=` field from `loginctl show-session`). Performs a VT switch
- Parameters: `sessionId` - Numeric session ID
- Returns: `"ok"` on dispatch, `"ERROR: missing session id"` if blank
- Note: Failures from `loginctl activate` surface through the `switchFailed` QML signal and a Log warning — the IPC call returns success once the spawn is queued, not after activation completes
**`switchTo <target>`**
- Switch to another session by username *or* session ID. The first non-current session matching the username wins; if there's no match, the call fails through the same logging path as `activate`
- Parameters: `target` - Username (e.g. `testuser2`) or numeric session ID
- Returns: `"ok"` on dispatch, `"ERROR: missing target (username or session id)"` if blank
### Examples
```bash
# Inspect what's switchable
dms ipc call sessions list
# Open the picker (useful for a keybind)
dms ipc call sessions open
# Jump straight to another logged-in user without the picker
dms ipc call sessions switchTo testuser2
# Or by session ID, when the user has multiple sessions
dms ipc call sessions activate 4
```
The dedicated `dms switch-user [target]` CLI command wraps the same behavior with a friendlier error path (it prints the switchable list when no target matches).
## Target: `inhibit` ## Target: `inhibit`
Idle inhibitor control to prevent automatic sleep/lock. Idle inhibitor control to prevent automatic sleep/lock.
+1 -1
View File
@@ -66,7 +66,7 @@ const DMS_ACTIONS = [
{ id: "spawn dms ipc call mpris increment 5", label: "Player Volume Up (5%)" }, { id: "spawn dms ipc call mpris increment 5", label: "Player Volume Up (5%)" },
{ id: "spawn dms ipc call mpris decrement 5", label: "Player Volume Down (5%)" }, { id: "spawn dms ipc call mpris decrement 5", label: "Player Volume Down (5%)" },
{ id: "spawn dms ipc call audio mute", label: "Volume Mute Toggle" }, { id: "spawn dms ipc call audio mute", label: "Volume Mute Toggle" },
{ id: "spawn dms ipc call audio micmute", label: "Microphone Mute Toggle" }, { id: "spawn dms ipc call mic mute", label: "Microphone Mute Toggle" },
{ id: "spawn dms ipc call audio cycleoutput", label: "Audio Output: Cycle" }, { id: "spawn dms ipc call audio cycleoutput", label: "Audio Output: Cycle" },
{ id: "spawn dms ipc call brightness increment 5 \"\"", label: "Brightness Up" }, { id: "spawn dms ipc call brightness increment 5 \"\"", label: "Brightness Up" },
{ id: "spawn dms ipc call brightness increment 1 \"\"", label: "Brightness Up (1%)" }, { id: "spawn dms ipc call brightness increment 1 \"\"", label: "Brightness Up (1%)" },
+6
View File
@@ -1179,6 +1179,12 @@ Singleton {
saveSettings(); saveSettings();
} }
function getLauncherRestoreMode() {
if (!SettingsData.rememberLastMode)
return "all";
return launcherLastMode || "all";
}
function setLauncherLastFileSearchType(type) { function setLauncherLastFileSearchType(type) {
launcherLastFileSearchType = type; launcherLastFileSearchType = type;
saveSettings(); saveSettings();
+3
View File
@@ -435,6 +435,7 @@ Singleton {
property int appLauncherGridColumns: 4 property int appLauncherGridColumns: 4
property bool spotlightCloseNiriOverview: true property bool spotlightCloseNiriOverview: true
property bool rememberLastQuery: false property bool rememberLastQuery: false
property bool rememberLastMode: true
property var spotlightSectionViewModes: ({}) property var spotlightSectionViewModes: ({})
onSpotlightSectionViewModesChanged: saveSettings() onSpotlightSectionViewModesChanged: saveSettings()
property var appDrawerSectionViewModes: ({}) property var appDrawerSectionViewModes: ({})
@@ -687,6 +688,7 @@ Singleton {
property int notificationTimeoutNormal: 5000 property int notificationTimeoutNormal: 5000
property int notificationTimeoutCritical: 0 property int notificationTimeoutCritical: 0
property bool notificationCompactMode: false property bool notificationCompactMode: false
property bool notificationDedupeEnabled: true
property int notificationPopupPosition: SettingsData.Position.Top property int notificationPopupPosition: SettingsData.Position.Top
property int notificationAnimationSpeed: SettingsData.AnimationSpeed.Short property int notificationAnimationSpeed: SettingsData.AnimationSpeed.Short
property int notificationCustomAnimationDuration: 400 property int notificationCustomAnimationDuration: 400
@@ -707,6 +709,7 @@ Singleton {
property bool osdBrightnessEnabled: true property bool osdBrightnessEnabled: true
property bool osdIdleInhibitorEnabled: true property bool osdIdleInhibitorEnabled: true
property bool osdMicMuteEnabled: true property bool osdMicMuteEnabled: true
property bool osdMicVolumeEnabled: true
property bool osdCapsLockEnabled: true property bool osdCapsLockEnabled: true
property bool osdPowerProfileEnabled: true property bool osdPowerProfileEnabled: true
property bool osdAudioOutputEnabled: true property bool osdAudioOutputEnabled: true
@@ -203,6 +203,7 @@ var SPEC = {
appLauncherGridColumns: { def: 4 }, appLauncherGridColumns: { def: 4 },
spotlightCloseNiriOverview: { def: true }, spotlightCloseNiriOverview: { def: true },
rememberLastQuery: { def: false }, rememberLastQuery: { def: false },
rememberLastMode: { def: true },
spotlightSectionViewModes: { def: {} }, spotlightSectionViewModes: { def: {} },
appDrawerSectionViewModes: { def: {} }, appDrawerSectionViewModes: { def: {} },
niriOverviewOverlayEnabled: { def: true }, niriOverviewOverlayEnabled: { def: true },
@@ -398,6 +399,7 @@ var SPEC = {
notificationTimeoutNormal: { def: 5000 }, notificationTimeoutNormal: { def: 5000 },
notificationTimeoutCritical: { def: 0 }, notificationTimeoutCritical: { def: 0 },
notificationCompactMode: { def: false }, notificationCompactMode: { def: false },
notificationDedupeEnabled: { def: true },
notificationPopupPosition: { def: 0 }, notificationPopupPosition: { def: 0 },
notificationAnimationSpeed: { def: 1 }, notificationAnimationSpeed: { def: 1 },
notificationCustomAnimationDuration: { def: 400 }, notificationCustomAnimationDuration: { def: 400 },
+131 -4
View File
@@ -30,6 +30,7 @@ import qs.Services
Item { Item {
id: root id: root
readonly property var log: Log.scoped("DMSShell") readonly property var log: Log.scoped("DMSShell")
readonly property var _sessionsServiceRef: SessionsService
property bool osdSurfacesLoaded: true property bool osdSurfacesLoaded: true
property int pendingOsdResumeReloads: 0 property int pendingOsdResumeReloads: 0
@@ -63,15 +64,27 @@ Item {
} }
} }
property bool wallpaperSurfacesLoaded: true
Loader { Loader {
id: blurredWallpaperBackgroundLoader id: blurredWallpaperBackgroundLoader
active: SettingsData.blurredWallpaperLayer && CompositorService.isNiri active: root.wallpaperSurfacesLoaded && SettingsData.blurredWallpaperLayer && CompositorService.isNiri
asynchronous: false asynchronous: false
sourceComponent: BlurredWallpaperBackground {} sourceComponent: BlurredWallpaperBackground {}
} }
WallpaperBackground {} DeferredAction {
id: wallpaperSurfaceReloadAction
onTriggered: root.wallpaperSurfacesLoaded = true
}
Loader {
id: wallpaperBackgroundLoader
active: root.wallpaperSurfacesLoaded
asynchronous: false
sourceComponent: WallpaperBackground {}
}
DesktopWidgetLayer {} DesktopWidgetLayer {}
@@ -168,6 +181,8 @@ Item {
property bool barSurfacesLoaded: true property bool barSurfacesLoaded: true
function recreateBarSurfaces() { function recreateBarSurfaces() {
log.info("Recreating bar surfaces, screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","));
if (barSurfacesLoaded) if (barSurfacesLoaded)
barSurfacesLoaded = false; barSurfacesLoaded = false;
barSurfaceReloadAction.schedule(); barSurfaceReloadAction.schedule();
@@ -217,7 +232,18 @@ Item {
} }
} }
Frame {} property bool frameSurfacesLoaded: true
Loader {
active: root.frameSurfacesLoaded
asynchronous: false
sourceComponent: Frame {}
}
DeferredAction {
id: frameSurfaceReloadAction
onTriggered: root.frameSurfacesLoaded = true
}
Repeater { Repeater {
id: dankBarRepeater id: dankBarRepeater
@@ -301,6 +327,81 @@ Item {
onTriggered: root.osdSurfacesLoaded = true onTriggered: root.osdSurfacesLoaded = true
} }
property bool hadRealScreen: true
function _hasRealScreen() {
for (let i = 0; i < Quickshell.screens.length; i++) {
if (Quickshell.screens[i].name.length > 0)
return true;
}
return false;
}
function triggerSurfaceRecovery(source) {
log.info("Surface recovery triggered by:", source,
"screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","),
"barLoaded:", root.barSurfacesLoaded,
"frameLoaded:", root.frameSurfacesLoaded,
"dockEnabled:", root.dockEnabled);
surfaceResumeRecoveryTimer.pass = 0;
surfaceResumeRecoveryTimer.interval = 800;
surfaceResumeRecoveryTimer.restart();
}
Connections {
target: Quickshell
function onScreensChanged() {
const hasReal = root._hasRealScreen();
log.info("Screens changed:", Quickshell.screens.length,
Quickshell.screens.map(s => "'" + s.name + "'").join(","),
"hasReal:", hasReal, "hadReal:", root.hadRealScreen);
if (!root.hadRealScreen && hasReal) {
log.info("Real screen reappeared after placeholder state, triggering surface recovery");
root.triggerSurfaceRecovery("screen-reconnect");
}
root.hadRealScreen = hasReal;
}
}
Timer {
id: surfaceResumeRecoveryTimer
interval: 800
repeat: false
property int pass: 0
onTriggered: {
pass++;
log.info("Surface recovery pass", pass,
"screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","));
root.recreateBarSurfaces();
if (root.frameSurfacesLoaded) {
root.frameSurfacesLoaded = false;
frameSurfaceReloadAction.schedule();
}
if (root.wallpaperSurfacesLoaded) {
root.wallpaperSurfacesLoaded = false;
wallpaperSurfaceReloadAction.schedule();
}
root.dockEnabled = false;
Qt.callLater(() => {
root.dockEnabled = true;
});
if (pass < 2) {
interval = 2000;
restart();
} else {
pass = 0;
interval = 800;
}
}
}
Component.onCompleted: { Component.onCompleted: {
dockRecreateDebounce.start(); dockRecreateDebounce.start();
// Force PolkitService singleton to initialize // Force PolkitService singleton to initialize
@@ -887,9 +988,17 @@ Item {
target: SessionService target: SessionService
function onSessionResumed() { function onSessionResumed() {
log.info("Session resumed: screens:", Quickshell.screens.length,
Quickshell.screens.map(s => s.name).join(","),
"barLoaded:", root.barSurfacesLoaded,
"frameLoaded:", root.frameSurfacesLoaded,
"dockEnabled:", root.dockEnabled);
root.pendingOsdResumeReloads = 2; root.pendingOsdResumeReloads = 2;
osdResumeRecreateTimer.interval = 400; osdResumeRecreateTimer.interval = 400;
osdResumeRecreateTimer.restart(); osdResumeRecreateTimer.restart();
root.triggerSurfaceRecovery("sessionResumed");
} }
} }
@@ -1038,12 +1147,30 @@ Item {
lock.activate(); lock.activate();
} }
onSwitchUserRequested: {
switchUserModalLoader.active = true;
Qt.callLater(() => {
if (switchUserModalLoader.item)
switchUserModalLoader.item.showFromPowerMenu();
});
}
Component.onCompleted: { Component.onCompleted: {
PopoutService.powerMenuModal = powerMenuModal; PopoutService.powerMenuModal = powerMenuModal;
} }
} }
} }
LazyLoader {
id: switchUserModalLoader
active: false
SwitchUserModal {
id: switchUserModal
}
}
LazyLoader { LazyLoader {
id: hyprKeybindsModalLoader id: hyprKeybindsModalLoader
@@ -1114,7 +1241,7 @@ Item {
Variants { Variants {
model: SettingsData.getFilteredScreens("osd") model: SettingsData.getFilteredScreens("osd")
delegate: MicMuteOSD { delegate: MicVolumeOSD {
modelData: item modelData: item
} }
} }
+30
View File
@@ -1794,6 +1794,36 @@ Item {
target: "outputs" target: "outputs"
} }
IpcHandler {
target: "mic"
function setvolume(percentage: string): string {
return AudioService.setMicVolume(parseInt(percentage));
}
function increment(step: string): string {
return AudioService.incrementMicVolume(step);
}
function decrement(step: string): string {
return AudioService.decrementMicVolume(step);
}
function mute(): string {
return AudioService.toggleMicMute();
}
function status(): string {
if (!AudioService.source || !AudioService.source.audio) {
return "No audio source available";
}
const volume = Math.round(AudioService.source.audio.volume * 100);
const muteStatus = AudioService.source.audio.muted ? " (muted)" : "";
return `Microphone: ${volume}%${muteStatus}`;
}
}
IpcHandler { IpcHandler {
function findTrayItem(itemId: string): var { function findTrayItem(itemId: string): var {
if (!itemId) if (!itemId)
@@ -145,6 +145,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
} }
} }
@@ -204,6 +205,7 @@ Item {
onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData) onDeleteRequested: clipboardContent.modal.deletePinnedEntry(modelData)
onPinRequested: clipboardContent.modal.pinEntry(modelData) onPinRequested: clipboardContent.modal.pinEntry(modelData)
onUnpinRequested: clipboardContent.modal.unpinEntry(modelData) onUnpinRequested: clipboardContent.modal.unpinEntry(modelData)
onEditRequested: clipboardContent.modal.editEntry(modelData)
} }
} }
@@ -0,0 +1,519 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
required property var modal
property var keyController: null
property var entry: null
property string editorText: ""
function decodeEntryData(data) {
if (!data) {
return "";
}
if (typeof data !== "string") {
return String(data);
}
const sanitized = data.replace(/\s+/g, "");
if (sanitized.length === 0) {
return "";
}
try {
const chars = new Array(sanitized.length);
for (let i = 0; i < sanitized.length; i++) {
chars[i] = sanitized.charAt(i);
}
let buffer = null;
if (typeof Qt !== "undefined" && typeof Qt.atob === "function") {
buffer = Qt.atob(chars);
} else if (typeof atob === "function") {
const binary = atob(sanitized);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
buffer = bytes.buffer;
}
if (!buffer || buffer.byteLength === 0) {
return data;
}
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
try {
return decodeURIComponent(escape(binary));
} catch (e) {
return binary;
}
} catch (e) {
return data;
}
}
function setEntry(newEntry) {
entry = newEntry;
editorText = newEntry?.text ?? newEntry?.preview ?? "";
if (editField) {
editField.text = editorText;
}
Qt.callLater(function () {
if (editField) {
editField.forceActiveFocus();
}
});
if (!newEntry || newEntry.isImage) {
return;
}
const requestedId = newEntry.id;
DMSService.sendRequest("clipboard.getEntry", {
"id": requestedId
}, function (response) {
if (response.error) {
return;
}
if (!root.entry || root.entry.id !== requestedId) {
return;
}
const result = response.result;
let fullText = "";
if (result?.data) {
fullText = root.decodeEntryData(result.data);
} else {
fullText = result?.preview ?? "";
}
if (!fullText || fullText.length === 0) {
return;
}
root.editorText = fullText;
if (editField) {
editField.text = fullText;
}
});
}
function saveEntry(action) {
const saveAction = action ?? "history";
DMSService.sendRequest("clipboard.copy", {
"text": root.editorText
}, function (response) {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update clipboard"));
return;
}
if (saveAction === "history") {
modal.mode = "history";
Qt.callLater(function () {
ClipboardService.reset();
ClipboardService.refresh();
if (keyController) {
keyController.reset();
}
});
return;
}
if (saveAction === "close") {
modal.hide();
return;
}
if (saveAction === "paste") {
ClipboardService.pasteClipboard(modal.hide);
}
});
}
function positionSaveMenu() {
saveMenu.width = Math.max(saveMenuColumn.implicitWidth + saveMenu.padding * 2, saveButton.width);
const pos = saveButton.mapToItem(Overlay.overlay, 0, 0);
const popupW = saveMenu.width;
const popupH = saveMenu.height;
const overlayW = Overlay.overlay.width;
const overlayH = Overlay.overlay.height;
let x = pos.x + (saveButton.width - popupW) / 2;
let y = pos.y + saveButton.height + 4;
if (y + popupH > overlayH) {
y = pos.y - popupH - 4;
}
x = Math.max(8, Math.min(x, overlayW - popupW - 8));
y = Math.max(8, y);
saveMenu.x = x;
saveMenu.y = y;
}
function toggleSaveMenu() {
if (saveMenu.visible) {
saveMenu.close();
return;
}
saveMenu.open();
positionSaveMenu();
Qt.callLater(positionSaveMenu);
}
Shortcut {
sequences: ["Escape"]
enabled: modal.mode === "editor"
onActivated: modal.mode = "history"
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
Item {
id: editorHeader
width: parent.width
height: ClipboardConstants.headerHeight
DankActionButton {
iconName: "arrow_back"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
onClicked: modal.mode = "history"
}
StyledText {
text: I18n.tr("Edit Clipboard")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.centerIn: parent
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
onClicked: modal.mode = "history"
}
}
StyledRect {
id: editFieldContainer
width: parent.width
height: Math.max(Theme.fontSizeMedium * 8, parent.height - editorHeader.height - editorActions.height - Theme.spacingM * 2)
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.color: editField.activeFocus ? Theme.primary : Theme.outlineMedium
border.width: editField.activeFocus ? 2 : 1
clip: true
DankIcon {
id: editIcon
name: "edit"
size: Theme.iconSize
color: editField.activeFocus ? Theme.primary : Theme.surfaceVariantText
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.top: parent.top
anchors.topMargin: Theme.spacingM
}
DankFlickable {
id: editScroll
anchors.left: editIcon.right
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.rightMargin: Theme.spacingM
anchors.topMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
clip: true
contentWidth: width
contentHeight: editField.height
TextEdit {
id: editField
width: editScroll.width
height: Math.max(editScroll.height, contentHeight)
text: root.editorText
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
wrapMode: TextEdit.Wrap
selectByMouse: true
onTextChanged: root.editorText = text
Keys.onPressed: function (event) {
const hasCtrl = (event.modifiers & Qt.ControlModifier) !== 0;
const hasShift = (event.modifiers & Qt.ShiftModifier) !== 0;
if (hasCtrl && event.key === Qt.Key_S) {
root.saveEntry(hasShift ? "close" : "history");
event.accepted = true;
return;
}
if (hasCtrl && hasShift && event.key === Qt.Key_V) {
root.saveEntry("paste");
event.accepted = true;
return;
}
}
}
}
StyledText {
text: I18n.tr("Edit clipboard text")
font.pixelSize: Theme.fontSizeMedium
color: Theme.outlineButton
anchors.left: editScroll.left
anchors.right: editScroll.right
anchors.top: editScroll.top
anchors.bottom: editScroll.bottom
visible: editField.text.length === 0 && !editField.activeFocus
wrapMode: Text.WordWrap
}
}
Row {
id: editorActions
width: parent.width
spacing: Theme.spacingS
Item {
id: buttonSpacer
width: Math.max(0, parent.width - cancelButton.width - saveButton.width - Theme.spacingS)
height: 1
}
DankButton {
id: cancelButton
text: I18n.tr("Cancel")
backgroundColor: Theme.surfaceContainerHigh
textColor: Theme.surfaceText
onClicked: modal.mode = "history"
}
Item {
id: saveButton
readonly property int buttonHeight: cancelButton.buttonHeight
readonly property int arrowWidth: Theme.iconSizeLarge
width: cancelButton.width
height: buttonHeight
Rectangle {
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.primary
}
Item {
id: saveMainArea
anchors.left: parent.left
anchors.right: saveArrowArea.left
anchors.top: parent.top
anchors.bottom: parent.bottom
}
StyledText {
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.onPrimary
anchors.centerIn: saveMainArea
}
Item {
id: saveArrowArea
width: saveButton.arrowWidth
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
}
Rectangle {
width: 1
height: parent.height - cancelButton.horizontalPadding
color: Theme.withAlpha(Theme.onPrimary, 0.2)
anchors.right: saveArrowArea.left
anchors.verticalCenter: parent.verticalCenter
}
DankIcon {
name: saveMenu.visible ? "expand_less" : "expand_more"
size: Theme.iconSizeSmall
color: Theme.onPrimary
anchors.centerIn: saveArrowArea
}
StateLayer {
z: 1
anchors.fill: saveMainArea
stateColor: Theme.onPrimary
onClicked: root.saveEntry("history")
}
StateLayer {
z: 1
anchors.fill: saveArrowArea
stateColor: Theme.onPrimary
onClicked: root.toggleSaveMenu()
}
}
}
Popup {
id: saveMenu
parent: Overlay.overlay
padding: Theme.spacingM
modal: true
dim: false
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: StyledRect {
radius: Theme.cornerRadius
color: Theme.surfaceContainer
border.color: Theme.outlineMedium
border.width: 1
}
contentItem: Column {
id: saveMenuColumn
spacing: Theme.spacingXS
StyledRect {
implicitWidth: saveMenuRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuSaveArea.containsMouse ? Theme.surfaceVariant : "transparent"
Row {
id: saveMenuRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "save"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuSaveArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
saveMenu.close();
root.saveEntry("history");
}
}
}
StyledRect {
implicitWidth: saveMenuCloseRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuCloseRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuCloseArea.containsMouse ? Theme.surfaceVariant : "transparent"
Row {
id: saveMenuCloseRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "close"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save and close")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuCloseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
saveMenu.close();
root.saveEntry("close");
}
}
}
StyledRect {
implicitWidth: saveMenuPasteRow.implicitWidth + Theme.spacingS * 2
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadius
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : "transparent"
opacity: modal.wtypeAvailable ? 1 : 0.5
Row {
id: saveMenuPasteRow
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: "content_paste"
size: Theme.iconSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Save and paste")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
}
}
MouseArea {
id: saveMenuPasteArea
anchors.fill: parent
hoverEnabled: true
enabled: modal.wtypeAvailable
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
saveMenu.close();
root.saveEntry("paste");
}
}
}
}
}
}
}
+20 -2
View File
@@ -17,6 +17,7 @@ Rectangle {
signal deleteRequested signal deleteRequested
signal pinRequested signal pinRequested
signal unpinRequested signal unpinRequested
signal editRequested
readonly property string entryType: modal ? modal.getEntryType(entry) : "text" readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : "" readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
@@ -70,6 +71,20 @@ Rectangle {
onClicked: entry.pinned ? unpinRequested() : pinRequested() onClicked: entry.pinned ? unpinRequested() : pinRequested()
} }
DankActionButton {
iconName: "edit"
iconSize: Theme.iconSize - 6
iconColor: Theme.surfaceText
onClicked: {
if (entryType === "image") {
// TODO - forward to editing software
} else {
editRequested();
}
}
}
DankActionButton { DankActionButton {
iconName: "close" iconName: "close"
iconSize: Theme.iconSize - 6 iconSize: Theme.iconSize - 6
@@ -142,8 +157,11 @@ Rectangle {
MouseArea { MouseArea {
id: mouseArea id: mouseArea
anchors.fill: parent anchors.left: parent.left
anchors.rightMargin: 80 anchors.right: actionButtons.left
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.bottom: parent.bottom
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPressed: mouse => { onPressed: mouse => {
@@ -43,6 +43,18 @@ DankModal {
service: ClipboardService service: ClipboardService
} }
property string mode: "history"
onModeChanged: {
if (mode !== "history") {
return;
}
Qt.callLater(function () {
if (contentLoader.item?.searchField) {
contentLoader.item.searchField.forceActiveFocus();
}
});
}
function updateFilteredModel() { function updateFilteredModel() {
ClipboardService.updateFilteredModel(); ClipboardService.updateFilteredModel();
} }
@@ -61,6 +73,7 @@ DankModal {
function show() { function show() {
open(); open();
mode = "history";
activeImageLoads = 0; activeImageLoads = 0;
shouldHaveFocus = true; shouldHaveFocus = true;
ClipboardService.reset(); ClipboardService.reset();
@@ -130,6 +143,21 @@ DankModal {
return ClipboardService.getEntryType(entry); return ClipboardService.getEntryType(entry);
} }
function editEntry(entry) {
if (!entry) {
return;
}
if (entry.isImage) {
return;
}
const editor = contentLoader.item?.editorView;
if (!editor) {
return;
}
editor.setEntry(entry);
mode = "editor";
}
visible: false visible: false
modalWidth: ClipboardConstants.modalWidth modalWidth: ClipboardConstants.modalWidth
modalHeight: ClipboardConstants.modalHeight modalHeight: ClipboardConstants.modalHeight
@@ -138,6 +166,7 @@ DankModal {
borderColor: Theme.outlineMedium borderColor: Theme.outlineMedium
borderWidth: 1 borderWidth: 1
enableShadow: true enableShadow: true
closeOnEscapeKey: mode !== "editor"
onBackgroundClicked: hide() onBackgroundClicked: hide()
modalFocusScope.Keys.onPressed: function (event) { modalFocusScope.Keys.onPressed: function (event) {
keyboardController.handleKey(event); keyboardController.handleKey(event);
@@ -174,9 +203,109 @@ DankModal {
property var confirmDialog: clearConfirmDialog property var confirmDialog: clearConfirmDialog
clipboardContent: Component { clipboardContent: Component {
ClipboardContent { Item {
modal: clipboardHistoryModal id: viewContainer
clearConfirmDialog: clipboardHistoryModal.confirmDialog
property alias editorView: editorView
property alias searchField: historyContent.searchField
anchors.fill: parent
Item {
id: historyView
anchors.fill: parent
opacity: 1
scale: 1
visible: opacity > 0.01
enabled: clipboardHistoryModal.mode === "history"
ClipboardContent {
id: historyContent
anchors.fill: parent
modal: clipboardHistoryModal
clearConfirmDialog: clipboardHistoryModal.confirmDialog
}
}
ClipboardEditor {
id: editorView
anchors.fill: parent
opacity: 0
scale: 0.98
visible: opacity > 0.01
enabled: clipboardHistoryModal.mode === "editor"
focus: clipboardHistoryModal.mode === "editor"
modal: clipboardHistoryModal
keyController: keyboardController
}
states: [
State {
name: "history"
when: clipboardHistoryModal.mode === "history"
PropertyChanges {
target: historyView
opacity: 1
scale: 1
}
PropertyChanges {
target: editorView
opacity: 0
scale: 0.98
}
},
State {
name: "editor"
when: clipboardHistoryModal.mode === "editor"
PropertyChanges {
target: historyView
opacity: 0
scale: 0.98
}
PropertyChanges {
target: editorView
opacity: 1
scale: 1
}
}
]
transitions: [
Transition {
from: "history"
to: "editor"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
},
Transition {
from: "editor"
to: "history"
ParallelAnimation {
NumberAnimation {
property: "opacity"
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
NumberAnimation {
property: "scale"
duration: Theme.shortDuration
easing.type: Theme.emphasizedEasing
}
}
}
]
} }
} }
} }
@@ -66,7 +66,24 @@ QtObject {
} }
} }
function editSelected() {
const entries = modal.activeTab === "saved" ? ClipboardService.pinnedEntries : ClipboardService.unpinnedEntries;
if (!entries || entries.length === 0) {
return;
}
const index = ClipboardService.selectedIndex >= 0 && ClipboardService.selectedIndex < entries.length ? ClipboardService.selectedIndex : 0;
modal.editEntry(entries[index]);
}
function handleKey(event) { function handleKey(event) {
if (modal.mode === "editor") {
if (event.key === Qt.Key_Escape) {
modal.mode = "history";
event.accepted = true;
}
return;
}
switch (event.key) { switch (event.key) {
case Qt.Key_Escape: case Qt.Key_Escape:
if (ClipboardService.keyboardNavigationActive) { if (ClipboardService.keyboardNavigationActive) {
@@ -152,6 +169,10 @@ QtObject {
event.accepted = true; event.accepted = true;
} }
return; return;
case Qt.Key_E:
editSelected();
event.accepted = true;
return;
} }
} }
@@ -10,7 +10,7 @@ Rectangle {
readonly property string hintsText: { readonly property string hintsText: {
if (!wtypeAvailable) if (!wtypeAvailable)
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close"); return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close");
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • Esc: Close"); return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • F10: Help • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • F10: Help • Esc: Close");
} }
height: ClipboardConstants.keyboardHintsHeight height: ClipboardConstants.keyboardHintsHeight
@@ -22,13 +22,17 @@ Rectangle {
z: 100 z: 100
Column { Column {
width: parent.width - Theme.spacingL * 2
anchors.centerIn: parent anchors.centerIn: parent
spacing: 2 spacing: 2
StyledText { StyledText {
text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Del: Delete • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • F10: Help") text: keyboardHints.enterToPaste ? I18n.tr("↑/↓: Navigate • Enter: Paste • Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("↑/↓: Navigate • Enter/Ctrl+C: Copy • Del: Delete • Ctrl+E: Edit • Ctrl+S: Pin/Unpin • F10: Help")
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
@@ -36,6 +40,9 @@ Rectangle {
text: keyboardHints.hintsText text: keyboardHints.hintsText
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText color: Theme.surfaceText
width: parent.width
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
} }
} }
@@ -39,7 +39,7 @@ Item {
signal itemExecuted signal itemExecuted
signal searchCompleted signal searchCompleted
signal modeChanged(string mode) signal modeChanged(string mode, bool userInitiated)
signal queryChanged(string query) signal queryChanged(string query)
signal viewModeChanged(string sectionId, string mode) signal viewModeChanged(string sectionId, string mode)
signal searchQueryRequested(string query) signal searchQueryRequested(string query)
@@ -440,7 +440,7 @@ Item {
} }
} }
function setMode(mode, isAutoSwitch, fileTypeOverride) { function setMode(mode, isAutoSwitch, fileTypeOverride, notPersist) {
if (searchMode === mode) { if (searchMode === mode) {
if (mode === "files" && fileTypeOverride !== undefined && fileSearchType !== fileTypeOverride) { if (mode === "files" && fileTypeOverride !== undefined && fileSearchType !== fileTypeOverride) {
fileSearchType = fileTypeOverride; fileSearchType = fileTypeOverride;
@@ -458,7 +458,7 @@ Item {
if (mode === "files") { if (mode === "files") {
fileSearchType = fileTypeOverride !== undefined ? fileTypeOverride : (SessionData.launcherLastFileSearchType || "all"); fileSearchType = fileTypeOverride !== undefined ? fileTypeOverride : (SessionData.launcherLastFileSearchType || "all");
} }
modeChanged(mode); modeChanged(mode, !isAutoSwitch && notPersist !== true);
performSearch(); performSearch();
var filesInAll = mode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll) && searchQuery.length > 0; var filesInAll = mode === "all" && (SettingsData.dankLauncherV2IncludeFilesInAll || SettingsData.dankLauncherV2IncludeFoldersInAll) && searchQuery.length > 0;
if (mode === "files" || filesInAll) { if (mode === "files" || filesInAll) {
@@ -471,7 +471,7 @@ Item {
return; return;
autoSwitchedToFiles = false; autoSwitchedToFiles = false;
searchMode = previousSearchMode; searchMode = previousSearchMode;
modeChanged(previousSearchMode); modeChanged(previousSearchMode, false);
performSearch(); performSearch();
} }
@@ -1897,7 +1897,7 @@ Item {
if (browseTrigger && browseTrigger.length > 0) { if (browseTrigger && browseTrigger.length > 0) {
searchQueryRequested(browseTrigger); searchQueryRequested(browseTrigger);
} else { } else {
setMode("plugins"); setMode("plugins", false, undefined, true);
pluginFilter = browsePluginId; pluginFilter = browsePluginId;
performSearch(); performSearch();
} }
@@ -396,7 +396,7 @@ Item {
spotlightContent.searchField.text = query; spotlightContent.searchField.text = query;
} }
if (spotlightContent.controller) { if (spotlightContent.controller) {
var targetMode = mode || SessionData.launcherLastMode || "all"; var targetMode = mode || SessionData.getLauncherRestoreMode();
spotlightContent.controller.searchMode = targetMode; spotlightContent.controller.searchMode = targetMode;
spotlightContent.controller.activePluginId = ""; spotlightContent.controller.activePluginId = "";
spotlightContent.controller.activePluginName = ""; spotlightContent.controller.activePluginName = "";
@@ -539,8 +539,8 @@ Item {
Connections { Connections {
target: spotlightContent?.controller ?? null target: spotlightContent?.controller ?? null
function onModeChanged(mode) { function onModeChanged(mode, userInitiated) {
if (spotlightContent.controller.autoSwitchedToFiles) if (!userInitiated || !SettingsData.rememberLastMode)
return; return;
SessionData.setLauncherLastMode(mode); SessionData.setLauncherLastMode(mode);
} }
@@ -928,8 +928,12 @@ Item {
} }
} }
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => { Keys.onEscapePressed: event => {
root.hide(); root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true; event.accepted = true;
} }
} }
@@ -145,7 +145,7 @@ Item {
spotlightContent.closeTransientUi?.(); spotlightContent.closeTransientUi?.();
const targetQuery = query || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : ""); const targetQuery = query || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : "");
const targetMode = mode || SessionData.launcherLastMode || "all"; const targetMode = mode || SessionData.getLauncherRestoreMode();
if (spotlightContent.searchField) { if (spotlightContent.searchField) {
spotlightContent.searchField.text = targetQuery; spotlightContent.searchField.text = targetQuery;
@@ -489,8 +489,12 @@ Item {
} }
} }
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => { Keys.onEscapePressed: event => {
root.hide(); root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true; event.accepted = true;
} }
} }
@@ -148,7 +148,7 @@ Item {
spotlightContent.searchField.text = targetQuery; spotlightContent.searchField.text = targetQuery;
} }
if (spotlightContent.controller) { if (spotlightContent.controller) {
var targetMode = mode || SessionData.launcherLastMode || "all"; var targetMode = mode || SessionData.getLauncherRestoreMode();
spotlightContent.controller.searchMode = targetMode; spotlightContent.controller.searchMode = targetMode;
spotlightContent.controller.activePluginId = ""; spotlightContent.controller.activePluginId = "";
spotlightContent.controller.activePluginName = ""; spotlightContent.controller.activePluginName = "";
@@ -260,8 +260,8 @@ Item {
Connections { Connections {
target: spotlightContent?.controller ?? null target: spotlightContent?.controller ?? null
function onModeChanged(mode) { function onModeChanged(mode, userInitiated) {
if (spotlightContent.controller.autoSwitchedToFiles) if (!userInitiated || !SettingsData.rememberLastMode || (mode !== "all" && mode !== "apps"))
return; return;
SessionData.setLauncherLastMode(mode); SessionData.setLauncherLastMode(mode);
} }
@@ -536,8 +536,12 @@ Item {
} }
} }
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => { Keys.onEscapePressed: event => {
root.hide(); root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true; event.accepted = true;
} }
} }
@@ -17,6 +17,7 @@ FocusScope {
property alias controller: controller property alias controller: controller
property alias resultsList: resultsList property alias resultsList: resultsList
property alias actionPanel: actionPanel property alias actionPanel: actionPanel
readonly property alias activeContextMenu: contextMenu
property bool editMode: false property bool editMode: false
property var editingApp: null property var editingApp: null
@@ -340,6 +340,31 @@ Item {
return count; return count;
} }
function handleKey(event) {
if (!openState)
return;
switch (event.key) {
case Qt.Key_Down:
selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
selectPrevious();
event.accepted = true;
return;
case Qt.Key_Return:
case Qt.Key_Enter:
activateSelected();
event.accepted = true;
return;
case Qt.Key_Left:
case Qt.Key_Escape:
hide();
event.accepted = true;
return;
}
}
function selectNext() { function selectNext() {
if (visibleItemCount > 0) { if (visibleItemCount > 0) {
keyboardNavigation = true; keyboardNavigation = true;
@@ -12,6 +12,7 @@ FocusScope {
property var parentModal: null property var parentModal: null
property alias searchField: searchInput property alias searchField: searchInput
property alias controller: searchController property alias controller: searchController
readonly property alias activeContextMenu: contextMenu
readonly property bool _hasQuery: searchInput.text.length > 0 readonly property bool _hasQuery: searchInput.text.length > 0
readonly property real _searchBarH: 56 readonly property real _searchBarH: 56
@@ -239,8 +240,8 @@ FocusScope {
Connections { Connections {
target: searchController target: searchController
function onModeChanged(mode) { function onModeChanged(mode, userInitiated) {
if (searchController.autoSwitchedToFiles) if (!userInitiated || !SettingsData.rememberLastMode)
return; return;
SessionData.setLauncherLastMode(mode); SessionData.setLauncherLastMode(mode);
} }
+13
View File
@@ -81,6 +81,8 @@ DankModal {
executeAction(action); executeAction(action);
} }
signal switchUserRequested
function executeAction(action) { function executeAction(action) {
if (action === "lock") { if (action === "lock") {
close(); close();
@@ -92,6 +94,11 @@ DankModal {
Quickshell.execDetached(["dms", "restart"]); Quickshell.execDetached(["dms", "restart"]);
return; return;
} }
if (action === "switchuser") {
close();
switchUserRequested();
return;
}
close(); close();
root.powerActionRequested(action, "", ""); root.powerActionRequested(action, "", "");
} }
@@ -216,6 +223,12 @@ DankModal {
"label": I18n.tr("Restart DMS"), "label": I18n.tr("Restart DMS"),
"key": "D" "key": "D"
}; };
case "switchuser":
return {
"icon": "switch_account",
"label": I18n.tr("Switch User"),
"key": "U"
};
default: default:
return { return {
"icon": "help", "icon": "help",
@@ -555,5 +555,20 @@ FocusScope {
Qt.callLater(() => item.forceActiveFocus()); Qt.callLater(() => item.forceActiveFocus());
} }
} }
Loader {
id: usersLoader
anchors.fill: parent
active: root.currentIndex === 35
visible: active
focus: active
sourceComponent: UsersTab {}
onActiveChanged: {
if (active && item)
Qt.callLater(() => item.forceActiveFocus());
}
}
} }
} }
@@ -293,6 +293,12 @@ Rectangle {
"tabIndex": 20, "tabIndex": 20,
"updaterOnly": true "updaterOnly": true
}, },
{
"id": "users",
"text": I18n.tr("Users"),
"icon": "manage_accounts",
"tabIndex": 35
},
{ {
"id": "window_rules", "id": "window_rules",
"text": I18n.tr("Window Rules"), "text": I18n.tr("Window Rules"),
+272
View File
@@ -0,0 +1,272 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
property bool lockOnSwitch: false
function showFromPowerMenu() {
root.lockOnSwitch = false;
SessionsService.refresh();
open();
}
function showFromLockScreen() {
root.lockOnSwitch = true;
SessionsService.refresh();
open();
}
function _formatTty(s) {
if (s.tty && s.tty.length > 0)
return s.tty;
if (s.seat && s.seat.length > 0)
return s.seat;
return I18n.tr("remote");
}
function _formatType(s) {
if (!s.type || s.type.length === 0)
return "";
switch (s.type) {
case "wayland":
return "Wayland";
case "x11":
return "X11";
case "tty":
return "TTY";
default:
return s.type.charAt(0).toUpperCase() + s.type.substring(1);
}
}
function _doSwitch(sessionId, username) {
if (root.lockOnSwitch && typeof SessionService !== "undefined" && SessionService.loginctlAvailable)
SessionService.lock();
SessionsService.activate(sessionId, null);
close();
}
layerNamespace: "dms:switch-user-modal"
shouldBeVisible: false
allowStacking: true
modalWidth: 420
modalHeight: contentLoader.item ? Math.min(540, contentLoader.item.implicitHeight + Theme.spacingM * 2) : 320
enableShadow: true
shouldHaveFocus: true
onBackgroundClicked: close()
Connections {
target: SessionsService
function onSwitchRequested() {
root.showFromPowerMenu();
}
}
content: Component {
Item {
anchors.fill: parent
implicitHeight: mainColumn.implicitHeight
Column {
id: mainColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL
anchors.topMargin: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "switch_account"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Switch User")
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
StyledText {
width: parent.width
text: I18n.tr("Select an active session to switch to. The current session stays running in the background.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
visible: SessionsService.otherSessions().length > 0
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: SessionsService.otherSessions().length > 0
Repeater {
model: SessionsService.otherSessions()
Rectangle {
id: sessionRow
required property var modelData
width: parent.width
height: 64
radius: Theme.cornerRadius
color: sessionMouse.containsMouse ? Theme.surfacePressed : Theme.surfaceContainerHighest
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "account_circle"
size: Theme.iconSize + 4
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - 4 - chevron.width - Theme.spacingM * 2
spacing: 2
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: sessionRow.modelData.username
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: {
const tty = root._formatTty(sessionRow.modelData);
const type = root._formatType(sessionRow.modelData);
const parts = [];
if (type)
parts.push(type);
parts.push(I18n.tr("session %1").arg(sessionRow.modelData.sessionId));
if (tty)
parts.push(tty);
return parts.join(" · ");
}
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
DankIcon {
id: chevron
name: "chevron_right"
size: Theme.iconSize
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: sessionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root._doSwitch(sessionRow.modelData.sessionId, sessionRow.modelData.username)
}
}
}
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: SessionsService.otherSessions().length === 0
Rectangle {
width: parent.width
height: bodyCol.implicitHeight + Theme.spacingM * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "info"
size: Theme.iconSize
color: Theme.surfaceVariantText
anchors.top: parent.top
anchors.topMargin: 2
}
Column {
id: bodyCol
width: parent.width - Theme.iconSize - Theme.spacingM
spacing: 4
StyledText {
text: I18n.tr("No other active sessions on this seat")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
width: parent.width
text: I18n.tr("To sign in as a different user, log out and pick the account from the greeter. Creating a fresh session in parallel needs a multi-session greeter (greetd-flexiserver / GDM / LightDM).")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
}
}
}
}
Row {
width: parent.width
spacing: Theme.spacingM
layoutDirection: Qt.RightToLeft
DankButton {
text: I18n.tr("Close")
backgroundColor: Theme.surfaceVariantAlpha
textColor: Theme.surfaceText
onClicked: root.close()
}
DankButton {
visible: SessionsService.otherSessions().length === 0 && !root.lockOnSwitch
text: I18n.tr("Log out")
iconName: "logout"
backgroundColor: Theme.primary
textColor: Theme.primaryText
onClicked: {
if (typeof SessionService !== "undefined")
SessionService.logout();
root.close();
}
}
}
Item {
width: 1
height: Theme.spacingS
}
}
}
}
}
@@ -301,12 +301,22 @@ Column {
property var widgetDef: root.model?.getWidgetForId(widgetData.id || "") property var widgetDef: root.model?.getWidgetForId(widgetData.id || "")
width: parent.width width: parent.width
height: 60 height: 60
iconBlinking: {
const id = widgetData.id || "";
if (id === "wifi")
return NetworkService.isWifiConnecting;
if (id === "bluetooth")
return BluetoothService.connecting;
return false;
}
iconName: { iconName: {
switch (widgetData.id || "") { switch (widgetData.id || "") {
case "wifi": case "wifi":
{ {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return "sync"; return "sync";
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return NetworkService.wifiSignalIcon;
const status = NetworkService.networkStatus; const status = NetworkService.networkStatus;
if (status === "ethernet") if (status === "ethernet")
@@ -360,6 +370,8 @@ Column {
{ {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return NetworkService.wifiEnabled ? I18n.tr("Disabling WiFi...", "network status") : I18n.tr("Enabling WiFi...", "network status"); return NetworkService.wifiEnabled ? I18n.tr("Disabling WiFi...", "network status") : I18n.tr("Enabling WiFi...", "network status");
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return NetworkService.connectingSSID || I18n.tr("Connecting...", "network status");
const status = NetworkService.networkStatus; const status = NetworkService.networkStatus;
if (status === "ethernet") if (status === "ethernet")
@@ -400,6 +412,8 @@ Column {
{ {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return I18n.tr("Please wait...", "network status"); return I18n.tr("Please wait...", "network status");
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return I18n.tr("Connecting...", "network status");
const status = NetworkService.networkStatus; const status = NetworkService.networkStatus;
if (status === "ethernet") if (status === "ethernet")
@@ -422,6 +436,8 @@ Column {
return I18n.tr("No adapters", "bluetooth status"); return I18n.tr("No adapters", "bluetooth status");
if (!BluetoothService.adapter || !BluetoothService.adapter.enabled) if (!BluetoothService.adapter || !BluetoothService.adapter.enabled)
return I18n.tr("Off", "bluetooth status"); return I18n.tr("Off", "bluetooth status");
if (BluetoothService.connecting)
return I18n.tr("Connecting...", "bluetooth status");
const primaryDevice = (() => { const primaryDevice = (() => {
if (!BluetoothService.adapter || !BluetoothService.adapter.devices) if (!BluetoothService.adapter || !BluetoothService.adapter.devices)
return null; return null;
@@ -10,6 +10,7 @@ Rectangle {
property string iconName: "" property string iconName: ""
property color iconColor: Theme.surfaceText property color iconColor: Theme.surfaceText
property bool iconBlinking: false
property string primaryText: "" property string primaryText: ""
property string secondaryText: "" property string secondaryText: ""
property bool expanded: false property bool expanded: false
@@ -109,10 +110,16 @@ Rectangle {
} }
DankIcon { DankIcon {
id: pillIcon
anchors.centerIn: parent anchors.centerIn: parent
name: iconName name: iconName
size: Theme.iconSize size: Theme.iconSize
color: isActive ? _tileIconActive : _tileIconInactive color: isActive ? _tileIconActive : _tileIconInactive
DankBlink {
target: pillIcon
running: root.iconBlinking
}
} }
DankRipple { DankRipple {
+3 -3
View File
@@ -726,7 +726,7 @@ PanelWindow {
item: clickThroughEnabled ? null : inputMask item: clickThroughEnabled ? null : inputMask
Region { Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._leftSection, false, barWindow._revealProgress) : { readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._leftSection, false, barWindow._revealProgress + barWindow.width * 0) : {
"x": 0, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -739,7 +739,7 @@ PanelWindow {
} }
Region { Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._centerSection, true, barWindow._revealProgress) : { readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._centerSection, true, barWindow._revealProgress + barWindow.width * 0) : {
"x": 0, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -752,7 +752,7 @@ PanelWindow {
} }
Region { Region {
readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._rightSection, false, barWindow._revealProgress) : { readonly property var r: barWindow.clickThroughEnabled ? barWindow.sectionRect(barWindow._rightSection, false, barWindow._revealProgress + barWindow.width * 0) : {
"x": 0, "x": 0,
"y": 0, "y": 0,
"w": 0, "w": 0,
@@ -131,9 +131,19 @@ BasePill {
function getNetworkIconColor() { function getNetworkIconColor() {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return Theme.primary; return Theme.primary;
if (NetworkService.isConnecting && !NetworkService.ethernetConnected)
return Theme.primary;
return NetworkService.networkStatus !== "disconnected" ? Theme.primary : Theme.surfaceText; return NetworkService.networkStatus !== "disconnected" ? Theme.primary : Theme.surfaceText;
} }
function getIconBlinking(id) {
if (id === "network")
return NetworkService.isWifiConnecting;
if (id === "bluetooth")
return BluetoothService.connecting;
return false;
}
function getVolumeIconName() { function getVolumeIconName() {
if (!AudioService.sink?.audio) if (!AudioService.sink?.audio)
return "volume_up"; return "volume_up";
@@ -485,6 +495,7 @@ BasePill {
} }
DankIcon { DankIcon {
id: vIconOnlyItem
anchors.centerIn: parent anchors.centerIn: parent
visible: !verticalGroupItem.modelData.composite visible: !verticalGroupItem.modelData.composite
name: { name: {
@@ -515,7 +526,7 @@ BasePill {
case "vpn": case "vpn":
return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText; return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText;
case "bluetooth": case "bluetooth":
return BluetoothService.connected ? Theme.primary : Theme.surfaceText; return (BluetoothService.connected || BluetoothService.connecting) ? Theme.primary : Theme.surfaceText;
case "battery": case "battery":
return root.getBatteryIconColor(); return root.getBatteryIconColor();
case "printer": case "printer":
@@ -524,6 +535,11 @@ BasePill {
return Theme.widgetIconColor; return Theme.widgetIconColor;
} }
} }
DankBlink {
target: vIconOnlyItem
running: root.getIconBlinking(verticalGroupItem.modelData.id)
}
} }
DankIcon { DankIcon {
@@ -687,7 +703,7 @@ BasePill {
case "vpn": case "vpn":
return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText; return NetworkService.vpnConnected ? Theme.primary : Theme.surfaceText;
case "bluetooth": case "bluetooth":
return BluetoothService.connected ? Theme.primary : Theme.surfaceText; return (BluetoothService.connected || BluetoothService.connecting) ? Theme.primary : Theme.surfaceText;
case "battery": case "battery":
return root.getBatteryIconColor(); return root.getBatteryIconColor();
case "printer": case "printer":
@@ -696,6 +712,11 @@ BasePill {
return Theme.widgetIconColor; return Theme.widgetIconColor;
} }
} }
DankBlink {
target: iconOnlyItem
running: root.getIconBlinking(horizontalGroupItem.modelData.id)
}
} }
Rectangle { Rectangle {
+13
View File
@@ -36,6 +36,8 @@ Rectangle {
signal closed signal closed
signal switchUserRequested
function updateVisibleActions() { function updateVisibleActions() {
const allActions = powerMenuActionsOverride !== undefined ? powerMenuActionsOverride : ((typeof SettingsData !== "undefined" && SettingsData.powerMenuActions) ? SettingsData.powerMenuActions : ["logout", "suspend", "hibernate", "reboot", "poweroff"]); const allActions = powerMenuActionsOverride !== undefined ? powerMenuActionsOverride : ((typeof SettingsData !== "undefined" && SettingsData.powerMenuActions) ? SettingsData.powerMenuActions : ["logout", "suspend", "hibernate", "reboot", "poweroff"]);
const hibernateSupported = (typeof SessionService !== "undefined" && SessionService.hibernateSupported) || false; const hibernateSupported = (typeof SessionService !== "undefined" && SessionService.hibernateSupported) || false;
@@ -128,6 +130,12 @@ Rectangle {
"label": I18n.tr("Hibernate"), "label": I18n.tr("Hibernate"),
"key": "H" "key": "H"
}; };
case "switchuser":
return {
"icon": "switch_account",
"label": I18n.tr("Switch User"),
"key": "U"
};
default: default:
return { return {
"icon": "help", "icon": "help",
@@ -183,6 +191,11 @@ Rectangle {
function executeAction(action) { function executeAction(action) {
if (!action) if (!action)
return; return;
if (action === "switchuser") {
hide();
switchUserRequested();
return;
}
if (typeof SessionService === "undefined") if (typeof SessionService === "undefined")
return; return;
hide(); hide();
+49 -6
View File
@@ -9,6 +9,7 @@ import Quickshell.Hyprland
import Quickshell.Io import Quickshell.Io
import Quickshell.Services.Mpris import Quickshell.Services.Mpris
import qs.Common import qs.Common
import qs.Modals
import qs.Services import qs.Services
import qs.Widgets import qs.Widgets
@@ -73,6 +74,10 @@ Item {
return pam && (pam.u2fState === "waiting" || pam.u2fState === "insert") && !pam.u2fPending; return pam && (pam.u2fState === "waiting" || pam.u2fState === "insert") && !pam.u2fPending;
} }
function canStartSecurityKeyUnlock() {
return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.passwd.active && !pam.u2f.active && !pam.u2fPending && !root.unlocking;
}
Component.onCompleted: { Component.onCompleted: {
WeatherService.addRef(); WeatherService.addRef();
UserInfoService.getUserInfo(); UserInfoService.getUserInfo();
@@ -761,6 +766,9 @@ Item {
if (enterButton.visible) { if (enterButton.visible) {
margin += enterButton.width + 2; margin += enterButton.width + 2;
} }
if (securityKeyButton.visible) {
margin += securityKeyButton.width;
}
if (virtualKeyboardButton.visible) { if (virtualKeyboardButton.visible) {
margin += virtualKeyboardButton.width; margin += virtualKeyboardButton.width;
} }
@@ -854,7 +862,7 @@ Item {
anchors.left: lockIconContainer.right anchors.left: lockIconContainer.right
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))) anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))))
anchors.rightMargin: 2 anchors.rightMargin: 2
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
@@ -896,7 +904,7 @@ Item {
StyledText { StyledText {
anchors.left: lockIconContainer.right anchors.left: lockIconContainer.right
anchors.leftMargin: Theme.spacingM anchors.leftMargin: Theme.spacingM
anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))) anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))))
anchors.rightMargin: 2 anchors.rightMargin: 2
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
@@ -926,7 +934,7 @@ Item {
DankActionButton { DankActionButton {
id: revealButton id: revealButton
anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)) anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))
anchors.rightMargin: 0 anchors.rightMargin: 0
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
iconName: parent.showPassword ? "visibility_off" : "visibility" iconName: parent.showPassword ? "visibility_off" : "visibility"
@@ -936,10 +944,26 @@ Item {
onClicked: parent.showPassword = !parent.showPassword onClicked: parent.showPassword = !parent.showPassword
} }
DankActionButton { DankActionButton {
id: virtualKeyboardButton id: securityKeyButton
anchors.right: enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right) anchors.right: enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)
anchors.rightMargin: enterButton.visible ? 0 : Theme.spacingS anchors.rightMargin: 0
anchors.verticalCenter: parent.verticalCenter
iconName: "passkey"
buttonSize: 32
visible: root.canStartSecurityKeyUnlock()
enabled: visible
onClicked: {
passwordField.text = "";
root.passwordBuffer = "";
pam.u2f.startForAlternativeAuth();
}
}
DankActionButton {
id: virtualKeyboardButton
anchors.right: securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))
anchors.rightMargin: securityKeyButton.visible || enterButton.visible ? 0 : Theme.spacingS
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
iconName: "keyboard" iconName: "keyboard"
buttonSize: 32 buttonSize: 32
@@ -1438,6 +1462,7 @@ Item {
} }
DankIcon { DankIcon {
id: lockNetworkIcon
name: { name: {
if (NetworkService.wifiToggling) if (NetworkService.wifiToggling)
return "sync"; return "sync";
@@ -1451,9 +1476,14 @@ Item {
} }
} }
size: Theme.iconSize - 2 size: Theme.iconSize - 2
color: NetworkService.networkStatus !== "disconnected" ? "white" : Qt.rgba(255, 255, 255, 0.5) color: (NetworkService.networkStatus !== "disconnected" || NetworkService.isConnecting) ? "white" : Qt.rgba(255, 255, 255, 0.5)
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: NetworkService.networkAvailable visible: NetworkService.networkAvailable
DankBlink {
target: lockNetworkIcon
running: NetworkService.isWifiConnecting
}
} }
DankIcon { DankIcon {
@@ -1465,11 +1495,17 @@ Item {
} }
DankIcon { DankIcon {
id: lockBluetoothIcon
name: "bluetooth" name: "bluetooth"
size: Theme.iconSize - 2 size: Theme.iconSize - 2
color: "white" color: "white"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: BluetoothService.available && BluetoothService.enabled visible: BluetoothService.available && BluetoothService.enabled
DankBlink {
target: lockBluetoothIcon
running: BluetoothService.connecting
}
} }
DankIcon { DankIcon {
@@ -1693,5 +1729,12 @@ Item {
Qt.callLater(() => passwordField.forceActiveFocus()); Qt.callLater(() => passwordField.forceActiveFocus());
} }
} }
onSwitchUserRequested: {
switchUserPicker.showFromLockScreen();
}
}
SwitchUserModal {
id: switchUserPicker
} }
} }
+31 -5
View File
@@ -20,6 +20,7 @@ Scope {
property string fprintState property string fprintState
property string u2fState property string u2fState
property bool u2fPending: false property bool u2fPending: false
property string u2fPendingMode
property string buffer property string buffer
signal flashMsg signal flashMsg
@@ -35,6 +36,7 @@ Scope {
passwdActiveTimeout.running = false; passwdActiveTimeout.running = false;
unlockRequestTimeout.running = false; unlockRequestTimeout.running = false;
root.u2fPending = false; root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = ""; root.u2fState = "";
root.unlockInProgress = false; root.unlockInProgress = false;
} }
@@ -58,6 +60,7 @@ Scope {
u2fErrorRetry.running = false; u2fErrorRetry.running = false;
u2fPendingTimeout.running = false; u2fPendingTimeout.running = false;
root.u2fPending = false; root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = ""; root.u2fState = "";
unlockRequestTimeout.restart(); unlockRequestTimeout.restart();
unlockRequested(); unlockRequested();
@@ -79,6 +82,7 @@ Scope {
u2fErrorRetry.running = false; u2fErrorRetry.running = false;
u2fPendingTimeout.running = false; u2fPendingTimeout.running = false;
root.u2fPending = false; root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = ""; root.u2fState = "";
fprint.checkAvail(); fprint.checkAvail();
} }
@@ -142,6 +146,7 @@ Scope {
unlockRequestTimeout.running = false; unlockRequestTimeout.running = false;
root.unlockInProgress = false; root.unlockInProgress = false;
root.u2fPending = false; root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = ""; root.u2fState = "";
u2fPendingTimeout.running = false; u2fPendingTimeout.running = false;
u2f.abort(); u2f.abort();
@@ -243,9 +248,8 @@ Scope {
return; return;
} }
if (SettingsData.u2fMode === "or") { if (SettingsData.u2fMode === "or")
start(); abort();
}
} }
function startForSecondFactor(): void { function startForSecondFactor(): void {
@@ -255,6 +259,18 @@ Scope {
} }
abort(); abort();
root.u2fPending = true; root.u2fPending = true;
root.u2fPendingMode = "and";
root.u2fState = "";
u2fPendingTimeout.restart();
start();
}
function startForAlternativeAuth(): void {
if (!available || !SettingsData.enableU2f || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active)
return;
abort();
root.u2fPending = true;
root.u2fPendingMode = "or";
root.u2fState = ""; root.u2fState = "";
u2fPendingTimeout.restart(); u2fPendingTimeout.restart();
start(); start();
@@ -281,9 +297,19 @@ Scope {
abort(); abort();
if (root.u2fPending) { if (root.u2fPending) {
if (root.u2fPendingMode === "or") {
root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = root.u2fState === "waiting" ? "" : "insert";
u2fPendingTimeout.running = false;
fprint.checkAvail();
return;
}
if (root.u2fState === "waiting") { if (root.u2fState === "waiting") {
// AND mode: device was found but auth failed back to password // AND mode: device was found but auth failed back to password
root.u2fPending = false; root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = ""; root.u2fState = "";
fprint.checkAvail(); fprint.checkAvail();
} else { } else {
@@ -292,9 +318,7 @@ Scope {
u2fErrorRetry.restart(); u2fErrorRetry.restart();
} }
} else { } else {
// OR mode: prompt to insert key, silently retry
root.u2fState = "insert"; root.u2fState = "insert";
u2fErrorRetry.restart();
} }
} }
} }
@@ -367,6 +391,7 @@ Scope {
root.fprintState = ""; root.fprintState = "";
root.u2fState = ""; root.u2fState = "";
root.u2fPending = false; root.u2fPending = false;
root.u2fPendingMode = "";
root.lockMessage = ""; root.lockMessage = "";
root.resetAuthFlows(); root.resetAuthFlows();
fprint.checkAvail(); fprint.checkAvail();
@@ -399,6 +424,7 @@ Scope {
u2fPendingTimeout.running = false; u2fPendingTimeout.running = false;
unlockRequestTimeout.running = false; unlockRequestTimeout.running = false;
root.u2fPending = false; root.u2fPending = false;
root.u2fPendingMode = "";
root.u2fState = ""; root.u2fState = "";
u2f.checkAvail(); u2f.checkAvail();
} }
@@ -182,26 +182,30 @@ Rectangle {
Row { Row {
width: parent.width width: parent.width
spacing: Theme.spacingXS spacing: Theme.spacingXS
readonly property real reservedTrailingWidth: historySeparator.implicitWidth + Math.max(historyTimeText.implicitWidth, 72) + spacing
StyledText { Item {
id: historyTitleText width: Math.max(0, parent.width - historySeparator.implicitWidth - Math.max(historyTimeText.implicitWidth, 72) - parent.spacing * 2)
width: Math.min(implicitWidth, Math.max(0, parent.width - parent.reservedTrailingWidth)) height: historyTitleText.implicitHeight
text: { visible: historyTitleText.text.length > 0
let title = historyItem.summary || "";
const appName = historyItem.appName || ""; StyledText {
const prefix = appName + " • "; id: historyTitleText
if (appName && title.toLowerCase().startsWith(prefix.toLowerCase())) { anchors.fill: parent
title = title.substring(prefix.length); text: {
let title = historyItem.summary || "";
const appName = historyItem.appName || "";
const prefix = appName + " • ";
if (appName && title.toLowerCase().startsWith(prefix.toLowerCase())) {
title = title.substring(prefix.length);
}
return title;
} }
return title; color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
} }
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
} }
StyledText { StyledText {
id: historySeparator id: historySeparator
-29
View File
@@ -1,29 +0,0 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
DankOSD {
id: root
osdWidth: Theme.iconSize + Theme.spacingS * 2
osdHeight: Theme.iconSize + Theme.spacingS * 2
autoHideInterval: 2000
enableMouseInteraction: false
Connections {
target: AudioService
function onMicMuteChanged() {
if (SettingsData.osdMicMuteEnabled) {
root.show()
}
}
}
content: DankIcon {
anchors.centerIn: parent
name: AudioService.source && AudioService.source.audio && AudioService.source.audio.muted ? "mic_off" : "mic"
size: Theme.iconSize
color: AudioService.source && AudioService.source.audio && AudioService.source.audio.muted ? Theme.error : Theme.primary
}
}
+253
View File
@@ -0,0 +1,253 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
DankOSD {
id: root
readonly property bool useVertical: isVerticalLayout
property int _displayVolume: 0
function _syncVolume() {
if (!AudioService.source?.audio)
return;
_displayVolume = Math.round(AudioService.source.audio.volume * 100);
}
osdWidth: useVertical ? (40 + Theme.spacingS * 2) : Math.min(260, Screen.width - Theme.spacingM * 2)
osdHeight: useVertical ? Math.min(260, Screen.height - Theme.spacingM * 2) : (40 + Theme.spacingS * 2)
autoHideInterval: 3000
enableMouseInteraction: true
Connections {
target: AudioService.source?.audio ?? null
function onVolumeChanged() {
root._syncVolume();
if (SettingsData.osdMicVolumeEnabled)
root.show();
}
function onMutedChanged() {
if (SettingsData.osdMicMuteEnabled)
root.show();
}
}
Connections {
target: AudioService
function onSourceChanged() {
root._syncVolume();
if (root.shouldBeVisible && SettingsData.osdMicVolumeEnabled)
root.show();
}
}
content: Loader {
anchors.fill: parent
sourceComponent: useVertical ? verticalContent : horizontalContent
}
Component {
id: horizontalContent
Item {
property int gap: Theme.spacingS
anchors.centerIn: parent
width: parent.width - Theme.spacingS * 2
height: 40
Rectangle {
width: Theme.iconSize
height: Theme.iconSize
radius: Theme.iconSize / 2
color: "transparent"
x: parent.gap
anchors.verticalCenter: parent.verticalCenter
DankIcon {
anchors.centerIn: parent
name: AudioService.source?.audio?.muted ? "mic_off" : "mic"
size: Theme.iconSize
color: muteButton.containsMouse ? Theme.primary : (AudioService.source?.audio?.muted ? Theme.error : Theme.surfaceText)
}
MouseArea {
id: muteButton
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: AudioService.toggleMicMute()
onContainsMouseChanged: setChildHovered(containsMouse || volumeSlider.containsMouse)
}
}
DankSlider {
id: volumeSlider
width: parent.width - Theme.iconSize - parent.gap * 3
height: 40
x: parent.gap * 2 + Theme.iconSize
anchors.verticalCenter: parent.verticalCenter
minimum: 0
maximum: 100
enabled: AudioService.source?.audio ?? false
showValue: true
unit: "%"
thumbOutlineColor: Theme.surfaceContainer
valueOverride: root._displayVolume
alwaysShowValue: SettingsData.osdAlwaysShowValue
Component.onCompleted: {
root._syncVolume();
value = root._displayVolume;
}
onSliderValueChanged: newValue => {
if (!AudioService.source?.audio)
return;
SessionData.suppressOSDTemporarily();
AudioService.source.audio.volume = newValue / 100;
resetHideTimer();
}
onContainsMouseChanged: setChildHovered(containsMouse || muteButton.containsMouse)
Binding on value {
value: root._displayVolume
when: !volumeSlider.pressed
}
}
}
}
Component {
id: verticalContent
Item {
anchors.fill: parent
property int gap: Theme.spacingS
Rectangle {
width: Theme.iconSize
height: Theme.iconSize
radius: Theme.iconSize / 2
color: "transparent"
anchors.horizontalCenter: parent.horizontalCenter
y: gap
DankIcon {
anchors.centerIn: parent
name: AudioService.source?.audio?.muted ? "mic_off" : "mic"
size: Theme.iconSize
color: muteButtonVert.containsMouse ? Theme.primary : (AudioService.source?.audio?.muted ? Theme.error : Theme.surfaceText)
}
MouseArea {
id: muteButtonVert
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: AudioService.toggleMicMute()
onContainsMouseChanged: setChildHovered(containsMouse || vertSliderArea.containsMouse)
}
}
Item {
id: vertSlider
width: 12
height: parent.height - Theme.iconSize - gap * 3 - 24
anchors.horizontalCenter: parent.horizontalCenter
y: gap * 2 + Theme.iconSize
property bool dragging: false
property int value: root._displayVolume
Rectangle {
id: vertTrack
width: parent.width
height: parent.height
anchors.centerIn: parent
color: Theme.outline
radius: Theme.cornerRadius
}
Rectangle {
id: vertFill
width: parent.width
height: (vertSlider.value / 100) * parent.height
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
color: AudioService.source?.audio?.muted ? Theme.error : Theme.primary
radius: Theme.cornerRadius
}
Rectangle {
id: vertHandle
width: 24
height: 8
radius: Theme.cornerRadius
y: {
const ratio = vertSlider.value / 100;
const travel = parent.height - height;
return Math.max(0, Math.min(travel, travel * (1 - ratio)));
}
anchors.horizontalCenter: parent.horizontalCenter
color: AudioService.source?.audio?.muted ? Theme.error : Theme.primary
border.width: 3
border.color: Theme.surfaceContainer
}
MouseArea {
id: vertSliderArea
anchors.fill: parent
anchors.margins: -12
enabled: AudioService.source?.audio ?? false
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: setChildHovered(containsMouse || muteButtonVert.containsMouse)
onPressed: mouse => {
vertSlider.dragging = true;
updateVolume(mouse);
}
onReleased: vertSlider.dragging = false
onPositionChanged: mouse => {
if (pressed)
updateVolume(mouse);
}
onClicked: mouse => updateVolume(mouse)
function updateVolume(mouse) {
if (!AudioService.source?.audio)
return;
const ratio = 1.0 - (mouse.y / height);
const volume = Math.max(0, Math.min(100, Math.round(ratio * 100)));
SessionData.suppressOSDTemporarily();
AudioService.source.audio.volume = volume / 100;
resetHideTimer();
}
}
}
StyledText {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottomMargin: gap
text: vertSlider.value + "%"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
visible: SettingsData.osdAlwaysShowValue
}
}
}
}
@@ -597,6 +597,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Auto-hide") text: I18n.tr("Auto-hide")
description: I18n.tr("Automatically hide the bar when the pointer moves away")
checked: selectedBarConfig?.autoHide ?? false checked: selectedBarConfig?.autoHide ?? false
onToggled: toggled => { onToggled: toggled => {
SettingsData.updateBarConfig(selectedBarId, { SettingsData.updateBarConfig(selectedBarId, {
@@ -623,6 +624,7 @@ Item {
id: hideDelaySlider id: hideDelaySlider
width: parent.width - parent.parent.leftPadding width: parent.width - parent.parent.leftPadding
text: I18n.tr("Hide Delay") text: I18n.tr("Hide Delay")
description: I18n.tr("Time to wait before hiding after the pointer leaves")
value: selectedBarConfig?.autoHideDelay ?? 250 value: selectedBarConfig?.autoHideDelay ?? 250
minimum: 0 minimum: 0
maximum: 2000 maximum: 2000
@@ -645,6 +647,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
width: parent.width - parent.leftPadding width: parent.width - parent.leftPadding
text: I18n.tr("Strict auto-hide", "Dank bar setting: hide the bar when the pointer leaves even if a menu or bar popover is still open") text: I18n.tr("Strict auto-hide", "Dank bar setting: hide the bar when the pointer leaves even if a menu or bar popover is still open")
description: I18n.tr("Hide the bar when the pointer leaves even if a popout is still open")
checked: selectedBarConfig?.autoHideStrict ?? false checked: selectedBarConfig?.autoHideStrict ?? false
onToggled: toggled => { onToggled: toggled => {
SettingsData.updateBarConfig(selectedBarId, { SettingsData.updateBarConfig(selectedBarId, {
@@ -658,6 +661,7 @@ Item {
width: parent.width - parent.leftPadding width: parent.width - parent.leftPadding
visible: CompositorService.isNiri || CompositorService.isHyprland visible: CompositorService.isNiri || CompositorService.isHyprland
text: I18n.tr("Hide When Windows Open") text: I18n.tr("Hide When Windows Open")
description: I18n.tr("Show the bar only when no windows are open")
checked: selectedBarConfig?.showOnWindowsOpen ?? false checked: selectedBarConfig?.showOnWindowsOpen ?? false
onToggled: toggled => { onToggled: toggled => {
SettingsData.updateBarConfig(selectedBarId, { SettingsData.updateBarConfig(selectedBarId, {
@@ -676,6 +680,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Manual Show/Hide") text: I18n.tr("Manual Show/Hide")
description: I18n.tr("Toggle bar visibility manually via IPC")
checked: selectedBarConfig?.visible ?? true checked: selectedBarConfig?.visible ?? true
onToggled: toggled => { onToggled: toggled => {
SettingsData.updateBarConfig(selectedBarId, { SettingsData.updateBarConfig(selectedBarId, {
@@ -694,6 +699,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Click Through") text: I18n.tr("Click Through")
description: I18n.tr("Mouse clicks pass through the bar to windows behind it")
checked: selectedBarConfig?.clickThrough ?? false checked: selectedBarConfig?.clickThrough ?? false
onToggled: toggled => SettingsData.updateBarConfig(selectedBarId, { onToggled: toggled => SettingsData.updateBarConfig(selectedBarId, {
clickThrough: toggled clickThrough: toggled
@@ -713,6 +719,7 @@ Item {
enabled: !SettingsData.frameEnabled enabled: !SettingsData.frameEnabled
opacity: SettingsData.frameEnabled ? 0.5 : 1.0 opacity: SettingsData.frameEnabled ? 0.5 : 1.0
text: I18n.tr("Show on Overview") text: I18n.tr("Show on Overview")
description: I18n.tr("Show the bar when niri overview is active")
checked: selectedBarConfig?.openOnOverview ?? false checked: selectedBarConfig?.openOnOverview ?? false
onToggled: toggled => { onToggled: toggled => {
SettingsData.updateBarConfig(selectedBarId, { SettingsData.updateBarConfig(selectedBarId, {
@@ -759,6 +766,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: edgeSpacingSlider id: edgeSpacingSlider
text: I18n.tr("Edge Spacing") text: I18n.tr("Edge Spacing")
description: I18n.tr("Space between the bar and screen edges")
value: selectedBarConfig?.spacing ?? 4 value: selectedBarConfig?.spacing ?? 4
minimum: 0 minimum: 0
maximum: 32 maximum: 32
@@ -780,6 +788,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: exclusiveZoneSlider id: exclusiveZoneSlider
text: I18n.tr("Exclusive Zone Offset") text: I18n.tr("Exclusive Zone Offset")
description: I18n.tr("Fine-tune the space reserved for the bar from the screen edge")
value: selectedBarConfig?.bottomGap ?? 0 value: selectedBarConfig?.bottomGap ?? 0
minimum: -50 minimum: -50
maximum: 50 maximum: 50
@@ -801,6 +810,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: sizeSlider id: sizeSlider
text: I18n.tr("Size") text: I18n.tr("Size")
description: I18n.tr("Adjust the bar height via inner padding")
value: selectedBarConfig?.innerPadding ?? 4 value: selectedBarConfig?.innerPadding ?? 4
minimum: -8 minimum: -8
maximum: 24 maximum: 24
@@ -822,6 +832,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: widgetPaddingSlider id: widgetPaddingSlider
text: I18n.tr("Padding") text: I18n.tr("Padding")
description: I18n.tr("Inner padding applied to each widget")
value: selectedBarConfig?.widgetPadding ?? 8 value: selectedBarConfig?.widgetPadding ?? 8
minimum: 0 minimum: 0
maximum: 32 maximum: 32
@@ -852,6 +863,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Auto Popup Gaps") text: I18n.tr("Auto Popup Gaps")
description: I18n.tr("Automatically calculate popup gap based on bar spacing")
checked: selectedBarConfig?.popupGapsAuto ?? true checked: selectedBarConfig?.popupGapsAuto ?? true
onToggled: checked => { onToggled: checked => {
SettingsData.updateBarConfig(selectedBarId, { SettingsData.updateBarConfig(selectedBarId, {
@@ -877,6 +889,7 @@ Item {
id: popupGapsManualSlider id: popupGapsManualSlider
width: parent.width - parent.parent.leftPadding width: parent.width - parent.parent.leftPadding
text: I18n.tr("Manual Gap Size") text: I18n.tr("Manual Gap Size")
description: I18n.tr("Override the popup gap size when auto is disabled")
value: selectedBarConfig?.popupGapsManual ?? 4 value: selectedBarConfig?.popupGapsManual ?? 4
minimum: 0 minimum: 0
maximum: 50 maximum: 50
@@ -907,6 +920,7 @@ Item {
id: barTransparencySlider id: barTransparencySlider
visible: !SettingsData.frameEnabled visible: !SettingsData.frameEnabled
text: I18n.tr("Bar Transparency") text: I18n.tr("Bar Transparency")
description: I18n.tr("Opacity of the bar background")
value: (selectedBarConfig?.transparency ?? 1.0) * 100 value: (selectedBarConfig?.transparency ?? 1.0) * 100
minimum: 0 minimum: 0
maximum: 100 maximum: 100
@@ -929,6 +943,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: widgetTransparencySlider id: widgetTransparencySlider
text: I18n.tr("Widget Transparency") text: I18n.tr("Widget Transparency")
description: I18n.tr("Opacity of widget backgrounds")
value: (selectedBarConfig?.widgetTransparency ?? 1.0) * 100 value: (selectedBarConfig?.widgetTransparency ?? 1.0) * 100
minimum: 0 minimum: 0
maximum: 100 maximum: 100
@@ -1023,6 +1038,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Square Corners") text: I18n.tr("Square Corners")
description: I18n.tr("Remove corner rounding from the bar")
visible: !SettingsData.frameEnabled visible: !SettingsData.frameEnabled
checked: selectedBarConfig?.squareCorners ?? false checked: selectedBarConfig?.squareCorners ?? false
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
@@ -1032,6 +1048,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("No Background") text: I18n.tr("No Background")
description: I18n.tr("Make the bar background fully transparent")
visible: !SettingsData.frameEnabled visible: !SettingsData.frameEnabled
checked: selectedBarConfig?.noBackground ?? false checked: selectedBarConfig?.noBackground ?? false
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
@@ -1041,6 +1058,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Maximize Widget Icons") text: I18n.tr("Maximize Widget Icons")
description: I18n.tr("Stretch widget icons to fill the available bar height")
checked: selectedBarConfig?.maximizeWidgetIcons ?? false checked: selectedBarConfig?.maximizeWidgetIcons ?? false
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
maximizeWidgetIcons: checked maximizeWidgetIcons: checked
@@ -1049,6 +1067,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Maximize Widget Text") text: I18n.tr("Maximize Widget Text")
description: I18n.tr("Stretch widget text to fill the available bar height")
checked: selectedBarConfig?.maximizeWidgetText ?? false checked: selectedBarConfig?.maximizeWidgetText ?? false
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
maximizeWidgetText: checked maximizeWidgetText: checked
@@ -1057,6 +1076,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Remove Widget Padding") text: I18n.tr("Remove Widget Padding")
description: I18n.tr("Remove inner padding from all widgets")
checked: selectedBarConfig?.removeWidgetPadding ?? false checked: selectedBarConfig?.removeWidgetPadding ?? false
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
removeWidgetPadding: checked removeWidgetPadding: checked
@@ -1072,6 +1092,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Goth Corners") text: I18n.tr("Goth Corners")
description: I18n.tr("Apply inverse concave corner cutouts to the bar")
visible: !SettingsData.frameEnabled visible: !SettingsData.frameEnabled
checked: selectedBarConfig?.gothCornersEnabled ?? false checked: selectedBarConfig?.gothCornersEnabled ?? false
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
@@ -1081,6 +1102,7 @@ Item {
SettingsToggleRow { SettingsToggleRow {
text: I18n.tr("Corner Radius Override") text: I18n.tr("Corner Radius Override")
description: I18n.tr("Use a custom radius for goth corner cutouts")
checked: selectedBarConfig?.gothCornerRadiusOverride ?? false checked: selectedBarConfig?.gothCornerRadiusOverride ?? false
visible: selectedBarConfig?.gothCornersEnabled ?? false visible: selectedBarConfig?.gothCornersEnabled ?? false
onToggled: checked => SettingsData.updateBarConfig(selectedBarId, { onToggled: checked => SettingsData.updateBarConfig(selectedBarId, {
@@ -1239,6 +1261,7 @@ Item {
SettingsButtonGroupRow { SettingsButtonGroupRow {
text: I18n.tr("Color") text: I18n.tr("Color")
description: I18n.tr("Theme color used for the border")
model: ["Surface", "Secondary", "Primary"] model: ["Surface", "Secondary", "Primary"]
currentIndex: { currentIndex: {
switch (selectedBarConfig?.borderColor || "surfaceText") { switch (selectedBarConfig?.borderColor || "surfaceText") {
@@ -1276,6 +1299,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: borderOpacitySlider id: borderOpacitySlider
text: I18n.tr("Opacity") text: I18n.tr("Opacity")
description: I18n.tr("Transparency of the border")
value: (selectedBarConfig?.borderOpacity ?? 1.0) * 100 value: (selectedBarConfig?.borderOpacity ?? 1.0) * 100
minimum: 0 minimum: 0
maximum: 100 maximum: 100
@@ -1298,6 +1322,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: borderThicknessSlider id: borderThicknessSlider
text: I18n.tr("Thickness") text: I18n.tr("Thickness")
description: I18n.tr("Width of the border in pixels")
value: selectedBarConfig?.borderThickness ?? 1 value: selectedBarConfig?.borderThickness ?? 1
minimum: 1 minimum: 1
maximum: 10 maximum: 10
@@ -1329,6 +1354,7 @@ Item {
SettingsButtonGroupRow { SettingsButtonGroupRow {
text: I18n.tr("Color") text: I18n.tr("Color")
description: I18n.tr("Theme color used for the widget outline")
model: ["Surface", "Secondary", "Primary"] model: ["Surface", "Secondary", "Primary"]
currentIndex: { currentIndex: {
switch (selectedBarConfig?.widgetOutlineColor || "primary") { switch (selectedBarConfig?.widgetOutlineColor || "primary") {
@@ -1366,6 +1392,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: widgetOutlineOpacitySlider id: widgetOutlineOpacitySlider
text: I18n.tr("Opacity") text: I18n.tr("Opacity")
description: I18n.tr("Transparency of the widget outline")
value: (selectedBarConfig?.widgetOutlineOpacity ?? 1.0) * 100 value: (selectedBarConfig?.widgetOutlineOpacity ?? 1.0) * 100
minimum: 0 minimum: 0
maximum: 100 maximum: 100
@@ -1388,6 +1415,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
id: widgetOutlineThicknessSlider id: widgetOutlineThicknessSlider
text: I18n.tr("Thickness") text: I18n.tr("Thickness")
description: I18n.tr("Width of the widget outline in pixels")
value: selectedBarConfig?.widgetOutlineThickness ?? 1 value: selectedBarConfig?.widgetOutlineThickness ?? 1
minimum: 1 minimum: 1
maximum: 10 maximum: 10
@@ -1458,6 +1486,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
visible: shadowCard.shadowActive visible: shadowCard.shadowActive
text: I18n.tr("Intensity", "shadow intensity slider") text: I18n.tr("Intensity", "shadow intensity slider")
description: I18n.tr("Shadow blur radius in pixels")
minimum: 0 minimum: 0
maximum: 100 maximum: 100
unit: "px" unit: "px"
@@ -1471,6 +1500,7 @@ Item {
SettingsSliderRow { SettingsSliderRow {
visible: shadowCard.shadowActive visible: shadowCard.shadowActive
text: I18n.tr("Opacity") text: I18n.tr("Opacity")
description: I18n.tr("Transparency of the shadow layer")
minimum: 10 minimum: 10
maximum: 100 maximum: 100
unit: "%" unit: "%"
@@ -1658,6 +1688,7 @@ Item {
SettingsButtonGroupRow { SettingsButtonGroupRow {
text: I18n.tr("Y Axis") text: I18n.tr("Y Axis")
description: I18n.tr("Action performed when scrolling vertically on the bar")
model: CompositorService.isNiri ? [I18n.tr("None"), I18n.tr("Workspace"), I18n.tr("Column")] : [I18n.tr("None"), I18n.tr("Workspace")] model: CompositorService.isNiri ? [I18n.tr("None"), I18n.tr("Workspace"), I18n.tr("Column")] : [I18n.tr("None"), I18n.tr("Workspace")]
currentIndex: { currentIndex: {
switch (selectedBarConfig?.scrollYBehavior || "workspace") { switch (selectedBarConfig?.scrollYBehavior || "workspace") {
@@ -1694,6 +1725,7 @@ Item {
SettingsButtonGroupRow { SettingsButtonGroupRow {
text: I18n.tr("X Axis") text: I18n.tr("X Axis")
description: I18n.tr("Action performed when scrolling horizontally on the bar")
visible: CompositorService.isNiri visible: CompositorService.isNiri
model: [I18n.tr("None"), I18n.tr("Workspace"), I18n.tr("Column")] model: [I18n.tr("None"), I18n.tr("Workspace"), I18n.tr("Column")]
currentIndex: { currentIndex: {
@@ -1121,6 +1121,15 @@ Item {
onToggled: checked => SessionData.setSearchAppActions(checked) onToggled: checked => SessionData.setSearchAppActions(checked)
} }
SettingsToggleRow {
settingKey: "rememberLastMode"
tags: ["launcher", "remember", "last", "mode", "tab"]
text: I18n.tr("Remember Last Mode")
description: I18n.tr("Restore the last selected mode (tab) when the launcher is opened")
checked: SettingsData.rememberLastMode
onToggled: checked => SettingsData.set("rememberLastMode", checked)
}
SettingsToggleRow { SettingsToggleRow {
settingKey: "rememberLastQuery" settingKey: "rememberLastQuery"
tags: ["launcher", "remember", "last", "search", "query"] tags: ["launcher", "remember", "last", "search", "query"]
@@ -273,6 +273,17 @@ Item {
onToggled: checked => SettingsData.set("notificationCompactMode", checked) onToggled: checked => SettingsData.set("notificationCompactMode", checked)
} }
SettingsToggleRow {
settingKey: "notificationDedupeEnabled"
tags: ["notification", "duplicate", "dedupe", "stack", "coalesce", "repeat"]
text: I18n.tr("Suppress Duplicate Notifications")
description: SettingsData.notificationDedupeEnabled
? I18n.tr("Identical alerts show as one popup instead of stacking")
: I18n.tr("Identical alerts stack as separate notification cards")
checked: SettingsData.notificationDedupeEnabled
onToggled: checked => SettingsData.set("notificationDedupeEnabled", checked)
}
SettingsToggleRow { SettingsToggleRow {
settingKey: "notificationPopupShadowEnabled" settingKey: "notificationPopupShadowEnabled"
tags: ["notification", "popup", "shadow", "radius", "rounded"] tags: ["notification", "popup", "shadow", "radius", "rounded"]
@@ -455,6 +455,11 @@ Item {
label: I18n.tr("Show Restart DMS"), label: I18n.tr("Show Restart DMS"),
desc: I18n.tr("Restart the DankMaterialShell") desc: I18n.tr("Restart the DankMaterialShell")
}, },
{
key: "switchuser",
label: I18n.tr("Show Switch User"),
desc: I18n.tr("Opens a picker of other active sessions on this seat")
},
{ {
key: "hibernate", key: "hibernate",
label: I18n.tr("Show Hibernate"), label: I18n.tr("Show Hibernate"),
+414
View File
@@ -0,0 +1,414 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
property string statusText: ""
property bool statusIsError: false
property bool operationPending: false
property string pendingUsername: ""
property string pendingPassword: ""
property string pendingConfirm: ""
property bool pendingAdmin: false
function _resetForm() {
pendingUsername = "";
pendingPassword = "";
pendingConfirm = "";
pendingAdmin = false;
usernameField.text = "";
passwordField.text = "";
confirmField.text = "";
}
function _passwordsMatch() {
return pendingPassword.length > 0 && pendingPassword === pendingConfirm;
}
function _createCanProceed() {
return !operationPending && UsersService.isValidUsername(pendingUsername) && !UsersService.userExists(pendingUsername) && _passwordsMatch();
}
Connections {
target: UsersService
function onOperationCompleted(op, username, success, message) {
root.operationPending = false;
root.statusIsError = !success;
if (success) {
root.statusText = message + (username ? (" — " + username) : "");
if (op === "create")
root._resetForm();
} else {
root.statusText = (username ? (username + ": ") : "") + message;
}
}
}
ConfirmModal {
id: deleteUserConfirm
}
ConfirmModal {
id: adminToggleConfirm
}
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: mainColumn.height + Theme.spacingXL
contentWidth: width
Column {
id: mainColumn
topPadding: 4
width: Math.min(600, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
StyledText {
width: parent.width
visible: !PolkitService.polkitAvailable
text: I18n.tr("Polkit integration is disabled. User management requires Polkit to elevate privileges.")
font.pixelSize: Theme.fontSizeMedium
color: Theme.error
wrapMode: Text.WordWrap
}
SettingsCard {
width: parent.width
iconName: "group"
title: I18n.tr("Existing Users")
settingKey: "usersList"
visible: PolkitService.polkitAvailable
Row {
width: parent.width
spacing: Theme.spacingS
StyledText {
text: I18n.tr("Administrator group:")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: UsersService.adminGroup
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Item {
width: Theme.spacingM
height: 1
}
StyledText {
text: UsersService.refreshing ? I18n.tr("Refreshing…") : ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
}
Repeater {
model: UsersService.users
Rectangle {
id: userRow
required property var modelData
width: parent.width
height: Math.max(48, rowContent.implicitHeight + Theme.spacingS * 2)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
readonly property bool isLastAdmin: modelData.isAdmin && UsersService.adminMembers.length <= 1
Row {
id: rowContent
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: "account_circle"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Theme.iconSize - actionButtons.width - Theme.spacingM * 3
spacing: 2
anchors.verticalCenter: parent.verticalCenter
Row {
spacing: Theme.spacingS
StyledText {
text: userRow.modelData.username
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Rectangle {
visible: userRow.modelData.isAdmin
width: adminChipText.implicitWidth + Theme.spacingS * 2
height: adminChipText.implicitHeight + Theme.spacingXS * 2
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.primary, 0.15)
anchors.verticalCenter: parent.verticalCenter
StyledText {
id: adminChipText
anchors.centerIn: parent
text: I18n.tr("admin")
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
font.weight: Font.Medium
}
}
}
StyledText {
text: userRow.modelData.gecos && userRow.modelData.gecos.length > 0 ? userRow.modelData.gecos + " · UID " + userRow.modelData.uid : "UID " + userRow.modelData.uid
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
}
}
Row {
id: actionButtons
spacing: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
DankActionButton {
id: adminToggleBtn
readonly property bool actionBlocked: root.operationPending || (userRow.isLastAdmin && userRow.modelData.isAdmin)
buttonSize: 36
iconSize: 20
iconName: userRow.modelData.isAdmin ? "shield_person" : "shield"
iconColor: userRow.modelData.isAdmin ? Theme.primary : Theme.surfaceVariantText
opacity: actionBlocked ? 0.4 : 1.0
tooltipText: (userRow.isLastAdmin && userRow.modelData.isAdmin) ? I18n.tr("Cannot remove the only administrator") : (userRow.modelData.isAdmin ? I18n.tr("Remove admin") : I18n.tr("Make admin"))
tooltipSide: "left"
onClicked: {
if (actionBlocked)
return;
const makeAdmin = !userRow.modelData.isAdmin;
adminToggleConfirm.showWithOptions({
title: makeAdmin ? I18n.tr("Grant admin?") : I18n.tr("Remove admin?"),
message: makeAdmin ? I18n.tr("Add \"%1\" to the %2 group?").arg(userRow.modelData.username).arg(UsersService.adminGroup) : I18n.tr("Remove \"%1\" from the %2 group?").arg(userRow.modelData.username).arg(UsersService.adminGroup),
confirmText: makeAdmin ? I18n.tr("Grant") : I18n.tr("Remove"),
confirmColor: Theme.primary,
onConfirm: () => {
root.operationPending = true;
root.statusText = "";
UsersService.setAdmin(userRow.modelData.username, makeAdmin, null);
}
});
}
}
DankActionButton {
id: deleteBtn
readonly property bool actionBlocked: root.operationPending || !UsersService.canDelete(userRow.modelData.username)
buttonSize: 36
iconSize: 20
iconName: "delete"
iconColor: Theme.error
opacity: actionBlocked ? 0.4 : 1.0
tooltipText: userRow.isLastAdmin ? I18n.tr("Cannot delete the only administrator") : I18n.tr("Delete user")
tooltipSide: "left"
onClicked: {
if (actionBlocked)
return;
deleteUserConfirm.showWithOptions({
title: I18n.tr("Delete user?"),
message: I18n.tr("Delete \"%1\" and remove the home directory? This cannot be undone.").arg(userRow.modelData.username),
confirmText: I18n.tr("Delete"),
confirmColor: Theme.primary,
onConfirm: () => {
root.operationPending = true;
root.statusText = "";
UsersService.deleteUser(userRow.modelData.username, null);
}
});
}
}
}
}
}
}
StyledText {
width: parent.width
visible: UsersService.users.length === 0 && !UsersService.refreshing
text: I18n.tr("No human user accounts found.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
SettingsCard {
width: parent.width
iconName: "person_add"
title: I18n.tr("Create User")
settingKey: "createUser"
visible: PolkitService.polkitAvailable
Column {
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Username")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: usernameField
width: parent.width
placeholderText: I18n.tr("e.g. alice")
backgroundColor: Theme.surfaceContainerHighest
normalBorderColor: usernameInvalid ? Theme.error : Theme.outlineMedium
focusedBorderColor: usernameInvalid ? Theme.error : Theme.primary
readonly property bool usernameInvalid: text.length > 0 && (!UsersService.isValidUsername(text) || UsersService.userExists(text))
onTextEdited: {
root.pendingUsername = text.trim();
}
}
StyledText {
width: parent.width
visible: usernameField.text.length > 0 && !UsersService.isValidUsername(usernameField.text)
text: I18n.tr("Username must start with a lowercase letter or underscore and contain only lowercase letters, digits, hyphens, or underscores.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
wrapMode: Text.WordWrap
}
StyledText {
width: parent.width
visible: usernameField.text.length > 0 && UsersService.isValidUsername(usernameField.text) && UsersService.userExists(usernameField.text)
text: I18n.tr("A user with that name already exists.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
wrapMode: Text.WordWrap
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Password")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: passwordField
width: parent.width
placeholderText: I18n.tr("Set initial password")
echoMode: TextInput.Password
showPasswordToggle: true
backgroundColor: Theme.surfaceContainerHighest
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
onTextEdited: root.pendingPassword = text
}
}
Column {
width: parent.width
spacing: Theme.spacingXS
StyledText {
text: I18n.tr("Confirm password")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DankTextField {
id: confirmField
width: parent.width
placeholderText: I18n.tr("Re-enter password")
echoMode: TextInput.Password
showPasswordToggle: true
backgroundColor: Theme.surfaceContainerHighest
normalBorderColor: confirmMismatch ? Theme.error : Theme.outlineMedium
focusedBorderColor: confirmMismatch ? Theme.error : Theme.primary
readonly property bool confirmMismatch: text.length > 0 && text !== passwordField.text
onTextEdited: root.pendingConfirm = text
}
StyledText {
width: parent.width
visible: confirmField.text.length > 0 && confirmField.text !== passwordField.text
text: I18n.tr("Passwords do not match.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
}
}
SettingsToggleRow {
settingKey: "createUserAdmin"
tags: ["user", "admin", "sudo", "wheel"]
text: I18n.tr("Grant administrator privileges")
description: I18n.tr("Add the new user to the %1 group so they can use sudo.").arg(UsersService.adminGroup)
checked: root.pendingAdmin
onToggled: checked => root.pendingAdmin = checked
}
Row {
width: parent.width
spacing: Theme.spacingM
DankButton {
text: root.operationPending ? I18n.tr("Working…") : I18n.tr("Create User")
iconName: "person_add"
backgroundColor: Theme.primary
textColor: Theme.primaryText
enabled: root._createCanProceed()
onClicked: {
if (!root._createCanProceed())
return;
root.operationPending = true;
root.statusText = "";
UsersService.createUser(root.pendingUsername, root.pendingPassword, root.pendingAdmin, null);
}
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: root.statusText
color: root.statusIsError ? Theme.error : Theme.primary
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
width: parent.width - parent.children[0].width - Theme.spacingM
}
}
}
}
}
}
@@ -338,45 +338,61 @@ Scope {
border.width: 1 border.width: 1
} }
LauncherContent { FocusScope {
id: launcherContent
anchors.fill: parent anchors.fill: parent
anchors.margins: 0 focus: true
property var fakeParentModal: QtObject { Keys.onPressed: event => launcherContent.activeContextMenu?.handleKey(event)
property bool spotlightOpen: spotlightContainer.visible
property bool isClosing: niriOverviewScope.isClosing Keys.onEscapePressed: event => {
function hide() { launcherContent.activeContextMenu?.handleKey(event);
if (niriOverviewScope.searchActive) { if (!event.accepted)
niriOverviewScope.hideSpotlight(); launcherContent.parentModal?.hide();
return; event.accepted = true;
}
LauncherContent {
id: launcherContent
anchors.fill: parent
anchors.margins: 0
property var fakeParentModal: QtObject {
property bool spotlightOpen: spotlightContainer.visible
property bool isClosing: niriOverviewScope.isClosing
property real alignedX: spotlightContainer.x
property real alignedY: spotlightContainer.y
function hide() {
if (niriOverviewScope.searchActive) {
niriOverviewScope.hideSpotlight();
return;
}
NiriService.toggleOverview();
} }
NiriService.toggleOverview();
} }
}
Connections { Connections {
target: launcherContent.searchField target: launcherContent.searchField
function onTextChanged() { function onTextChanged() {
if (launcherContent.searchField.text.length > 0 || !niriOverviewScope.searchActive) if (launcherContent.searchField.text.length > 0 || !niriOverviewScope.searchActive)
return; return;
niriOverviewScope.hideSpotlight(); niriOverviewScope.hideSpotlight();
}
} }
}
Component.onCompleted: { Component.onCompleted: {
parentModal = fakeParentModal; parentModal = fakeParentModal;
}
Connections {
target: launcherContent.controller
function onItemExecuted() {
niriOverviewScope.releaseKeyboard = true;
} }
function onModeChanged(mode) {
if (launcherContent.controller.autoSwitchedToFiles) Connections {
return; target: launcherContent.controller
SessionData.setNiriOverviewLastMode(mode); function onItemExecuted() {
niriOverviewScope.releaseKeyboard = true;
}
function onModeChanged(mode) {
if (launcherContent.controller.autoSwitchedToFiles)
return;
SessionData.setNiriOverviewLastMode(mode);
}
} }
} }
} }
+39 -4
View File
@@ -397,6 +397,14 @@ EOFCONFIG
} }
} }
Connections {
target: root.source?.audio ?? null
function onMutedChanged() {
root.micMuteChanged();
}
}
function checkGsettings() { function checkGsettings() {
Proc.runCommand("checkGsettings", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null"], (output, exitCode) => { Proc.runCommand("checkGsettings", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null"], (output, exitCode) => {
gsettingsAvailable = (exitCode === 0); gsettingsAvailable = (exitCode === 0);
@@ -844,6 +852,36 @@ EOFCONFIG
return root.source.audio.muted ? "Microphone muted" : "Microphone unmuted"; return root.source.audio.muted ? "Microphone muted" : "Microphone unmuted";
} }
function incrementMicVolume(step) {
if (!root.source?.audio)
return "No audio source available";
if (root.source.audio.muted)
root.source.audio.muted = false;
const currentVolume = Math.round(root.source.audio.volume * 100);
const stepValue = parseInt(step || "5");
const newVolume = Math.max(0, Math.min(100, currentVolume + stepValue));
root.source.audio.volume = newVolume / 100;
return `Microphone volume increased to ${newVolume}%`;
}
function decrementMicVolume(step) {
if (!root.source?.audio)
return "No audio source available";
if (root.source.audio.muted)
root.source.audio.muted = false;
const currentVolume = Math.round(root.source.audio.volume * 100);
const stepValue = parseInt(step || "5");
const newVolume = Math.max(0, Math.min(100, currentVolume - stepValue));
root.source.audio.volume = newVolume / 100;
return `Microphone volume decreased to ${newVolume}%`;
}
IpcHandler { IpcHandler {
target: "audio" target: "audio"
@@ -892,9 +930,7 @@ EOFCONFIG
} }
function micmute(): string { function micmute(): string {
const result = root.toggleMicMute(); return root.toggleMicMute();
root.micMuteChanged();
return result;
} }
function status(): string { function status(): string {
@@ -957,7 +993,6 @@ EOFCONFIG
return `Switched to: ${result}`; return `Switched to: ${result}`;
} }
} }
Connections { Connections {
target: SettingsData target: SettingsData
function onUseSystemSoundThemeChanged() { function onUseSystemSoundThemeChanged() {
+14
View File
@@ -28,6 +28,20 @@ Singleton {
}); });
return isConnected; return isConnected;
} }
readonly property bool connecting: {
if (!adapter || !adapter.devices) {
return false;
}
let busy = false;
adapter.devices.values.forEach(dev => {
if (!dev)
return;
if (dev.pairing || dev.state === BluetoothDeviceState.Connecting)
busy = true;
});
return busy;
}
readonly property var pairedDevices: { readonly property var pairedDevices: {
if (!adapter || !adapter.devices) { if (!adapter || !adapter.devices) {
return []; return [];
+11
View File
@@ -240,6 +240,17 @@ Singleton {
}); });
} }
function pasteClipboard(closeCallback) {
if (!wtypeAvailable) {
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
return;
}
if (closeCallback) {
closeCallback();
}
pasteTimer.start();
}
function pasteEntry(entry, closeCallback) { function pasteEntry(entry, closeCallback) {
if (!wtypeAvailable) { if (!wtypeAvailable) {
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support")); ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
@@ -41,6 +41,9 @@ Singleton {
property var savedConnections: [] property var savedConnections: []
property var ssidToConnectionName: ({}) property var ssidToConnectionName: ({})
property var wifiSignalIcon: { property var wifiSignalIcon: {
if (isConnecting) {
return "wifi";
}
if (!wifiConnected) { if (!wifiConnected) {
return "wifi_off"; return "wifi_off";
} }
@@ -99,6 +99,9 @@ Singleton {
} }
readonly property string wifiSignalIcon: { readonly property string wifiSignalIcon: {
if (isConnecting) {
return "wifi";
}
if (!wifiConnected || networkStatus !== "wifi") { if (!wifiConnected || networkStatus !== "wifi") {
return "wifi_off"; return "wifi_off";
} }
+1
View File
@@ -42,6 +42,7 @@ Singleton {
property string userPreference: activeService?.userPreference ?? "auto" property string userPreference: activeService?.userPreference ?? "auto"
property bool isConnecting: activeService?.isConnecting ?? false property bool isConnecting: activeService?.isConnecting ?? false
readonly property bool isWifiConnecting: isConnecting && !ethernetConnected && !wifiToggling
property string connectingSSID: activeService?.connectingSSID ?? "" property string connectingSSID: activeService?.connectingSSID ?? ""
property string connectionError: activeService?.connectionError ?? "" property string connectionError: activeService?.connectionError ?? ""
+66 -21
View File
@@ -35,6 +35,8 @@ Singleton {
property int maxIngressPerSecond: 20 property int maxIngressPerSecond: 20
property double _lastIngressSec: 0 property double _lastIngressSec: 0
property int _ingressCountThisSec: 0 property int _ingressCountThisSec: 0
readonly property int notificationDedupBurstMs: 5000
property var _recentDedupKeys: []
property var _dismissQueue: [] property var _dismissQueue: []
property int _dismissBatchSize: 8 property int _dismissBatchSize: 8
@@ -291,18 +293,58 @@ Singleton {
return Date.now() / 1000.0; return Date.now() / 1000.0;
} }
function _normalizeDedupText(text) {
if (!text)
return "";
let normalized = text.toString();
normalized = normalized.replace(/<img\b[^>]*>/gi, "");
normalized = normalized.replace(/<[^>]+>/g, "");
normalized = normalized.replace(/\s+/g, " ").trim();
return normalized.toLowerCase();
}
function _dedupAppId(source) {
if (!source)
return "";
const desktopEntry = (source.desktopEntry || "").toString().trim().toLowerCase();
if (desktopEntry)
return desktopEntry;
return (source.appName || "").toString().trim().toLowerCase();
}
function _notificationDedupKey(source) { function _notificationDedupKey(source) {
if (!source) if (!source)
return ""; return "";
const app = (source.appName || source.desktopEntry || "").toString(); const app = _dedupAppId(source);
const summary = (source.summary || "").toString(); const summary = _normalizeDedupText(source.summary);
const body = (source.body || "").toString(); const body = _normalizeDedupText(source.body);
const urgency = typeof source.urgency === "number" ? source.urgency : NotificationUrgency.Normal; const urgency = typeof source.urgency === "number" ? source.urgency : NotificationUrgency.Normal;
const icon = (source.appIcon || "").toString();
if (!app && !summary && !body) if (!app && !summary && !body)
return ""; return "";
const sep = ""; const sep = "";
return app + sep + summary + sep + body + sep + urgency + sep + icon; return app + sep + summary + sep + body + sep + urgency;
}
function _pruneRecentDedupKeys() {
const cutoff = Date.now() - notificationDedupBurstMs;
_recentDedupKeys = _recentDedupKeys.filter(entry => entry && entry.atMs >= cutoff);
}
function _hasRecentDuplicate(key) {
if (!key)
return false;
_pruneRecentDedupKeys();
return _recentDedupKeys.some(entry => entry && entry.key === key);
}
function _recordDedupKey(key) {
if (!key)
return;
_pruneRecentDedupKeys();
_recentDedupKeys.push({
"key": key,
"atMs": Date.now()
});
} }
function _findActiveDuplicate(notif) { function _findActiveDuplicate(notif) {
@@ -310,17 +352,14 @@ Singleton {
if (!key) if (!key)
return null; return null;
for (const w of visibleNotifications) { for (const w of allWrappers) {
if (!w || !w.notification || !w.popup) if (!w || !w.notification || !w.popup)
continue; continue;
if (_notificationDedupKey(w.notification) === key) if (_notificationDedupKey(w.notification) !== key)
return w;
}
for (const w of notificationQueue) {
if (!w || !w.notification)
continue; continue;
if (_notificationDedupKey(w.notification) === key) if (visibleNotifications.indexOf(w) !== -1 || notificationQueue.indexOf(w) !== -1)
return w;
if (w.timer && w.timer.running)
return w; return w;
} }
@@ -637,14 +676,17 @@ Singleton {
return; return;
} }
const duplicate = _findActiveDuplicate(notif); if (SettingsData.notificationDedupeEnabled) {
if (duplicate) { const dedupKey = _notificationDedupKey(notif);
if (duplicate.timer && duplicate.timer.running) const duplicate = _findActiveDuplicate(notif);
duplicate.timer.restart(); if (duplicate || _hasRecentDuplicate(dedupKey)) {
try { if (duplicate && duplicate.timer && duplicate.timer.running)
notif.dismiss(); duplicate.timer.restart();
} catch (e) {} try {
return; notif.dismiss();
} catch (e) {}
return;
}
} }
if (!_ingressAllowed(policy.urgency)) { if (!_ingressAllowed(policy.urgency)) {
@@ -686,6 +728,9 @@ Singleton {
}); });
if (wrapper) { if (wrapper) {
if (SettingsData.notificationDedupeEnabled)
_recordDedupKey(_notificationDedupKey(notif));
root.allWrappers.push(wrapper); root.allWrappers.push(wrapper);
if (shouldKeepInCenter) { if (shouldKeepInCenter) {
root.notifications.push(wrapper); root.notifications.push(wrapper);
+236
View File
@@ -0,0 +1,236 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
readonly property var log: Log.scoped("SessionsService")
property var sessions: []
property string currentSessionId: ""
property string currentSeat: ""
property bool refreshing: false
signal switchFailed(string sessionId, string username, string message)
signal switchRequested
function isCurrent(sessionId) {
return sessionId === currentSessionId;
}
function findByUsername(username) {
for (let i = 0; i < sessions.length; i++) {
const s = sessions[i];
if (s.username === username && !s.current)
return s;
}
return null;
}
function findById(sessionId) {
for (let i = 0; i < sessions.length; i++) {
if (sessions[i].sessionId === sessionId)
return sessions[i];
}
return null;
}
function otherSessions() {
return sessions.filter(s => !s.current);
}
function refresh() {
if (refreshing)
return;
refreshing = true;
Proc.runCommand("sessionsService-current", ["sh", "-c", "echo \"${XDG_SESSION_ID}:$(loginctl show-session \"${XDG_SESSION_ID}\" -p Seat --value 2>/dev/null)\""], (output, exitCode) => {
const trimmed = (output || "").trim();
const parts = trimmed.split(":");
root.currentSessionId = parts[0] || "";
root.currentSeat = parts[1] || "";
_loadSessions();
}, 0);
}
function _loadSessions() {
const script = "loginctl list-sessions --no-legend 2>/dev/null | awk '{print $1}' | while read id; do loginctl show-session \"$id\" -p Id -p User -p Name -p Seat -p TTY -p Type -p Class -p Active -p State -p Remote 2>/dev/null | tr '\\n' '|'; echo; done";
Proc.runCommand("sessionsService-list", ["sh", "-c", script], (output, exitCode) => {
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
const list = [];
for (let i = 0; i < lines.length; i++) {
const fields = {};
const pairs = lines[i].split("|");
for (let j = 0; j < pairs.length; j++) {
const eq = pairs[j].indexOf("=");
if (eq <= 0)
continue;
fields[pairs[j].substring(0, eq)] = pairs[j].substring(eq + 1);
}
if (!fields.Id)
continue;
if (fields.Class !== "user")
continue;
if (fields.State === "closing")
continue;
const sessionId = fields.Id;
list.push({
sessionId: sessionId,
uid: parseInt(fields.User || "0", 10),
username: fields.Name || "",
seat: fields.Seat || "",
tty: fields.TTY || "",
type: fields.Type || "",
sessionClass: fields.Class || "",
active: fields.Active === "yes",
state: fields.State || "",
remote: fields.Remote === "yes",
current: sessionId === root.currentSessionId
});
}
list.sort((a, b) => {
if (a.current !== b.current)
return a.current ? -1 : 1;
if (a.username !== b.username)
return a.username.localeCompare(b.username);
return parseInt(a.sessionId, 10) - parseInt(b.sessionId, 10);
});
root.sessions = list;
root.refreshing = false;
}, 0);
}
function activate(sessionId, callback) {
if (!sessionId) {
_fail("", "", I18n.tr("No session selected"), callback);
return;
}
if (sessionId === root.currentSessionId) {
_fail(sessionId, "", I18n.tr("Already on that session"), callback);
return;
}
const session = findById(sessionId);
const username = session ? session.username : "";
_spawnActivate(sessionId, username, callback);
}
function switchToUser(target, callback) {
if (!target) {
_fail("", "", I18n.tr("No user specified"), callback);
return;
}
let session = findById(target);
if (!session)
session = findByUsername(target);
if (!session) {
_fail("", target, I18n.tr("No active session found for %1").arg(target), callback);
return;
}
if (session.current) {
_fail(session.sessionId, session.username, I18n.tr("Already on that session"), callback);
return;
}
_spawnActivate(session.sessionId, session.username, callback);
}
function _fail(sessionId, username, message, callback) {
log.warn("switch failed:", sessionId, username, message);
root.switchFailed(sessionId, username, message);
if (typeof callback === "function") {
try {
callback(false, message);
} catch (e) {
log.warn("SessionsService callback error:", e);
}
}
}
Component {
id: activateComp
Process {
id: activateProc
property string targetSession: ""
property string targetUsername: ""
property var cb: null
property string capturedErr: ""
running: false
stdout: StdioCollector {}
stderr: StdioCollector {
onStreamFinished: activateProc.capturedErr = text || ""
}
onExited: exitCode => {
const svc = root;
const sessionId = activateProc.targetSession;
const username = activateProc.targetUsername;
const cb = activateProc.cb;
const err = (activateProc.capturedErr || "").trim();
Qt.callLater(() => activateProc.destroy());
if (exitCode !== 0) {
svc._fail(sessionId, username, err || I18n.tr("loginctl activate failed (exit %1)").arg(exitCode), cb);
return;
}
if (typeof cb === "function") {
try {
cb(true, "");
} catch (e) {
svc.log.warn("activate cb error:", e);
}
}
}
}
}
function _spawnActivate(sessionId, username, callback) {
const proc = activateComp.createObject(root, {
command: ["loginctl", "activate", sessionId],
targetSession: sessionId,
targetUsername: username,
cb: callback
});
proc.running = true;
}
IpcHandler {
target: "sessions"
function list(): string {
const lines = [];
for (let i = 0; i < root.sessions.length; i++) {
const s = root.sessions[i];
lines.push([s.sessionId, s.username, s.seat || "-", s.tty || "-", s.type || "-", s.current ? "*current*" : ""].join("\t"));
}
return lines.join("\n");
}
function refresh(): string {
root.refresh();
return "ok";
}
function open(): string {
root.refresh();
root.switchRequested();
return "ok";
}
function activate(sessionId: string): string {
if (!sessionId)
return "ERROR: missing session id";
root.activate(sessionId, null);
return "ok";
}
function switchTo(target: string): string {
if (!target)
return "ERROR: missing target (username or session id)";
root.switchToUser(target, null);
return "ok";
}
}
Component.onCompleted: refresh()
}
+365
View File
@@ -0,0 +1,365 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
readonly property var log: Log.scoped("UsersService")
property var users: []
property string adminGroup: "wheel"
property var adminMembers: []
property bool refreshing: false
signal operationCompleted(string op, string username, bool success, string message)
readonly property var _usernameRegex: /^[a-z_][a-z0-9_-]{0,30}\$?$/
function isValidUsername(name) {
if (typeof name !== "string")
return false;
return _usernameRegex.test(name);
}
function userExists(name) {
for (let i = 0; i < users.length; i++) {
if (users[i].username === name)
return true;
}
return false;
}
function _findUser(name) {
for (let i = 0; i < users.length; i++) {
if (users[i].username === name)
return users[i];
}
return null;
}
function canDelete(name) {
const u = _findUser(name);
if (!u)
return false;
if (u.isAdmin && adminMembers.length <= 1)
return false;
return true;
}
function refresh() {
if (refreshing)
return;
refreshing = true;
_detectAdminGroup();
}
function _detectAdminGroup() {
Proc.runCommand("usersService-detectGroup", ["sh", "-c", "getent group wheel >/dev/null && echo wheel || (getent group sudo >/dev/null && echo sudo || echo wheel)"], (output, exitCode) => {
const detected = (output || "").trim() || "wheel";
root.adminGroup = detected;
_loadAdminMembers();
}, 0);
}
function _loadAdminMembers() {
Proc.runCommand("usersService-adminMembers", ["sh", "-c", "getent group " + root.adminGroup + " | awk -F: '{print $4}'"], (output, exitCode) => {
const members = (output || "").trim().split(",").map(s => s.trim()).filter(s => s.length > 0);
root.adminMembers = members;
_loadUsers();
}, 0);
}
function _loadUsers() {
Proc.runCommand("usersService-loadUsers", ["sh", "-c", "getent passwd | awk -F: '$3>=1000 && $3<60000 && $1!=\"nobody\" {print $1\":\"$3\":\"$5\":\"$6\":\"$7}'"], (output, exitCode) => {
const lines = (output || "").trim().split("\n").filter(l => l.length > 0);
const list = [];
const adminSet = {};
for (let i = 0; i < root.adminMembers.length; i++)
adminSet[root.adminMembers[i]] = true;
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split(":");
if (parts.length < 5)
continue;
const username = parts[0];
list.push({
username,
uid: parseInt(parts[1], 10),
gecos: (parts[2] || "").split(",")[0],
home: parts[3] || "",
shell: parts[4] || "",
isAdmin: adminSet[username] === true
});
}
list.sort((a, b) => a.username.localeCompare(b.username));
root.users = list;
root.refreshing = false;
}, 0);
}
function createUser(username, password, addToAdmin, callback) {
if (!isValidUsername(username)) {
_emit("create", username, false, I18n.tr("Invalid username"), callback);
return;
}
if (!password || password.length < 1) {
_emit("create", username, false, I18n.tr("Password cannot be empty"), callback);
return;
}
if (userExists(username)) {
_emit("create", username, false, I18n.tr("User already exists"), callback);
return;
}
_runUseradd(username, password, addToAdmin === true, callback);
}
function setPassword(username, newPassword, callback) {
if (!isValidUsername(username) || !userExists(username)) {
_emit("passwd", username, false, I18n.tr("User not found"), callback);
return;
}
if (!newPassword || newPassword.length < 1) {
_emit("passwd", username, false, I18n.tr("Password cannot be empty"), callback);
return;
}
_runChpasswd(username, newPassword, "passwd", callback);
}
function deleteUser(username, callback) {
if (!userExists(username)) {
_emit("delete", username, false, I18n.tr("User not found"), callback);
return;
}
if (!canDelete(username)) {
_emit("delete", username, false, I18n.tr("Cannot delete the only administrator"), callback);
return;
}
_runUserdel(username, callback);
}
function setAdmin(username, makeAdmin, callback) {
if (!userExists(username)) {
_emit("admin", username, false, I18n.tr("User not found"), callback);
return;
}
if (!makeAdmin) {
const u = _findUser(username);
if (u && u.isAdmin && root.adminMembers.length <= 1) {
_emit("admin", username, false, I18n.tr("Cannot remove the only administrator"), callback);
return;
}
}
_runAdminToggle(username, makeAdmin === true, callback);
}
function _emit(op, username, success, message, callback) {
root.operationCompleted(op, username, success, message);
if (typeof callback === "function") {
try {
callback(success, message);
} catch (e) {
log.warn("UsersService callback error:", e);
}
}
}
Component {
id: useraddComp
Process {
id: useraddProc
property string targetUser: ""
property string targetPassword: ""
property bool addAdmin: false
property var cb: null
property string capturedErr: ""
running: false
stdout: StdioCollector {}
stderr: StdioCollector {
onStreamFinished: useraddProc.capturedErr = text || ""
}
onExited: exitCode => {
const svc = root;
if (exitCode !== 0) {
svc._emit("create", useraddProc.targetUser, false, (useraddProc.capturedErr || "").trim() || I18n.tr("useradd failed (exit %1)").arg(exitCode), useraddProc.cb);
Qt.callLater(() => useraddProc.destroy());
return;
}
const targetUser = useraddProc.targetUser;
const targetPassword = useraddProc.targetPassword;
const addAdmin = useraddProc.addAdmin;
const outerCb = useraddProc.cb;
Qt.callLater(() => useraddProc.destroy());
svc._runChpasswd(targetUser, targetPassword, "create", (pwOk, pwMsg) => {
if (!pwOk) {
svc._emit("create", targetUser, false, pwMsg, outerCb);
return;
}
if (addAdmin) {
svc._runAdminToggle(targetUser, true, (adminOk, adminMsg) => {
if (adminOk) {
svc._emit("create", targetUser, true, I18n.tr("User created with administrator privileges"), outerCb);
} else {
svc._emit("create", targetUser, false, adminMsg, outerCb);
}
});
} else {
svc._emit("create", targetUser, true, I18n.tr("User created"), outerCb);
}
});
}
}
}
Component {
id: chpasswdComp
Process {
id: chpasswdProc
property string targetUser: ""
property string targetPassword: ""
property string op: "passwd"
property var cb: null
property string capturedErr: ""
command: ["pkexec", "sh", "-c", "head -n1 | chpasswd"]
stdinEnabled: true
running: false
stdout: StdioCollector {}
stderr: StdioCollector {
onStreamFinished: chpasswdProc.capturedErr = text || ""
}
onStarted: {
chpasswdProc.write(chpasswdProc.targetUser + ":" + chpasswdProc.targetPassword + "\n");
}
onExited: exitCode => {
const op = chpasswdProc.op;
const targetUser = chpasswdProc.targetUser;
const cb = chpasswdProc.cb;
const err = (chpasswdProc.capturedErr || "").trim();
Qt.callLater(() => chpasswdProc.destroy());
if (exitCode !== 0) {
const msg = err || I18n.tr("Password change failed (exit %1)").arg(exitCode);
if (op === "create") {
if (typeof cb === "function")
cb(false, msg);
} else {
root._emit("passwd", targetUser, false, msg, cb);
}
} else {
root.refresh();
if (op === "create") {
if (typeof cb === "function")
cb(true, I18n.tr("Password set"));
} else {
root._emit("passwd", targetUser, true, I18n.tr("Password updated"), cb);
}
}
}
}
}
Component {
id: userdelComp
Process {
id: userdelProc
property string targetUser: ""
property var cb: null
property string capturedErr: ""
running: false
stdout: StdioCollector {}
stderr: StdioCollector {
onStreamFinished: userdelProc.capturedErr = text || ""
}
onExited: exitCode => {
const targetUser = userdelProc.targetUser;
const cb = userdelProc.cb;
const err = (userdelProc.capturedErr || "").trim();
Qt.callLater(() => userdelProc.destroy());
if (exitCode !== 0) {
root._emit("delete", targetUser, false, err || I18n.tr("userdel failed (exit %1)").arg(exitCode), cb);
} else {
root.refresh();
root._emit("delete", targetUser, true, I18n.tr("User deleted"), cb);
}
}
}
}
Component {
id: adminToggleComp
Process {
id: adminToggleProc
property string targetUser: ""
property bool makeAdmin: false
property var cb: null
property string capturedErr: ""
running: false
stdout: StdioCollector {}
stderr: StdioCollector {
onStreamFinished: adminToggleProc.capturedErr = text || ""
}
onExited: exitCode => {
const targetUser = adminToggleProc.targetUser;
const makeAdmin = adminToggleProc.makeAdmin;
const cb = adminToggleProc.cb;
const err = (adminToggleProc.capturedErr || "").trim();
Qt.callLater(() => adminToggleProc.destroy());
if (exitCode !== 0) {
root._emit("admin", targetUser, false, err || I18n.tr("usermod failed (exit %1)").arg(exitCode), cb);
} else {
root.refresh();
root._emit("admin", targetUser, true, makeAdmin ? I18n.tr("Granted administrator privileges") : I18n.tr("Removed administrator privileges"), cb);
}
}
}
}
function _runUseradd(username, password, addToAdmin, callback) {
const proc = useraddComp.createObject(root, {
command: ["pkexec", "useradd", "-m", "-s", "/bin/bash", username],
targetUser: username,
targetPassword: password,
addAdmin: addToAdmin,
cb: callback
});
proc.running = true;
}
function _runChpasswd(username, password, op, callback) {
const proc = chpasswdComp.createObject(root, {
targetUser: username,
targetPassword: password,
op: op,
cb: callback
});
proc.running = true;
}
function _runUserdel(username, callback) {
const proc = userdelComp.createObject(root, {
command: ["pkexec", "userdel", "-r", username],
targetUser: username,
cb: callback
});
proc.running = true;
}
function _runAdminToggle(username, makeAdmin, callback) {
const cmd = makeAdmin ? ["pkexec", "usermod", "-aG", root.adminGroup, username] : ["pkexec", "gpasswd", "-d", username, root.adminGroup];
const proc = adminToggleComp.createObject(root, {
command: cmd,
targetUser: username,
makeAdmin: makeAdmin,
cb: callback
});
proc.running = true;
}
Component.onCompleted: refresh()
}
+9
View File
@@ -345,4 +345,13 @@ Singleton {
return 0; return 0;
} }
} }
Connections {
target: SessionService
function onSessionResumed() {
log.info("Session resumed, re-requesting output state, current outputs:", outputs.length);
requestState();
}
}
} }
+28
View File
@@ -0,0 +1,28 @@
import QtQuick
SequentialAnimation {
id: root
property Item target
property real minOpacity: 0.3
property int pulseDuration: 600
loops: Animation.Infinite
NumberAnimation {
target: root.target
property: "opacity"
to: root.minOpacity
duration: root.pulseDuration
easing.type: Easing.InOutQuad
}
NumberAnimation {
target: root.target
property: "opacity"
to: 1.0
duration: root.pulseDuration
easing.type: Easing.InOutQuad
}
onStopped: if (root.target) root.target.opacity = 1.0
}
@@ -5807,6 +5807,28 @@
], ],
"description": "Use smaller notification cards" "description": "Use smaller notification cards"
}, },
{
"section": "notificationDedupeEnabled",
"label": "Suppress Duplicate Notifications",
"tabIndex": 17,
"category": "Notifications",
"keywords": [
"alert",
"alerts",
"coalesce",
"dedupe",
"duplicate",
"duplicates",
"messages",
"notif",
"notification",
"notifications",
"repeat",
"stack",
"toast"
],
"description": "Control whether identical alerts stack or show as a single popup"
},
{ {
"section": "notificationHistorySaveCritical", "section": "notificationHistorySaveCritical",
"label": "Critical Priority", "label": "Critical Priority",