mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-18 00:55:20 -04:00
Compare commits
36 Commits
3701b3d7a3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 085ce01da6 | |||
| 7af530de8f | |||
| 9a4cff4e49 | |||
| 480ffa4ac2 | |||
| d5ac0c9aa0 | |||
| 29f19b07a9 | |||
| 39301c534c | |||
| 58b9e4bda7 | |||
| 820a9ce983 | |||
| 68410e882d | |||
| f26c0af39a | |||
| 0ca451483f | |||
| 2849dd0ba2 | |||
| df41ae4acb | |||
| 2692777707 | |||
| ca1a45ccf8 | |||
| 2f39f248fc | |||
| 90f8ce5035 | |||
| cb29125580 | |||
| 988b54515e | |||
| 2fd9de5062 | |||
| fd5aabcb17 | |||
| 85b63219b9 | |||
| ddf943846f | |||
| e7221ec623 | |||
| 78daaf0cb4 | |||
| a6ab3bab4c | |||
| 53cea7023f | |||
| a098088f03 | |||
| 59998e9fd2 | |||
| 1df7e478df | |||
| 1fc4890857 | |||
| f5d52f1506 | |||
| 2026ba5bd2 | |||
| db56c8d74d | |||
| 9d1a81c93c |
@@ -19,7 +19,12 @@ var (
|
||||
var colorCmd = &cobra.Command{
|
||||
Use: "color",
|
||||
Short: "Color utilities",
|
||||
Long: "Color utilities including picking colors from the screen",
|
||||
Long: `Color utilities including picking colors from the screen.
|
||||
|
||||
This is the screen eyedropper CLI. To open the in-shell color modal, use:
|
||||
dms ipc call color-picker toggle
|
||||
|
||||
See: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
|
||||
}
|
||||
|
||||
var colorPickCmd = &cobra.Command{
|
||||
@@ -29,6 +34,9 @@ var colorPickCmd = &cobra.Command{
|
||||
|
||||
Click on any pixel to capture its color, or press Escape to cancel.
|
||||
|
||||
This is the screen eyedropper CLI. To open the in-shell color modal, use:
|
||||
dms ipc call color-picker toggle
|
||||
|
||||
Output format flags (mutually exclusive, default: --hex):
|
||||
--hex - Hexadecimal (#RRGGBB)
|
||||
--rgb - RGB values (R G B)
|
||||
|
||||
@@ -77,10 +77,15 @@ var killCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var ipcCmd = &cobra.Command{
|
||||
Use: "ipc [target] [function] [args...]",
|
||||
Use: "ipc",
|
||||
Short: "Send IPC commands to running DMS shell",
|
||||
Long: `Send IPC commands to the running DMS shell.
|
||||
|
||||
dms ipc call <target> <function> [args...] invoke a command
|
||||
dms ipc list list all targets and functions
|
||||
|
||||
Full reference: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc`,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
_ = findConfig(cmd, args)
|
||||
return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
@@ -88,9 +93,17 @@ var ipcCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var ipcListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all IPC targets and functions",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
printIPCHelp()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
ipcCmd.AddCommand(ipcListCmd)
|
||||
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
_ = findConfig(cmd, args)
|
||||
printIPCHelp()
|
||||
})
|
||||
}
|
||||
|
||||
+44
-27
@@ -601,12 +601,30 @@ func parseTargetsFromIPCShowOutput(output string) ipcTargets {
|
||||
return targets
|
||||
}
|
||||
|
||||
func getShellIPCCompletions(args []string, _ string) []string {
|
||||
func buildQsIPCBaseArgs() ([]string, error) {
|
||||
cmdArgs := []string{"ipc"}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
switch pid, ok := getFirstDMSPID(); {
|
||||
case ok:
|
||||
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
||||
default:
|
||||
if err := findConfig(nil, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", configPath)
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", configPath, "show")
|
||||
return cmdArgs, nil
|
||||
}
|
||||
|
||||
func getShellIPCCompletions(args []string, _ string) []string {
|
||||
baseArgs, err := buildQsIPCBaseArgs()
|
||||
if err != nil {
|
||||
log.Debugf("Error building IPC args for completions: %v", err)
|
||||
return nil
|
||||
}
|
||||
cmdArgs := append(baseArgs, "show")
|
||||
cmd := exec.Command("qs", cmdArgs...)
|
||||
var targets ipcTargets
|
||||
|
||||
@@ -623,7 +641,7 @@ func getShellIPCCompletions(args []string, _ string) []string {
|
||||
|
||||
if len(args) == 0 {
|
||||
targetNames := make([]string, 0)
|
||||
targetNames = append(targetNames, "call")
|
||||
targetNames = append(targetNames, "call", "list")
|
||||
for k := range targets {
|
||||
targetNames = append(targetNames, k)
|
||||
}
|
||||
@@ -696,23 +714,11 @@ func runShellIPCCommand(args []string) {
|
||||
args = append([]string{"call"}, args...)
|
||||
}
|
||||
|
||||
cmdArgs := []string{"ipc"}
|
||||
|
||||
switch pid, ok := getFirstDMSPID(); {
|
||||
case ok:
|
||||
cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid))
|
||||
default:
|
||||
if err := findConfig(nil, nil); err != nil {
|
||||
log.Fatalf("Error finding config: %v", err)
|
||||
}
|
||||
// ! TODO - remove check when QS 0.3 is released
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", configPath)
|
||||
baseArgs, err := buildQsIPCBaseArgs()
|
||||
if err != nil {
|
||||
log.Fatalf("Error finding config: %v", err)
|
||||
}
|
||||
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
cmdArgs := append(baseArgs, args...)
|
||||
cmd := exec.Command("qs", cmdArgs...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -724,19 +730,20 @@ func runShellIPCCommand(args []string) {
|
||||
}
|
||||
|
||||
func printIPCHelp() {
|
||||
fmt.Println("Usage: dms ipc <target> <function> [args...]")
|
||||
fmt.Println("Usage: dms ipc call <target> <function> [args...]")
|
||||
fmt.Println()
|
||||
|
||||
cmdArgs := []string{"ipc"}
|
||||
if qsHasAnyDisplay() {
|
||||
cmdArgs = append(cmdArgs, "--any-display")
|
||||
baseArgs, err := buildQsIPCBaseArgs()
|
||||
if err != nil {
|
||||
printIPCHelpFailure(err)
|
||||
return
|
||||
}
|
||||
cmdArgs = append(cmdArgs, "-p", configPath, "show")
|
||||
cmdArgs := append(baseArgs, "show")
|
||||
cmd := exec.Command("qs", cmdArgs...)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
fmt.Println("Could not retrieve available IPC targets (is DMS running?)")
|
||||
printIPCHelpFailure(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -765,6 +772,16 @@ func printIPCHelp() {
|
||||
}
|
||||
}
|
||||
|
||||
func printIPCHelpFailure(err error) {
|
||||
fmt.Println("Could not retrieve IPC targets.")
|
||||
if err != nil {
|
||||
fmt.Printf(" %v\n", err)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" Full docs: https://danklinux.com/docs/dankmaterialshell/keybinds-ipc")
|
||||
fmt.Println(" Try: dms ipc call <target> <function>")
|
||||
}
|
||||
|
||||
// ensureFontCache rebuilds the fontconfig cache if user-configured fonts are missing while skipping defaults
|
||||
func ensureFontCache() {
|
||||
if _, err := exec.LookPath("fc-list"); err != nil {
|
||||
|
||||
@@ -51,7 +51,7 @@ type NiriParser struct {
|
||||
}
|
||||
|
||||
func parseKDL(data []byte) (*document.Document, error) {
|
||||
return kdl.Parse(strings.NewReader(normalizeKDLBraces(string(data))))
|
||||
return kdl.Parse(strings.NewReader(normalizeKDLBraces(quoteLeadingUnderscoreIdents(string(data)))))
|
||||
}
|
||||
|
||||
func normalizeKDLBraces(input string) string {
|
||||
@@ -94,6 +94,93 @@ func normalizeKDLBraces(input string) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// quoteLeadingUnderscoreIdents wraps bare KDL identifiers that begin with '_'
|
||||
// in double quotes. kdl-go rejects '_' as the first character of a bare
|
||||
// identifier (e.g. the common `_JAVA_AWT_WM_NONREPARENTING "1"` environment
|
||||
// node), even though niri's own parser and the KDL spec accept it — so without
|
||||
// this the whole config fails to parse and no keybinds load. Quoting lets
|
||||
// kdl-go parse it; this is safe because the niri parser only dispatches on
|
||||
// fixed node/section names (binds, recent-windows, include, ...) that never
|
||||
// start with '_', so re-quoting such a name cannot change what DMS reads.
|
||||
// Underscores elsewhere in an identifier (XDG_CURRENT_DESKTOP) are left
|
||||
// untouched, and underscores inside strings or comments are skipped. Only a
|
||||
// leading '_' is handled; other start characters kdl-go over-rejects (e.g. '.'
|
||||
// or '?') do not occur in niri configs.
|
||||
func quoteLeadingUnderscoreIdents(input string) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(input))
|
||||
|
||||
var prev byte
|
||||
n := len(input)
|
||||
for i := 0; i < n; {
|
||||
c := input[i]
|
||||
|
||||
switch {
|
||||
case c == '"':
|
||||
end := findStringEnd(input, i)
|
||||
sb.WriteString(input[i:end])
|
||||
prev = '"'
|
||||
i = end
|
||||
case c == '/' && i+1 < n && input[i+1] == '/':
|
||||
end := findLineCommentEnd(input, i)
|
||||
sb.WriteString(input[i:end])
|
||||
prev = '\n'
|
||||
i = end
|
||||
case c == '/' && i+1 < n && input[i+1] == '*':
|
||||
end := findBlockCommentEnd(input, i)
|
||||
sb.WriteString(input[i:end])
|
||||
prev = ' '
|
||||
i = end
|
||||
case c == '/' && i+1 < n && input[i+1] == '-':
|
||||
// KDL slashdash: /- comments out the next node/value. Keep the
|
||||
// marker but treat what follows as a fresh token start, so a
|
||||
// slashdashed leading-underscore node (e.g. `/-_FOO "1"`) still
|
||||
// gets quoted instead of crashing kdl-go.
|
||||
sb.WriteByte('/')
|
||||
sb.WriteByte('-')
|
||||
prev = ' '
|
||||
i += 2
|
||||
case c == '_' && isIdentBoundary(prev):
|
||||
end := scanBareIdent(input, i)
|
||||
sb.WriteByte('"')
|
||||
sb.WriteString(input[i:end])
|
||||
sb.WriteByte('"')
|
||||
prev = '"'
|
||||
i = end
|
||||
default:
|
||||
sb.WriteByte(c)
|
||||
prev = c
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// isIdentBoundary reports whether the previously emitted byte ends a token, so
|
||||
// that a following '_' starts a fresh bare identifier rather than sitting in
|
||||
// the middle of one.
|
||||
func isIdentBoundary(prev byte) bool {
|
||||
switch prev {
|
||||
case 0, ' ', '\t', '\n', '\r', '{', '}', ';', '=', '(', ')', ',':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scanBareIdent returns the index just past the bare identifier starting at
|
||||
// start, stopping at whitespace or any KDL delimiter.
|
||||
func scanBareIdent(s string, start int) int {
|
||||
n := len(s)
|
||||
for i := start; i < n; i++ {
|
||||
switch s[i] {
|
||||
case ' ', '\t', '\n', '\r', '"', '{', '}', '(', ')', ';', '=', ',', '/', '\\', '<', '>', '[', ']':
|
||||
return i
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func findStringEnd(s string, start int) int {
|
||||
n := len(s)
|
||||
for i := start + 1; i < n; {
|
||||
|
||||
@@ -71,6 +71,101 @@ func TestNormalizeKDLBraces(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteLeadingUnderscoreIdents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{"leading underscore node", `_JAVA_AWT_WM_NONREPARENTING "1"`, `"_JAVA_AWT_WM_NONREPARENTING" "1"`},
|
||||
{"mid underscore untouched", `XDG_CURRENT_DESKTOP "niri"`, `XDG_CURRENT_DESKTOP "niri"`},
|
||||
{"indented node", "environment {\n _FOO \"1\"\n}", "environment {\n \"_FOO\" \"1\"\n}"},
|
||||
{"underscore in string", `spawn "_not_a_node"`, `spawn "_not_a_node"`},
|
||||
{"underscore in line comment", "// _comment\n_FOO \"1\"", "// _comment\n\"_FOO\" \"1\""},
|
||||
{"underscore in block comment", "/* _x */ _FOO \"1\"", "/* _x */ \"_FOO\" \"1\""},
|
||||
{"block comment abuts node", `/* x */_FOO "1"`, `/* x */"_FOO" "1"`},
|
||||
{"slashdash before node", `/-_FOO "1"`, `/-"_FOO" "1"`},
|
||||
{"node after closing paren", "node (u8)_v", `node (u8)"_v"`},
|
||||
{"node before brace without space", "_FOO{ }", `"_FOO"{ }`},
|
||||
{"lone underscore", `_ "x"`, `"_" "x"`},
|
||||
{"property value", "node key=_val", `node key="_val"`},
|
||||
{"no underscores", "node child", "node child"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := quoteLeadingUnderscoreIdents(tc.in)
|
||||
if got != tc.out {
|
||||
t.Errorf("quoteLeadingUnderscoreIdents(%q) = %q, want %q", tc.in, got, tc.out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriParseLeadingUnderscoreEnvironment(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||
|
||||
// A leading-underscore environment node (a common Java/tiling-WM fix) must
|
||||
// not abort parsing of the rest of the config — keybinds still have to load.
|
||||
content := `environment {
|
||||
XDG_CURRENT_DESKTOP "niri"
|
||||
_JAVA_AWT_WM_NONREPARENTING "1"
|
||||
}
|
||||
binds {
|
||||
Mod+Q { close-window; }
|
||||
Mod+KP_Home { focus-workspace 1; }
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
result, err := ParseNiriKeys(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNiriKeys failed on config with leading-underscore env node: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Section.Keybinds) != 2 {
|
||||
t.Errorf("Expected 2 keybinds, got %d", len(result.Section.Keybinds))
|
||||
}
|
||||
|
||||
foundClose := false
|
||||
for _, kb := range result.Section.Keybinds {
|
||||
if kb.Action == "close-window" {
|
||||
foundClose = true
|
||||
}
|
||||
}
|
||||
if !foundClose {
|
||||
t.Error("close-window keybind not found — leading-underscore env node broke parsing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriParseSlashdashLeadingUnderscore(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "config.kdl")
|
||||
|
||||
// A slashdashed leading-underscore node must not abort parsing either.
|
||||
content := `environment {
|
||||
/-_JAVA_AWT_WM_NONREPARENTING "1"
|
||||
}
|
||||
binds {
|
||||
Mod+Q { close-window; }
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
result, err := ParseNiriKeys(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNiriKeys failed on config with slashdashed leading-underscore node: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Section.Keybinds) != 1 {
|
||||
t.Errorf("Expected 1 keybind, got %d", len(result.Section.Keybinds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNiriParseKeyCombo(t *testing.T) {
|
||||
tests := []struct {
|
||||
combo string
|
||||
|
||||
@@ -125,6 +125,8 @@ State updates are sent whenever network configuration changes:
|
||||
- `wifiConnected`: Whether associated with an access point
|
||||
- `wifiSSID`: Currently connected network name
|
||||
- `wifiIP`: Assigned IP address (empty until DHCP completes)
|
||||
- `savedWifiNetworks` (API v26+): Saved WiFi profiles exposed at SSID granularity. If a backend has multiple profiles for the same SSID, DMS merges them into one SSID-level entry. Clients talking to older servers should derive saved visible networks from `wifiNetworks` entries where `saved` is true.
|
||||
- `savedWifiNetworks[].outOfRange` (API v26+): Whether the saved profile is not currently visible in scan results. Fallback entries derived from `wifiNetworks` should be treated as visible (`outOfRange: false`).
|
||||
- `lastError`: Error message from last failed connection attempt
|
||||
|
||||
### network.credentials Service Events
|
||||
|
||||
@@ -67,6 +67,7 @@ type BackendState struct {
|
||||
WiFiBSSID string
|
||||
WiFiSignal uint8
|
||||
WiFiNetworks []WiFiNetwork
|
||||
SavedWiFiNetworks []WiFiNetwork
|
||||
WiFiDevices []WiFiDevice
|
||||
WiredConnections []WiredConnection
|
||||
VPNProfiles []VPNProfile
|
||||
|
||||
@@ -27,6 +27,19 @@ func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) {
|
||||
wifi.state.WiFiBSSID = "00:11:22:33:44:55"
|
||||
wifi.state.WiFiSignal = 75
|
||||
wifi.state.WiFiDevice = "wlan0"
|
||||
wifi.state.SavedWiFiNetworks = []WiFiNetwork{
|
||||
{
|
||||
SSID: "TestNetwork",
|
||||
Saved: true,
|
||||
Autoconnect: true,
|
||||
Connected: true,
|
||||
},
|
||||
{
|
||||
SSID: "AwayNetwork",
|
||||
Saved: true,
|
||||
OutOfRange: true,
|
||||
},
|
||||
}
|
||||
|
||||
l3.state.WiFiIP = "192.168.1.100"
|
||||
l3.state.EthernetConnected = false
|
||||
@@ -42,6 +55,9 @@ func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) {
|
||||
assert.True(t, state.WiFiConnected)
|
||||
assert.False(t, state.EthernetConnected)
|
||||
assert.Equal(t, StatusWiFi, state.NetworkStatus)
|
||||
assert.Len(t, state.SavedWiFiNetworks, 2)
|
||||
assert.Equal(t, "TestNetwork", state.SavedWiFiNetworks[0].SSID)
|
||||
assert.True(t, state.SavedWiFiNetworks[1].OutOfRange)
|
||||
}
|
||||
|
||||
func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) {
|
||||
|
||||
@@ -80,6 +80,10 @@ func (b *IWDBackend) Initialize() error {
|
||||
return fmt.Errorf("failed to discover iwd devices: %w", err)
|
||||
}
|
||||
|
||||
if err := b.updateSavedWiFiNetworks(); err != nil {
|
||||
log.Warnf("Failed to get initial saved WiFi networks: %v", err)
|
||||
}
|
||||
|
||||
if err := b.updateState(); err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("failed to get initial state: %w", err)
|
||||
@@ -145,6 +149,7 @@ func (b *IWDBackend) GetCurrentState() (*BackendState, error) {
|
||||
|
||||
state := *b.state
|
||||
state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
|
||||
state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...)
|
||||
state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
|
||||
state.WiFiDevices = b.getWiFiDevicesLocked()
|
||||
|
||||
|
||||
@@ -45,12 +45,42 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(dbusPropertiesInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
dbus.WithMatchArg(0, iwdKnownNetworkInterface),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to add known network signal match: %w", err)
|
||||
}
|
||||
|
||||
if err := b.conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(dbusObjectManager),
|
||||
dbus.WithMatchMember("InterfacesAdded"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to add iwd interfaces-added signal match: %w", err)
|
||||
}
|
||||
|
||||
if err := b.conn.AddMatchSignal(
|
||||
dbus.WithMatchInterface(dbusObjectManager),
|
||||
dbus.WithMatchMember("InterfacesRemoved"),
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to add iwd interfaces-removed signal match: %w", err)
|
||||
}
|
||||
|
||||
b.sigWG.Add(1)
|
||||
go b.signalHandler(sigChan)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) refreshWiFiNetworkState() bool {
|
||||
_, err := b.updateWiFiNetworks()
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return b.updateSavedWiFiNetworks() == nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
defer b.sigWG.Done()
|
||||
|
||||
@@ -66,11 +96,36 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
return
|
||||
}
|
||||
|
||||
if sig.Name != dbusPropertiesInterface+".PropertiesChanged" {
|
||||
if sig.Name == dbusObjectManager+".InterfacesAdded" {
|
||||
if len(sig.Body) >= 2 {
|
||||
if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok {
|
||||
if _, ok := interfaces[iwdKnownNetworkInterface]; ok {
|
||||
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(sig.Body) < 2 {
|
||||
if sig.Name == dbusObjectManager+".InterfacesRemoved" {
|
||||
if len(sig.Body) >= 2 {
|
||||
if interfaces, ok := sig.Body[1].([]string); ok {
|
||||
for _, iface := range interfaces {
|
||||
if iface == iwdKnownNetworkInterface {
|
||||
if b.refreshWiFiNetworkState() && b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if sig.Name != dbusPropertiesInterface+".PropertiesChanged" || len(sig.Body) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -87,6 +142,9 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
stateChanged := false
|
||||
|
||||
switch iface {
|
||||
case iwdKnownNetworkInterface:
|
||||
stateChanged = b.refreshWiFiNetworkState()
|
||||
|
||||
case iwdDeviceInterface:
|
||||
if sig.Path == b.devicePath {
|
||||
if poweredVar, ok := changed["Powered"]; ok {
|
||||
@@ -105,13 +163,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
if sig.Path == b.stationPath {
|
||||
if scanningVar, ok := changed["Scanning"]; ok {
|
||||
if scanning, ok := scanningVar.Value().(bool); ok && !scanning {
|
||||
networks, err := b.updateWiFiNetworks()
|
||||
if err == nil {
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiNetworks = networks
|
||||
b.stateMutex.Unlock()
|
||||
stateChanged = true
|
||||
}
|
||||
stateChanged = b.refreshWiFiNetworkState() || stateChanged
|
||||
|
||||
b.stateMutex.RLock()
|
||||
wifiConnected := b.state.WiFiConnected
|
||||
@@ -236,6 +288,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
}
|
||||
}
|
||||
|
||||
b.refreshWiFiNetworkState()
|
||||
stateChanged = true
|
||||
|
||||
if att != nil && isTarget {
|
||||
@@ -282,6 +335,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
b.state.NetworkStatus = StatusDisconnected
|
||||
}
|
||||
b.stateMutex.Unlock()
|
||||
b.refreshWiFiNetworkState()
|
||||
stateChanged = true
|
||||
}
|
||||
}
|
||||
@@ -342,6 +396,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) {
|
||||
stateChanged = true
|
||||
}
|
||||
b.stateMutex.Unlock()
|
||||
b.refreshWiFiNetworkState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -168,6 +169,92 @@ func TestIWDBackend_MapIwdDBusError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) {
|
||||
objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{
|
||||
"/net/connman/iwd/known_network/1": {
|
||||
iwdKnownNetworkInterface: {
|
||||
"Name": dbus.MakeVariant("Home"),
|
||||
"AutoConnect": dbus.MakeVariant(false),
|
||||
"Hidden": dbus.MakeVariant(true),
|
||||
"Type": dbus.MakeVariant("psk"),
|
||||
},
|
||||
},
|
||||
"/net/connman/iwd/known_network/2": {
|
||||
iwdKnownNetworkInterface: {
|
||||
"Name": dbus.MakeVariant("Office"),
|
||||
"Type": dbus.MakeVariant("8021x"),
|
||||
},
|
||||
},
|
||||
"/net/connman/iwd/known_network/3": {
|
||||
iwdKnownNetworkInterface: {
|
||||
"Name": dbus.MakeVariant("Cafe"),
|
||||
"Type": dbus.MakeVariant("open"),
|
||||
},
|
||||
},
|
||||
"/net/connman/iwd/network/1": {
|
||||
iwdNetworkInterface: {
|
||||
"Name": dbus.MakeVariant("VisibleOnly"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
profiles := iwdSavedWiFiProfilesFromManagedObjects(objects)
|
||||
|
||||
assert.Len(t, profiles, 3)
|
||||
assert.False(t, profiles["Home"].Autoconnect)
|
||||
assert.True(t, profiles["Home"].Hidden)
|
||||
assert.True(t, profiles["Home"].Secured)
|
||||
assert.False(t, profiles["Home"].Enterprise)
|
||||
|
||||
assert.True(t, profiles["Office"].Autoconnect)
|
||||
assert.True(t, profiles["Office"].Secured)
|
||||
assert.True(t, profiles["Office"].Enterprise)
|
||||
|
||||
assert.True(t, profiles["Cafe"].Autoconnect)
|
||||
assert.False(t, profiles["Cafe"].Secured)
|
||||
assert.False(t, profiles["Cafe"].Enterprise)
|
||||
}
|
||||
|
||||
func TestIWDWiFiNetworksFromVisibleIncludesConnectedHiddenFallback(t *testing.T) {
|
||||
profiles := map[string]savedWiFiProfile{
|
||||
"Home": {
|
||||
Autoconnect: true,
|
||||
Secured: true,
|
||||
Hidden: true,
|
||||
Mode: "infrastructure",
|
||||
},
|
||||
}
|
||||
visible := []WiFiNetwork{
|
||||
{
|
||||
SSID: "Cafe",
|
||||
Signal: 42,
|
||||
Secured: false,
|
||||
},
|
||||
}
|
||||
|
||||
networks := iwdWiFiNetworksFromVisible(visible, profiles, "Home", true, 68)
|
||||
savedNetworks := savedWiFiNetworksFromProfiles(profiles, map[string]WiFiNetwork{
|
||||
networks[0].SSID: networks[0],
|
||||
networks[1].SSID: networks[1],
|
||||
}, "Home", true)
|
||||
|
||||
assert.Len(t, networks, 2)
|
||||
assert.Equal(t, "Cafe", networks[0].SSID)
|
||||
assert.False(t, networks[0].Connected)
|
||||
|
||||
assert.Equal(t, "Home", networks[1].SSID)
|
||||
assert.True(t, networks[1].Connected)
|
||||
assert.True(t, networks[1].Hidden)
|
||||
assert.True(t, networks[1].Saved)
|
||||
assert.True(t, networks[1].Autoconnect)
|
||||
assert.Equal(t, uint8(68), networks[1].Signal)
|
||||
|
||||
assert.Len(t, savedNetworks, 1)
|
||||
assert.Equal(t, "Home", savedNetworks[0].SSID)
|
||||
assert.True(t, savedNetworks[0].Connected)
|
||||
assert.False(t, savedNetworks[0].OutOfRange)
|
||||
}
|
||||
|
||||
func TestConnectAttempt_Finalization(t *testing.T) {
|
||||
backend, _ := NewIWDBackend()
|
||||
backend.state = &BackendState{}
|
||||
|
||||
@@ -164,22 +164,18 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
return nil, fmt.Errorf("failed to get networks: %w", err)
|
||||
}
|
||||
|
||||
knownNetworks, err := b.getKnownNetworks()
|
||||
savedProfiles, err := b.getIWDSavedWiFiProfiles()
|
||||
if err != nil {
|
||||
knownNetworks = make(map[string]bool)
|
||||
}
|
||||
|
||||
autoconnectMap, err := b.getAutoconnectSettings()
|
||||
if err != nil {
|
||||
autoconnectMap = make(map[string]bool)
|
||||
savedProfiles = make(map[string]savedWiFiProfile)
|
||||
}
|
||||
|
||||
b.stateMutex.RLock()
|
||||
currentSSID := b.state.WiFiSSID
|
||||
wifiConnected := b.state.WiFiConnected
|
||||
wifiSignal := b.state.WiFiSignal
|
||||
b.stateMutex.RUnlock()
|
||||
|
||||
networks := make([]WiFiNetwork, 0, len(orderedNetworks))
|
||||
visibleNetworks := make([]WiFiNetwork, 0, len(orderedNetworks))
|
||||
for _, netData := range orderedNetworks {
|
||||
if len(netData) < 2 {
|
||||
continue
|
||||
@@ -225,23 +221,26 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
|
||||
secured := netType != "open"
|
||||
|
||||
network := WiFiNetwork{
|
||||
SSID: name,
|
||||
Signal: signal,
|
||||
Secured: secured,
|
||||
Connected: wifiConnected && name == currentSSID,
|
||||
Saved: knownNetworks[name],
|
||||
Autoconnect: autoconnectMap[name],
|
||||
Enterprise: netType == "8021x",
|
||||
}
|
||||
|
||||
networks = append(networks, network)
|
||||
visibleNetworks = append(visibleNetworks, WiFiNetwork{
|
||||
SSID: name,
|
||||
Signal: signal,
|
||||
Secured: secured,
|
||||
Enterprise: netType == "8021x",
|
||||
})
|
||||
}
|
||||
|
||||
networks := iwdWiFiNetworksFromVisible(visibleNetworks, savedProfiles, currentSSID, wifiConnected, wifiSignal)
|
||||
visibleNetworkMap := make(map[string]WiFiNetwork, len(networks))
|
||||
for _, network := range networks {
|
||||
visibleNetworkMap[network.SSID] = network
|
||||
}
|
||||
savedNetworks := savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworkMap, currentSSID, wifiConnected)
|
||||
|
||||
sortWiFiNetworks(networks)
|
||||
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiNetworks = networks
|
||||
b.state.SavedWiFiNetworks = savedNetworks
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
@@ -254,30 +253,129 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) getKnownNetworks() (map[string]bool, error) {
|
||||
obj := b.conn.Object(iwdBusName, iwdObjectPath)
|
||||
|
||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||
err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects)
|
||||
func (b *IWDBackend) updateSavedWiFiNetworks() error {
|
||||
savedProfiles, err := b.getIWDSavedWiFiProfiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
known := make(map[string]bool)
|
||||
for _, interfaces := range objects {
|
||||
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
|
||||
if nameVar, ok := knownProps["Name"]; ok {
|
||||
if name, ok := nameVar.Value().(string); ok {
|
||||
known[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b.stateMutex.RLock()
|
||||
currentSSID := b.state.WiFiSSID
|
||||
wifiConnected := b.state.WiFiConnected
|
||||
wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
|
||||
b.stateMutex.RUnlock()
|
||||
|
||||
return known, nil
|
||||
wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, savedProfiles, currentSSID, wifiConnected)
|
||||
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiNetworks = wifiNetworks
|
||||
b.state.SavedWiFiNetworks = savedNetworks
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) {
|
||||
func iwdWiFiNetworksFromVisible(visibleNetworks []WiFiNetwork, savedProfiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool, wifiSignal uint8) []WiFiNetwork {
|
||||
networks := make([]WiFiNetwork, 0, len(visibleNetworks)+1)
|
||||
seenSSIDs := make(map[string]struct{}, len(visibleNetworks)+1)
|
||||
|
||||
for _, network := range visibleNetworks {
|
||||
profile, saved := savedProfiles[network.SSID]
|
||||
network.Connected = wifiConnected && network.SSID == currentSSID
|
||||
network.Saved = saved
|
||||
network.Autoconnect = profile.Autoconnect
|
||||
network.Hidden = network.Hidden || profile.Hidden
|
||||
network.Secured = network.Secured || profile.Secured
|
||||
network.Enterprise = network.Enterprise || profile.Enterprise
|
||||
if network.Mode == "" {
|
||||
network.Mode = profile.Mode
|
||||
}
|
||||
networks = append(networks, network)
|
||||
seenSSIDs[network.SSID] = struct{}{}
|
||||
}
|
||||
|
||||
if wifiConnected && currentSSID != "" {
|
||||
if _, exists := seenSSIDs[currentSSID]; !exists {
|
||||
profile, saved := savedProfiles[currentSSID]
|
||||
secured := profile.Secured
|
||||
if !saved {
|
||||
secured = true
|
||||
}
|
||||
mode := profile.Mode
|
||||
if mode == "" {
|
||||
mode = "infrastructure"
|
||||
}
|
||||
|
||||
networks = append(networks, WiFiNetwork{
|
||||
SSID: currentSSID,
|
||||
Signal: wifiSignal,
|
||||
Secured: secured,
|
||||
Enterprise: profile.Enterprise,
|
||||
Connected: true,
|
||||
Saved: saved,
|
||||
Autoconnect: profile.Autoconnect,
|
||||
Hidden: true,
|
||||
Mode: mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return networks
|
||||
}
|
||||
|
||||
func iwdSavedWiFiProfilesFromManagedObjects(objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant) map[string]savedWiFiProfile {
|
||||
profiles := make(map[string]savedWiFiProfile)
|
||||
|
||||
for _, interfaces := range objects {
|
||||
knownProps, ok := interfaces[iwdKnownNetworkInterface]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
nameVar, ok := knownProps["Name"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, ok := nameVar.Value().(string)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
profile := savedWiFiProfile{
|
||||
Autoconnect: true,
|
||||
Mode: "infrastructure",
|
||||
}
|
||||
if acVar, ok := knownProps["AutoConnect"]; ok {
|
||||
if autoconnect, ok := acVar.Value().(bool); ok {
|
||||
profile.Autoconnect = autoconnect
|
||||
}
|
||||
}
|
||||
if hiddenVar, ok := knownProps["Hidden"]; ok {
|
||||
if hidden, ok := hiddenVar.Value().(bool); ok {
|
||||
profile.Hidden = hidden
|
||||
}
|
||||
}
|
||||
if typeVar, ok := knownProps["Type"]; ok {
|
||||
if networkType, ok := typeVar.Value().(string); ok {
|
||||
profile.Secured = networkType != "" && networkType != "open"
|
||||
profile.Enterprise = networkType == "8021x"
|
||||
}
|
||||
}
|
||||
|
||||
if existing, ok := profiles[name]; ok {
|
||||
profile.Autoconnect = profile.Autoconnect || existing.Autoconnect
|
||||
profile.Hidden = profile.Hidden || existing.Hidden
|
||||
profile.Secured = profile.Secured || existing.Secured
|
||||
profile.Enterprise = profile.Enterprise || existing.Enterprise
|
||||
}
|
||||
|
||||
profiles[name] = profile
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
|
||||
func (b *IWDBackend) getIWDSavedWiFiProfiles() (map[string]savedWiFiProfile, error) {
|
||||
obj := b.conn.Object(iwdBusName, iwdObjectPath)
|
||||
|
||||
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
|
||||
@@ -286,24 +384,7 @@ func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
autoconnectMap := make(map[string]bool)
|
||||
for _, interfaces := range objects {
|
||||
if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok {
|
||||
if nameVar, ok := knownProps["Name"]; ok {
|
||||
if name, ok := nameVar.Value().(string); ok {
|
||||
autoconnect := true
|
||||
if acVar, ok := knownProps["AutoConnect"]; ok {
|
||||
if ac, ok := acVar.Value().(bool); ok {
|
||||
autoconnect = ac
|
||||
}
|
||||
}
|
||||
autoconnectMap[name] = autoconnect
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return autoconnectMap, nil
|
||||
return iwdSavedWiFiProfilesFromManagedObjects(objects), nil
|
||||
}
|
||||
|
||||
func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) {
|
||||
@@ -614,6 +695,8 @@ func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error {
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
|
||||
_, _ = b.updateWiFiNetworks()
|
||||
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
|
||||
@@ -222,6 +222,10 @@ func (b *NetworkManagerBackend) Initialize() error {
|
||||
log.Warnf("Failed to update WiFi state: %v", err)
|
||||
}
|
||||
|
||||
if err := b.updateSavedWiFiNetworks(); err != nil {
|
||||
log.Warnf("Failed to get initial saved WiFi networks: %v", err)
|
||||
}
|
||||
|
||||
if wifiEnabled {
|
||||
if _, err := b.updateWiFiNetworks(); err != nil {
|
||||
log.Warnf("Failed to get initial networks: %v", err)
|
||||
@@ -261,6 +265,7 @@ func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) {
|
||||
|
||||
state := *b.state
|
||||
state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
|
||||
state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...)
|
||||
state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...)
|
||||
state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...)
|
||||
state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...)
|
||||
|
||||
@@ -5,6 +5,12 @@ import (
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
dbusNMSettingsPath = "/org/freedesktop/NetworkManager/Settings"
|
||||
dbusNMSettingsInterface = "org.freedesktop.NetworkManager.Settings"
|
||||
dbusNMSettingsConnectionInterface = "org.freedesktop.NetworkManager.Settings.Connection"
|
||||
)
|
||||
|
||||
func (b *NetworkManagerBackend) startSignalPump() error {
|
||||
conn, err := dbus.ConnectSystemBus()
|
||||
if err != nil {
|
||||
@@ -27,8 +33,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
||||
}
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
|
||||
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsInterface),
|
||||
dbus.WithMatchMember("NewConnection"),
|
||||
); err != nil {
|
||||
conn.RemoveMatchSignal(
|
||||
@@ -42,8 +48,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
||||
}
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
|
||||
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsInterface),
|
||||
dbus.WithMatchMember("ConnectionRemoved"),
|
||||
); err != nil {
|
||||
conn.RemoveMatchSignal(
|
||||
@@ -52,8 +58,8 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")),
|
||||
dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"),
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsInterface),
|
||||
dbus.WithMatchMember("NewConnection"),
|
||||
)
|
||||
conn.RemoveSignal(signals)
|
||||
@@ -61,6 +67,31 @@ func (b *NetworkManagerBackend) startSignalPump() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsConnectionInterface),
|
||||
dbus.WithMatchMember("Updated"),
|
||||
); err != nil {
|
||||
conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
|
||||
dbus.WithMatchInterface(dbusPropsInterface),
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsInterface),
|
||||
dbus.WithMatchMember("NewConnection"),
|
||||
)
|
||||
conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsInterface),
|
||||
dbus.WithMatchMember("ConnectionRemoved"),
|
||||
)
|
||||
conn.RemoveSignal(signals)
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
|
||||
dbus.WithMatchInterface(dbusNMInterface),
|
||||
@@ -137,6 +168,32 @@ func (b *NetworkManagerBackend) stopSignalPump() {
|
||||
dbus.WithMatchMember("PropertiesChanged"),
|
||||
)
|
||||
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsInterface),
|
||||
dbus.WithMatchMember("NewConnection"),
|
||||
)
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsInterface),
|
||||
dbus.WithMatchMember("ConnectionRemoved"),
|
||||
)
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMSettingsPath)),
|
||||
dbus.WithMatchInterface(dbusNMSettingsConnectionInterface),
|
||||
dbus.WithMatchMember("Updated"),
|
||||
)
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
|
||||
dbus.WithMatchInterface(dbusNMInterface),
|
||||
dbus.WithMatchMember("DeviceAdded"),
|
||||
)
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)),
|
||||
dbus.WithMatchInterface(dbusNMInterface),
|
||||
dbus.WithMatchMember("DeviceRemoved"),
|
||||
)
|
||||
|
||||
for _, info := range b.wifiDevices {
|
||||
b.dbusConn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())),
|
||||
@@ -164,9 +221,13 @@ func (b *NetworkManagerBackend) stopSignalPump() {
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) {
|
||||
if sig.Name == "org.freedesktop.NetworkManager.Settings.NewConnection" ||
|
||||
sig.Name == "org.freedesktop.NetworkManager.Settings.ConnectionRemoved" {
|
||||
if sig.Name == dbusNMSettingsInterface+".NewConnection" ||
|
||||
sig.Name == dbusNMSettingsInterface+".ConnectionRemoved" ||
|
||||
sig.Name == dbusNMSettingsConnectionInterface+".Updated" {
|
||||
b.ListVPNProfiles()
|
||||
if err := b.updateSavedWiFiNetworks(); err != nil {
|
||||
b.updateWiFiNetworks()
|
||||
}
|
||||
if b.onStateChange != nil {
|
||||
b.onStateChange()
|
||||
}
|
||||
|
||||
@@ -225,24 +225,14 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error
|
||||
return "", fmt.Errorf("failed to identify security type of network `%s`", ssid)
|
||||
}
|
||||
|
||||
var securityType string
|
||||
switch keyMgmt {
|
||||
case "none":
|
||||
authAlg, _ := secSettings["auth-alg"].(string)
|
||||
switch authAlg {
|
||||
case "open":
|
||||
securityType = "nopass"
|
||||
default:
|
||||
securityType = "WEP"
|
||||
}
|
||||
return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` is open or WEP", ssid)
|
||||
case "ieee8021x":
|
||||
securityType = "WEP"
|
||||
return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` is enterprise", ssid)
|
||||
case "wpa-psk", "sae", "wpa-psk-sae":
|
||||
default:
|
||||
securityType = "WPA"
|
||||
}
|
||||
|
||||
if securityType != "WPA" {
|
||||
return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType)
|
||||
return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` uses %s", ssid, keyMgmt)
|
||||
}
|
||||
|
||||
var psk string
|
||||
@@ -276,7 +266,7 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error
|
||||
return "", fmt.Errorf("failed to retrieve password for `%s`", ssid)
|
||||
}
|
||||
|
||||
return FormatWiFiQRString(securityType, ssid, psk), nil
|
||||
return FormatWiFiQRString("WPA", ssid, psk), nil
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error {
|
||||
@@ -405,6 +395,74 @@ func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSavedWiFiProfiles(connections []gonetworkmanager.Connection) map[string]savedWiFiProfile {
|
||||
profiles := make(map[string]savedWiFiProfile)
|
||||
|
||||
for _, conn := range connections {
|
||||
connSettings, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
connMeta, ok := connSettings["connection"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
connType, ok := connMeta["type"].(string)
|
||||
if !ok || connType != "802-11-wireless" {
|
||||
continue
|
||||
}
|
||||
|
||||
wifiSettings, ok := connSettings["802-11-wireless"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ssidBytes, ok := wifiSettings["ssid"].([]byte)
|
||||
if !ok || len(ssidBytes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
ssid := string(ssidBytes)
|
||||
profile := savedWiFiProfile{
|
||||
Autoconnect: true,
|
||||
Mode: "infrastructure",
|
||||
}
|
||||
|
||||
if ac, ok := connMeta["autoconnect"].(bool); ok {
|
||||
profile.Autoconnect = ac
|
||||
}
|
||||
if hidden, ok := wifiSettings["hidden"].(bool); ok {
|
||||
profile.Hidden = hidden
|
||||
}
|
||||
if mode, ok := wifiSettings["mode"].(string); ok && mode != "" {
|
||||
profile.Mode = mode
|
||||
}
|
||||
if _, ok := connSettings["802-11-wireless-security"]; ok {
|
||||
profile.Secured = true
|
||||
}
|
||||
if _, ok := connSettings["802-1x"]; ok {
|
||||
profile.Enterprise = true
|
||||
profile.Secured = true
|
||||
}
|
||||
|
||||
if existing, ok := profiles[ssid]; ok {
|
||||
profile.Autoconnect = profile.Autoconnect || existing.Autoconnect
|
||||
profile.Hidden = profile.Hidden || existing.Hidden
|
||||
profile.Secured = profile.Secured || existing.Secured
|
||||
profile.Enterprise = profile.Enterprise || existing.Enterprise
|
||||
if profile.Mode == "" {
|
||||
profile.Mode = existing.Mode
|
||||
}
|
||||
}
|
||||
|
||||
profiles[ssid] = profile
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool {
|
||||
b.stateMutex.RLock()
|
||||
defer b.stateMutex.RUnlock()
|
||||
@@ -442,47 +500,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
return nil, fmt.Errorf("failed to get connections: %w", err)
|
||||
}
|
||||
|
||||
savedSSIDs := make(map[string]bool)
|
||||
autoconnectMap := make(map[string]bool)
|
||||
hiddenSSIDs := make(map[string]bool)
|
||||
for _, conn := range connections {
|
||||
connSettings, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
connMeta, ok := connSettings["connection"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
connType, ok := connMeta["type"].(string)
|
||||
if !ok || connType != "802-11-wireless" {
|
||||
continue
|
||||
}
|
||||
|
||||
wifiSettings, ok := connSettings["802-11-wireless"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ssidBytes, ok := wifiSettings["ssid"].([]byte)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ssid := string(ssidBytes)
|
||||
savedSSIDs[ssid] = true
|
||||
autoconnect := true
|
||||
if ac, ok := connMeta["autoconnect"].(bool); ok {
|
||||
autoconnect = ac
|
||||
}
|
||||
autoconnectMap[ssid] = autoconnect
|
||||
|
||||
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
|
||||
hiddenSSIDs[ssid] = true
|
||||
}
|
||||
}
|
||||
savedProfiles := getSavedWiFiProfiles(connections)
|
||||
|
||||
b.stateMutex.RLock()
|
||||
currentSSID := b.state.WiFiSSID
|
||||
@@ -491,8 +509,8 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
wifiBSSID := b.state.WiFiBSSID
|
||||
b.stateMutex.RUnlock()
|
||||
|
||||
seenSSIDs := make(map[string]*WiFiNetwork)
|
||||
networks := []WiFiNetwork{}
|
||||
seenSSIDs := make(map[string]int)
|
||||
networks := make([]WiFiNetwork, 0, len(apPaths)+1)
|
||||
|
||||
for _, ap := range apPaths {
|
||||
ssid, err := ap.GetPropertySSID()
|
||||
@@ -500,7 +518,8 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
if existing, exists := seenSSIDs[ssid]; exists {
|
||||
if existingIndex, exists := seenSSIDs[ssid]; exists {
|
||||
existing := &networks[existingIndex]
|
||||
strength, _ := ap.GetPropertyStrength()
|
||||
if strength > existing.Signal {
|
||||
existing.Signal = strength
|
||||
@@ -550,6 +569,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
}
|
||||
}
|
||||
|
||||
profile, saved := savedProfiles[ssid]
|
||||
network := WiFiNetwork{
|
||||
SSID: ssid,
|
||||
BSSID: bssid,
|
||||
@@ -557,45 +577,86 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) {
|
||||
Secured: secured,
|
||||
Enterprise: enterprise,
|
||||
Connected: isConnected,
|
||||
Saved: savedSSIDs[ssid],
|
||||
Autoconnect: autoconnectMap[ssid],
|
||||
Hidden: hiddenSSIDs[ssid],
|
||||
Saved: saved,
|
||||
Autoconnect: profile.Autoconnect,
|
||||
Hidden: profile.Hidden,
|
||||
Frequency: freq,
|
||||
Mode: modeStr,
|
||||
Rate: rate,
|
||||
Channel: channel,
|
||||
}
|
||||
|
||||
seenSSIDs[ssid] = &network
|
||||
networks = append(networks, network)
|
||||
seenSSIDs[ssid] = len(networks) - 1
|
||||
}
|
||||
|
||||
if wifiConnected && currentSSID != "" {
|
||||
if _, exists := seenSSIDs[currentSSID]; !exists {
|
||||
profile, saved := savedProfiles[currentSSID]
|
||||
hiddenNetwork := WiFiNetwork{
|
||||
SSID: currentSSID,
|
||||
BSSID: wifiBSSID,
|
||||
Signal: wifiSignal,
|
||||
Secured: true,
|
||||
Connected: true,
|
||||
Saved: savedSSIDs[currentSSID],
|
||||
Autoconnect: autoconnectMap[currentSSID],
|
||||
Saved: saved,
|
||||
Autoconnect: profile.Autoconnect,
|
||||
Hidden: true,
|
||||
Mode: "infrastructure",
|
||||
}
|
||||
networks = append(networks, hiddenNetwork)
|
||||
seenSSIDs[currentSSID] = len(networks) - 1
|
||||
}
|
||||
}
|
||||
|
||||
visibleNetworks := wiFiNetworksBySSID(networks, true)
|
||||
savedNetworks := savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworks, currentSSID, wifiConnected)
|
||||
|
||||
sortWiFiNetworks(networks)
|
||||
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiNetworks = networks
|
||||
b.state.SavedWiFiNetworks = savedNetworks
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) updateSavedWiFiNetworks() error {
|
||||
s := b.settings
|
||||
if s == nil {
|
||||
var err error
|
||||
s, err = gonetworkmanager.NewSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get settings: %w", err)
|
||||
}
|
||||
b.settings = s
|
||||
}
|
||||
|
||||
settingsMgr := s.(gonetworkmanager.Settings)
|
||||
connections, err := settingsMgr.ListConnections()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get connections: %w", err)
|
||||
}
|
||||
|
||||
savedProfiles := getSavedWiFiProfiles(connections)
|
||||
|
||||
b.stateMutex.RLock()
|
||||
currentSSID := b.state.WiFiSSID
|
||||
wifiConnected := b.state.WiFiConnected
|
||||
wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...)
|
||||
b.stateMutex.RUnlock()
|
||||
|
||||
wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, savedProfiles, currentSSID, wifiConnected)
|
||||
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiNetworks = wifiNetworks
|
||||
b.state.SavedWiFiNetworks = savedNetworks
|
||||
b.stateMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) {
|
||||
s := b.settings
|
||||
if s == nil {
|
||||
@@ -975,49 +1036,14 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
return
|
||||
}
|
||||
|
||||
savedSSIDs := make(map[string]bool)
|
||||
autoconnectMap := make(map[string]bool)
|
||||
hiddenSSIDs := make(map[string]bool)
|
||||
for _, conn := range connections {
|
||||
connSettings, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
connMeta, ok := connSettings["connection"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
connType, ok := connMeta["type"].(string)
|
||||
if !ok || connType != "802-11-wireless" {
|
||||
continue
|
||||
}
|
||||
|
||||
wifiSettings, ok := connSettings["802-11-wireless"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ssidBytes, ok := wifiSettings["ssid"].([]byte)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ssid := string(ssidBytes)
|
||||
savedSSIDs[ssid] = true
|
||||
autoconnect := true
|
||||
if ac, ok := connMeta["autoconnect"].(bool); ok {
|
||||
autoconnect = ac
|
||||
}
|
||||
autoconnectMap[ssid] = autoconnect
|
||||
|
||||
if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden {
|
||||
hiddenSSIDs[ssid] = true
|
||||
}
|
||||
}
|
||||
savedProfiles := getSavedWiFiProfiles(connections)
|
||||
|
||||
var devices []WiFiDevice
|
||||
visibleNetworks := make(map[string]WiFiNetwork)
|
||||
b.stateMutex.RLock()
|
||||
currentSSID := b.state.WiFiSSID
|
||||
wifiConnected := b.state.WiFiConnected
|
||||
b.stateMutex.RUnlock()
|
||||
|
||||
for name, devInfo := range b.wifiDevices {
|
||||
state, _ := devInfo.device.GetPropertyState()
|
||||
@@ -1050,14 +1076,16 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
apPaths, err := devInfo.wireless.GetAccessPoints()
|
||||
var networks []WiFiNetwork
|
||||
if err == nil {
|
||||
seenSSIDs := make(map[string]*WiFiNetwork)
|
||||
seenSSIDs := make(map[string]int)
|
||||
networks = make([]WiFiNetwork, 0, len(apPaths)+1)
|
||||
for _, ap := range apPaths {
|
||||
apSSID, err := ap.GetPropertySSID()
|
||||
if err != nil || apSSID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if existing, exists := seenSSIDs[apSSID]; exists {
|
||||
if existingIndex, exists := seenSSIDs[apSSID]; exists {
|
||||
existing := &networks[existingIndex]
|
||||
strength, _ := ap.GetPropertyStrength()
|
||||
if strength > existing.Signal {
|
||||
existing.Signal = strength
|
||||
@@ -1107,6 +1135,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
profile, saved := savedProfiles[apSSID]
|
||||
network := WiFiNetwork{
|
||||
SSID: apSSID,
|
||||
BSSID: apBSSID,
|
||||
@@ -1114,9 +1143,9 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
Secured: secured,
|
||||
Enterprise: enterprise,
|
||||
Connected: isConnected,
|
||||
Saved: savedSSIDs[apSSID],
|
||||
Autoconnect: autoconnectMap[apSSID],
|
||||
Hidden: hiddenSSIDs[apSSID],
|
||||
Saved: saved,
|
||||
Autoconnect: profile.Autoconnect,
|
||||
Hidden: profile.Hidden,
|
||||
Frequency: freq,
|
||||
Mode: modeStr,
|
||||
Rate: rate,
|
||||
@@ -1124,25 +1153,31 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
Device: name,
|
||||
}
|
||||
|
||||
seenSSIDs[apSSID] = &network
|
||||
networks = append(networks, network)
|
||||
seenSSIDs[apSSID] = len(networks) - 1
|
||||
if existing, ok := visibleNetworks[apSSID]; !ok || network.Signal > existing.Signal {
|
||||
visibleNetworks[apSSID] = network
|
||||
}
|
||||
}
|
||||
|
||||
if connected && ssid != "" {
|
||||
if _, exists := seenSSIDs[ssid]; !exists {
|
||||
profile, saved := savedProfiles[ssid]
|
||||
hiddenNetwork := WiFiNetwork{
|
||||
SSID: ssid,
|
||||
BSSID: bssid,
|
||||
Signal: signal,
|
||||
Secured: true,
|
||||
Connected: true,
|
||||
Saved: savedSSIDs[ssid],
|
||||
Autoconnect: autoconnectMap[ssid],
|
||||
Saved: saved,
|
||||
Autoconnect: profile.Autoconnect,
|
||||
Hidden: true,
|
||||
Mode: "infrastructure",
|
||||
Device: name,
|
||||
}
|
||||
networks = append(networks, hiddenNetwork)
|
||||
seenSSIDs[ssid] = len(networks) - 1
|
||||
visibleNetworks[ssid] = hiddenNetwork
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1203,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() {
|
||||
|
||||
b.stateMutex.Lock()
|
||||
b.state.WiFiDevices = devices
|
||||
b.state.SavedWiFiNetworks = savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworks, currentSSID, wifiConnected)
|
||||
b.stateMutex.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2"
|
||||
"github.com/Wifx/gonetworkmanager/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -176,6 +177,54 @@ func TestNetworkManagerBackend_UpdateWiFiNetworks_NoDevice(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "no WiFi device available")
|
||||
}
|
||||
|
||||
func TestNetworkManagerBackend_UpdateSavedWiFiNetworksPreservesVisibleSavedNetworks(t *testing.T) {
|
||||
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
|
||||
mockSettings := mock_gonetworkmanager.NewMockSettings(t)
|
||||
mockConn := mock_gonetworkmanager.NewMockConnection(t)
|
||||
|
||||
backend, err := NewNetworkManagerBackend(mockNM)
|
||||
assert.NoError(t, err)
|
||||
backend.settings = mockSettings
|
||||
|
||||
backend.stateMutex.Lock()
|
||||
backend.state.WiFiNetworks = []WiFiNetwork{
|
||||
{
|
||||
SSID: "Home",
|
||||
Signal: 76,
|
||||
},
|
||||
}
|
||||
backend.stateMutex.Unlock()
|
||||
|
||||
settings := gonetworkmanager.ConnectionSettings{
|
||||
"connection": {
|
||||
"type": "802-11-wireless",
|
||||
"autoconnect": true,
|
||||
},
|
||||
"802-11-wireless": {
|
||||
"ssid": []byte("Home"),
|
||||
},
|
||||
"802-11-wireless-security": {},
|
||||
}
|
||||
mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil)
|
||||
mockConn.EXPECT().GetSettings().Return(settings, nil)
|
||||
|
||||
err = backend.updateSavedWiFiNetworks()
|
||||
assert.NoError(t, err)
|
||||
|
||||
backend.stateMutex.RLock()
|
||||
savedNetworks := append([]WiFiNetwork(nil), backend.state.SavedWiFiNetworks...)
|
||||
wifiNetworks := append([]WiFiNetwork(nil), backend.state.WiFiNetworks...)
|
||||
backend.stateMutex.RUnlock()
|
||||
|
||||
assert.Len(t, wifiNetworks, 1)
|
||||
assert.True(t, wifiNetworks[0].Saved)
|
||||
assert.Len(t, savedNetworks, 1)
|
||||
assert.Equal(t, "Home", savedNetworks[0].SSID)
|
||||
assert.True(t, savedNetworks[0].Saved)
|
||||
assert.False(t, savedNetworks[0].OutOfRange)
|
||||
assert.Equal(t, uint8(76), savedNetworks[0].Signal)
|
||||
}
|
||||
|
||||
func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) {
|
||||
mockNM := mock_gonetworkmanager.NewMockNetworkManager(t)
|
||||
|
||||
|
||||
@@ -64,9 +64,10 @@ func NewManager() (*Manager, error) {
|
||||
m := &Manager{
|
||||
backend: backend,
|
||||
state: &NetworkState{
|
||||
NetworkStatus: StatusDisconnected,
|
||||
Preference: PreferenceAuto,
|
||||
WiFiNetworks: []WiFiNetwork{},
|
||||
NetworkStatus: StatusDisconnected,
|
||||
Preference: PreferenceAuto,
|
||||
WiFiNetworks: []WiFiNetwork{},
|
||||
SavedWiFiNetworks: []WiFiNetwork{},
|
||||
},
|
||||
stateMutex: sync.RWMutex{},
|
||||
|
||||
@@ -120,6 +121,7 @@ func (m *Manager) syncStateFromBackend() error {
|
||||
m.state.WiFiBSSID = backendState.WiFiBSSID
|
||||
m.state.WiFiSignal = backendState.WiFiSignal
|
||||
m.state.WiFiNetworks = backendState.WiFiNetworks
|
||||
m.state.SavedWiFiNetworks = backendState.SavedWiFiNetworks
|
||||
m.state.WiFiDevices = backendState.WiFiDevices
|
||||
m.state.WiredConnections = backendState.WiredConnections
|
||||
m.state.VPNProfiles = backendState.VPNProfiles
|
||||
@@ -156,6 +158,7 @@ func (m *Manager) snapshotState() NetworkState {
|
||||
defer m.stateMutex.RUnlock()
|
||||
s := *m.state
|
||||
s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...)
|
||||
s.SavedWiFiNetworks = append([]WiFiNetwork(nil), m.state.SavedWiFiNetworks...)
|
||||
s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...)
|
||||
s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...)
|
||||
s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...)
|
||||
@@ -211,6 +214,9 @@ func stateChangedMeaningfully(old, new *NetworkState) bool {
|
||||
if len(old.WiFiNetworks) != len(new.WiFiNetworks) {
|
||||
return true
|
||||
}
|
||||
if len(old.SavedWiFiNetworks) != len(new.SavedWiFiNetworks) {
|
||||
return true
|
||||
}
|
||||
if len(old.WiFiDevices) != len(new.WiFiDevices) {
|
||||
return true
|
||||
}
|
||||
@@ -238,6 +244,23 @@ func stateChangedMeaningfully(old, new *NetworkState) bool {
|
||||
}
|
||||
}
|
||||
|
||||
for i := range old.SavedWiFiNetworks {
|
||||
oldNet := &old.SavedWiFiNetworks[i]
|
||||
newNet := &new.SavedWiFiNetworks[i]
|
||||
if oldNet.SSID != newNet.SSID {
|
||||
return true
|
||||
}
|
||||
if oldNet.Connected != newNet.Connected {
|
||||
return true
|
||||
}
|
||||
if oldNet.Autoconnect != newNet.Autoconnect {
|
||||
return true
|
||||
}
|
||||
if oldNet.OutOfRange != newNet.OutOfRange {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for i := range old.WiredConnections {
|
||||
oldNet := &old.WiredConnections[i]
|
||||
newNet := &new.WiredConnections[i]
|
||||
|
||||
@@ -34,6 +34,7 @@ type WiFiNetwork struct {
|
||||
Saved bool `json:"saved"`
|
||||
Autoconnect bool `json:"autoconnect"`
|
||||
Hidden bool `json:"hidden"`
|
||||
OutOfRange bool `json:"outOfRange"`
|
||||
Frequency uint32 `json:"frequency"`
|
||||
Mode string `json:"mode"`
|
||||
Rate uint32 `json:"rate"`
|
||||
@@ -111,6 +112,7 @@ type NetworkState struct {
|
||||
WiFiBSSID string `json:"wifiBSSID"`
|
||||
WiFiSignal uint8 `json:"wifiSignal"`
|
||||
WiFiNetworks []WiFiNetwork `json:"wifiNetworks"`
|
||||
SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"`
|
||||
WiFiDevices []WiFiDevice `json:"wifiDevices"`
|
||||
WiredConnections []WiredConnection `json:"wiredConnections"`
|
||||
VPNProfiles []VPNProfile `json:"vpnProfiles"`
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package network
|
||||
|
||||
import "sort"
|
||||
|
||||
type savedWiFiProfile struct {
|
||||
Autoconnect bool
|
||||
Hidden bool
|
||||
Secured bool
|
||||
Enterprise bool
|
||||
Mode string
|
||||
}
|
||||
|
||||
// Saved WiFi state is keyed by SSID because the UI/API accepts SSID actions.
|
||||
// Multiple backend profiles for the same SSID are intentionally collapsed here.
|
||||
func mergeSavedProfilesIntoWiFiNetworks(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool) []WiFiNetwork {
|
||||
merged := make([]WiFiNetwork, len(networks))
|
||||
for i, network := range networks {
|
||||
profile, saved := profiles[network.SSID]
|
||||
network.Connected = wifiConnected && network.SSID == currentSSID
|
||||
network.Saved = saved
|
||||
if saved {
|
||||
network.Autoconnect = profile.Autoconnect
|
||||
network.Hidden = network.Hidden || profile.Hidden
|
||||
network.Secured = network.Secured || profile.Secured
|
||||
network.Enterprise = network.Enterprise || profile.Enterprise
|
||||
if network.Mode == "" {
|
||||
network.Mode = profile.Mode
|
||||
}
|
||||
} else {
|
||||
network.Autoconnect = false
|
||||
}
|
||||
merged[i] = network
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func wiFiNetworksBySSID(networks []WiFiNetwork, visibleOnly bool) map[string]WiFiNetwork {
|
||||
visible := make(map[string]WiFiNetwork, len(networks))
|
||||
for _, network := range networks {
|
||||
if visibleOnly && network.OutOfRange {
|
||||
continue
|
||||
}
|
||||
visible[network.SSID] = network
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func refreshSavedWiFiState(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool) ([]WiFiNetwork, []WiFiNetwork) {
|
||||
mergedNetworks := mergeSavedProfilesIntoWiFiNetworks(networks, profiles, currentSSID, wifiConnected)
|
||||
visibleNetworks := wiFiNetworksBySSID(mergedNetworks, true)
|
||||
savedNetworks := savedWiFiNetworksFromProfiles(profiles, visibleNetworks, currentSSID, wifiConnected)
|
||||
return mergedNetworks, savedNetworks
|
||||
}
|
||||
|
||||
func savedWiFiNetworksFromProfiles(profiles map[string]savedWiFiProfile, visible map[string]WiFiNetwork, currentSSID string, wifiConnected bool) []WiFiNetwork {
|
||||
networks := make([]WiFiNetwork, 0, len(profiles))
|
||||
for ssid, profile := range profiles {
|
||||
if network, ok := visible[ssid]; ok {
|
||||
network.Saved = true
|
||||
network.Autoconnect = profile.Autoconnect
|
||||
network.Hidden = network.Hidden || profile.Hidden
|
||||
network.Secured = network.Secured || profile.Secured
|
||||
network.Enterprise = network.Enterprise || profile.Enterprise
|
||||
network.OutOfRange = false
|
||||
if network.Mode == "" {
|
||||
network.Mode = profile.Mode
|
||||
}
|
||||
networks = append(networks, network)
|
||||
continue
|
||||
}
|
||||
|
||||
isConnected := wifiConnected && ssid == currentSSID
|
||||
networks = append(networks, WiFiNetwork{
|
||||
SSID: ssid,
|
||||
Secured: profile.Secured,
|
||||
Enterprise: profile.Enterprise,
|
||||
Connected: isConnected,
|
||||
Saved: true,
|
||||
Autoconnect: profile.Autoconnect,
|
||||
Hidden: profile.Hidden,
|
||||
OutOfRange: !isConnected,
|
||||
Mode: profile.Mode,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(networks, func(i, j int) bool {
|
||||
if networks[i].Connected && !networks[j].Connected {
|
||||
return true
|
||||
}
|
||||
if !networks[i].Connected && networks[j].Connected {
|
||||
return false
|
||||
}
|
||||
if networks[i].OutOfRange != networks[j].OutOfRange {
|
||||
return !networks[i].OutOfRange
|
||||
}
|
||||
if networks[i].Signal != networks[j].Signal {
|
||||
return networks[i].Signal > networks[j].Signal
|
||||
}
|
||||
return networks[i].SSID < networks[j].SSID
|
||||
})
|
||||
|
||||
return networks
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMergeSavedProfilesIntoWiFiNetworks(t *testing.T) {
|
||||
networks := []WiFiNetwork{
|
||||
{
|
||||
SSID: "Home",
|
||||
Signal: 80,
|
||||
Secured: false,
|
||||
Autoconnect: false,
|
||||
},
|
||||
{
|
||||
SSID: "Cafe",
|
||||
Signal: 50,
|
||||
Secured: false,
|
||||
Autoconnect: true,
|
||||
},
|
||||
}
|
||||
profiles := map[string]savedWiFiProfile{
|
||||
"Home": {
|
||||
Autoconnect: true,
|
||||
Hidden: true,
|
||||
Secured: true,
|
||||
Mode: "infrastructure",
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeSavedProfilesIntoWiFiNetworks(networks, profiles, "Home", true)
|
||||
|
||||
assert.Len(t, merged, 2)
|
||||
assert.Equal(t, "Home", merged[0].SSID)
|
||||
assert.True(t, merged[0].Connected)
|
||||
assert.True(t, merged[0].Saved)
|
||||
assert.True(t, merged[0].Autoconnect)
|
||||
assert.True(t, merged[0].Hidden)
|
||||
assert.True(t, merged[0].Secured)
|
||||
assert.Equal(t, "infrastructure", merged[0].Mode)
|
||||
|
||||
assert.Equal(t, "Cafe", merged[1].SSID)
|
||||
assert.False(t, merged[1].Saved)
|
||||
assert.False(t, merged[1].Autoconnect)
|
||||
}
|
||||
|
||||
func TestSavedWiFiNetworksFromProfilesOutOfRangeWithoutVisibleNetworks(t *testing.T) {
|
||||
profiles := map[string]savedWiFiProfile{
|
||||
"Home": {
|
||||
Autoconnect: true,
|
||||
Secured: true,
|
||||
Mode: "infrastructure",
|
||||
},
|
||||
}
|
||||
|
||||
networks := savedWiFiNetworksFromProfiles(profiles, nil, "", false)
|
||||
|
||||
assert.Len(t, networks, 1)
|
||||
assert.Equal(t, "Home", networks[0].SSID)
|
||||
assert.True(t, networks[0].Saved)
|
||||
assert.True(t, networks[0].OutOfRange)
|
||||
assert.Equal(t, uint8(0), networks[0].Signal)
|
||||
}
|
||||
|
||||
func TestSavedWiFiNetworksFromProfilesKeepsConnectedCurrentNetworkInRange(t *testing.T) {
|
||||
profiles := map[string]savedWiFiProfile{
|
||||
"Home": {
|
||||
Autoconnect: true,
|
||||
Secured: true,
|
||||
},
|
||||
}
|
||||
|
||||
networks := savedWiFiNetworksFromProfiles(profiles, nil, "Home", true)
|
||||
|
||||
assert.Len(t, networks, 1)
|
||||
assert.Equal(t, "Home", networks[0].SSID)
|
||||
assert.True(t, networks[0].Connected)
|
||||
assert.False(t, networks[0].OutOfRange)
|
||||
}
|
||||
|
||||
func TestSavedWiFiNetworksFromProfilesIncludesOutOfRange(t *testing.T) {
|
||||
profiles := map[string]savedWiFiProfile{
|
||||
"Home": {
|
||||
Autoconnect: true,
|
||||
Hidden: true,
|
||||
Secured: true,
|
||||
Mode: "infrastructure",
|
||||
},
|
||||
"Office": {
|
||||
Autoconnect: false,
|
||||
Secured: true,
|
||||
Enterprise: true,
|
||||
Mode: "infrastructure",
|
||||
},
|
||||
}
|
||||
visible := map[string]WiFiNetwork{
|
||||
"Home": {
|
||||
SSID: "Home",
|
||||
Signal: 72,
|
||||
Secured: true,
|
||||
Connected: true,
|
||||
},
|
||||
}
|
||||
|
||||
networks := savedWiFiNetworksFromProfiles(profiles, visible, "Home", true)
|
||||
|
||||
assert.Len(t, networks, 2)
|
||||
assert.Equal(t, "Home", networks[0].SSID)
|
||||
assert.True(t, networks[0].Saved)
|
||||
assert.True(t, networks[0].Connected)
|
||||
assert.False(t, networks[0].OutOfRange)
|
||||
assert.True(t, networks[0].Hidden)
|
||||
assert.Equal(t, uint8(72), networks[0].Signal)
|
||||
|
||||
assert.Equal(t, "Office", networks[1].SSID)
|
||||
assert.True(t, networks[1].Saved)
|
||||
assert.False(t, networks[1].Autoconnect)
|
||||
assert.True(t, networks[1].Enterprise)
|
||||
assert.True(t, networks[1].OutOfRange)
|
||||
}
|
||||
|
||||
func TestWiFiNetworksBySSIDVisibleOnlySkipsOutOfRange(t *testing.T) {
|
||||
visible := wiFiNetworksBySSID([]WiFiNetwork{
|
||||
{SSID: "Home", Signal: 70},
|
||||
{SSID: "Office", Signal: 0, OutOfRange: true},
|
||||
}, true)
|
||||
|
||||
assert.Contains(t, visible, "Home")
|
||||
assert.NotContains(t, visible, "Office")
|
||||
}
|
||||
|
||||
func TestRefreshSavedWiFiStatePreservesVisibleSavedNetworks(t *testing.T) {
|
||||
networks := []WiFiNetwork{
|
||||
{
|
||||
SSID: "Home",
|
||||
Signal: 82,
|
||||
},
|
||||
}
|
||||
profiles := map[string]savedWiFiProfile{
|
||||
"Home": {
|
||||
Autoconnect: true,
|
||||
Secured: true,
|
||||
Mode: "infrastructure",
|
||||
},
|
||||
"Office": {
|
||||
Autoconnect: false,
|
||||
Secured: true,
|
||||
Mode: "infrastructure",
|
||||
},
|
||||
}
|
||||
|
||||
mergedNetworks, savedNetworks := refreshSavedWiFiState(networks, profiles, "", false)
|
||||
|
||||
assert.Len(t, mergedNetworks, 1)
|
||||
assert.Equal(t, "Home", mergedNetworks[0].SSID)
|
||||
assert.True(t, mergedNetworks[0].Saved)
|
||||
assert.True(t, mergedNetworks[0].Autoconnect)
|
||||
|
||||
assert.Len(t, savedNetworks, 2)
|
||||
assert.Equal(t, "Home", savedNetworks[0].SSID)
|
||||
assert.True(t, savedNetworks[0].Saved)
|
||||
assert.False(t, savedNetworks[0].OutOfRange)
|
||||
assert.Equal(t, uint8(82), savedNetworks[0].Signal)
|
||||
|
||||
assert.Equal(t, "Office", savedNetworks[1].SSID)
|
||||
assert.True(t, savedNetworks[1].Saved)
|
||||
assert.True(t, savedNetworks[1].OutOfRange)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 25
|
||||
const APIVersion = 26
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
|
||||
@@ -66,16 +66,17 @@ func convertPeerStatus(ps *ipnstate.PeerStatus, users map[tailcfg.UserID]tailcfg
|
||||
}
|
||||
|
||||
peer := Peer{
|
||||
ID: string(ps.ID),
|
||||
Hostname: hostname,
|
||||
DNSName: dnsName,
|
||||
OS: ps.OS,
|
||||
Online: ps.Online,
|
||||
Active: ps.Active,
|
||||
ExitNode: ps.ExitNode,
|
||||
Relay: ps.Relay,
|
||||
RxBytes: ps.RxBytes,
|
||||
TxBytes: ps.TxBytes,
|
||||
ID: string(ps.ID),
|
||||
Hostname: hostname,
|
||||
DNSName: dnsName,
|
||||
OS: ps.OS,
|
||||
Online: ps.Online,
|
||||
Active: ps.Active,
|
||||
ExitNode: ps.ExitNode,
|
||||
ExitNodeOption: ps.ExitNodeOption,
|
||||
Relay: ps.Relay,
|
||||
RxBytes: ps.RxBytes,
|
||||
TxBytes: ps.TxBytes,
|
||||
}
|
||||
|
||||
for _, ip := range ps.TailscaleIPs {
|
||||
|
||||
@@ -14,6 +14,14 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||
handleGetStatus(conn, req, manager)
|
||||
case "tailscale.refresh":
|
||||
handleRefresh(conn, req, manager)
|
||||
case "tailscale.connect":
|
||||
handleConnect(conn, req, manager)
|
||||
case "tailscale.disconnect":
|
||||
handleDisconnect(conn, req, manager)
|
||||
case "tailscale.setExitNode":
|
||||
handleSetExitNode(conn, req, manager)
|
||||
case "tailscale.setAllowLanAccess":
|
||||
handleSetAllowLanAccess(conn, req, manager)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
@@ -28,3 +36,37 @@ func handleRefresh(conn net.Conn, req models.Request, manager *Manager) {
|
||||
manager.RefreshState()
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "refreshed"})
|
||||
}
|
||||
|
||||
func handleConnect(conn net.Conn, req models.Request, manager *Manager) {
|
||||
if err := manager.Connect(); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "connected"})
|
||||
}
|
||||
|
||||
func handleDisconnect(conn net.Conn, req models.Request, manager *Manager) {
|
||||
if err := manager.Disconnect(); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "disconnected"})
|
||||
}
|
||||
|
||||
func handleSetExitNode(conn net.Conn, req models.Request, manager *Manager) {
|
||||
id := models.GetOr(req, "id", "")
|
||||
if err := manager.SetExitNode(id); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "exit node updated"})
|
||||
}
|
||||
|
||||
func handleSetAllowLanAccess(conn net.Conn, req models.Request, manager *Manager) {
|
||||
enabled := models.GetOr(req, "enabled", false)
|
||||
if err := manager.SetAllowLANAccess(enabled); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "lan access updated"})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -78,6 +79,63 @@ func TestHandleRefresh(t *testing.T) {
|
||||
assert.True(t, resp.Result.Success)
|
||||
}
|
||||
|
||||
func TestHandleActions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
params map[string]any
|
||||
}{
|
||||
{"connect", "tailscale.connect", nil},
|
||||
{"disconnect", "tailscale.disconnect", nil},
|
||||
{"setExitNode", "tailscale.setExitNode", map[string]any{"id": "nABC123"}},
|
||||
{"clearExitNode", "tailscale.setExitNode", map[string]any{"id": ""}},
|
||||
{"setAllowLanAccess", "tailscale.setAllowLanAccess", map[string]any{"enabled": true}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := handlerTestManager()
|
||||
defer m.Close()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{ID: 1, Method: tc.method, Params: tc.params}
|
||||
HandleRequest(conn, req, m)
|
||||
|
||||
var resp models.Response[models.SuccessResult]
|
||||
require.NoError(t, json.NewDecoder(buf).Decode(&resp))
|
||||
assert.Equal(t, 1, resp.ID)
|
||||
assert.Empty(t, resp.Error)
|
||||
require.NotNil(t, resp.Result)
|
||||
assert.True(t, resp.Result.Success)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAction_BackendError(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: blockingWatch,
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
|
||||
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
return nil, fmt.Errorf("backend rejected edit")
|
||||
},
|
||||
}
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
conn := &mockConn{Buffer: buf}
|
||||
|
||||
req := models.Request{ID: 1, Method: "tailscale.connect"}
|
||||
HandleRequest(conn, req, m)
|
||||
|
||||
var resp models.Response[models.SuccessResult]
|
||||
require.NoError(t, json.NewDecoder(buf).Decode(&resp))
|
||||
assert.Nil(t, resp.Result)
|
||||
assert.Contains(t, resp.Error, "backend rejected edit")
|
||||
}
|
||||
|
||||
func TestHandleRequest_UnknownMethod(t *testing.T) {
|
||||
m := handlerTestManager()
|
||||
defer m.Close()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,6 +23,8 @@ const (
|
||||
type tailscaleClient interface {
|
||||
WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||
Status(ctx context.Context) (*ipnstate.Status, error)
|
||||
GetPrefs(ctx context.Context) (*ipn.Prefs, error)
|
||||
EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error)
|
||||
}
|
||||
|
||||
// ipnBusWatcher abstracts the IPN bus watcher for testing.
|
||||
@@ -43,6 +46,14 @@ func (w *localClientWrapper) Status(ctx context.Context) (*ipnstate.Status, erro
|
||||
return w.client.Status(ctx)
|
||||
}
|
||||
|
||||
func (w *localClientWrapper) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
return w.client.GetPrefs(ctx)
|
||||
}
|
||||
|
||||
func (w *localClientWrapper) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
return w.client.EditPrefs(ctx, mp)
|
||||
}
|
||||
|
||||
// Manager manages Tailscale state via IPN bus events and subscriber notifications.
|
||||
type Manager struct {
|
||||
state *TailscaleState
|
||||
@@ -169,16 +180,36 @@ func (m *Manager) fetchAndBroadcast(ctx context.Context) {
|
||||
statusCtx, cancel := context.WithTimeout(ctx, statusTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, err := m.client.Status(statusCtx)
|
||||
state, err := m.fetchState(statusCtx)
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] Failed to fetch status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
m.updateState(state)
|
||||
}
|
||||
|
||||
// fetchState fetches the current status and merges in pref-derived fields
|
||||
// (e.g. exit-node LAN access) that are not present in the IPN status itself.
|
||||
func (m *Manager) fetchState(ctx context.Context) (*TailscaleState, error) {
|
||||
status, err := m.client.Status(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
|
||||
// Prefs carry the exit-node LAN-access toggle, which the status does not
|
||||
// expose. Treat a prefs failure as non-fatal so status still updates.
|
||||
if prefs, err := m.client.GetPrefs(ctx); err != nil {
|
||||
log.Warnf("[Tailscale] Failed to fetch prefs: %v", err)
|
||||
} else if prefs != nil {
|
||||
state.ExitNodeAllowLANAccess = prefs.ExitNodeAllowLANAccess
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (m *Manager) updateState(state *TailscaleState) {
|
||||
m.stateMutex.Lock()
|
||||
m.state = state
|
||||
@@ -266,12 +297,62 @@ func (m *Manager) RefreshState() {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
|
||||
defer cancel()
|
||||
|
||||
status, err := m.client.Status(ctx)
|
||||
state, err := m.fetchState(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("[Tailscale] Failed to refresh state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
state := convertStatus(status)
|
||||
m.updateState(state)
|
||||
}
|
||||
|
||||
// Connect brings the Tailscale backend up (WantRunning = true).
|
||||
func (m *Manager) Connect() error {
|
||||
return m.editPrefs(&ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{WantRunning: true},
|
||||
WantRunningSet: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Disconnect brings the Tailscale backend down (WantRunning = false).
|
||||
func (m *Manager) Disconnect() error {
|
||||
return m.editPrefs(&ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{WantRunning: false},
|
||||
WantRunningSet: true,
|
||||
})
|
||||
}
|
||||
|
||||
// SetExitNode selects the exit node identified by its stable node ID. An empty
|
||||
// id clears the current exit node. Mirrors `tailscale set --exit-node=<id>`,
|
||||
// which also clears any legacy IP-based exit node so a stale ExitNodeIP cannot
|
||||
// silently take precedence over the now-empty ID.
|
||||
func (m *Manager) SetExitNode(id string) error {
|
||||
return m.editPrefs(&ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{ExitNodeID: tailcfg.StableNodeID(id)},
|
||||
ExitNodeIDSet: true,
|
||||
ExitNodeIPSet: true,
|
||||
})
|
||||
}
|
||||
|
||||
// SetAllowLANAccess toggles whether locally accessible subnets remain
|
||||
// reachable while an exit node is in use.
|
||||
func (m *Manager) SetAllowLANAccess(enabled bool) error {
|
||||
return m.editPrefs(&ipn.MaskedPrefs{
|
||||
Prefs: ipn.Prefs{ExitNodeAllowLANAccess: enabled},
|
||||
ExitNodeAllowLANAccessSet: true,
|
||||
})
|
||||
}
|
||||
|
||||
// editPrefs applies a masked prefs edit and refreshes state so subscribers see
|
||||
// the result immediately, in addition to the IPN bus notification it triggers.
|
||||
func (m *Manager) editPrefs(mp *ipn.MaskedPrefs) error {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, statusTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := m.client.EditPrefs(ctx, mp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.RefreshState()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,8 +12,16 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// blockingWatch is a watchFn that blocks until the context is cancelled, used
|
||||
// by tests that exercise direct manager calls rather than the watch loop.
|
||||
func blockingWatch(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// mockWatcher yields canned Notify events, then returns err or blocks until Close/context cancel.
|
||||
type mockWatcher struct {
|
||||
events []ipn.Notify
|
||||
@@ -68,8 +76,10 @@ func (w *mockWatcher) Close() error {
|
||||
|
||||
// mockClient implements tailscaleClient for testing.
|
||||
type mockClient struct {
|
||||
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||
statusFn func(ctx context.Context) (*ipnstate.Status, error)
|
||||
watchFn func(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error)
|
||||
statusFn func(ctx context.Context) (*ipnstate.Status, error)
|
||||
getPrefsFn func(ctx context.Context) (*ipn.Prefs, error)
|
||||
editPrefsFn func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error)
|
||||
}
|
||||
|
||||
func (c *mockClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (ipnBusWatcher, error) {
|
||||
@@ -80,6 +90,20 @@ func (c *mockClient) Status(ctx context.Context) (*ipnstate.Status, error) {
|
||||
return c.statusFn(ctx)
|
||||
}
|
||||
|
||||
func (c *mockClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) {
|
||||
if c.getPrefsFn != nil {
|
||||
return c.getPrefsFn(ctx)
|
||||
}
|
||||
return &ipn.Prefs{}, nil
|
||||
}
|
||||
|
||||
func (c *mockClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
if c.editPrefsFn != nil {
|
||||
return c.editPrefsFn(ctx, mp)
|
||||
}
|
||||
return &ipn.Prefs{}, nil
|
||||
}
|
||||
|
||||
func runningStatus() *ipnstate.Status {
|
||||
return &ipnstate.Status{
|
||||
Version: "1.94.2",
|
||||
@@ -296,3 +320,78 @@ func TestManager_RefreshState(t *testing.T) {
|
||||
assert.True(t, state.Connected)
|
||||
assert.Equal(t, "cachyos", state.Self.Hostname)
|
||||
}
|
||||
|
||||
func TestManager_RefreshState_MergesPrefs(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: blockingWatch,
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
|
||||
getPrefsFn: func(ctx context.Context) (*ipn.Prefs, error) {
|
||||
return &ipn.Prefs{ExitNodeAllowLANAccess: true}, nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
m.RefreshState()
|
||||
|
||||
assert.True(t, m.GetState().ExitNodeAllowLANAccess)
|
||||
}
|
||||
|
||||
func TestManager_Actions_EditPrefs(t *testing.T) {
|
||||
var captured *ipn.MaskedPrefs
|
||||
client := &mockClient{
|
||||
watchFn: blockingWatch,
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
|
||||
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
captured = mp
|
||||
return &ipn.Prefs{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
require.NoError(t, m.Connect())
|
||||
require.NotNil(t, captured)
|
||||
assert.True(t, captured.WantRunningSet)
|
||||
assert.True(t, captured.WantRunning)
|
||||
|
||||
require.NoError(t, m.Disconnect())
|
||||
assert.True(t, captured.WantRunningSet)
|
||||
assert.False(t, captured.WantRunning)
|
||||
|
||||
require.NoError(t, m.SetExitNode("nABC123"))
|
||||
assert.True(t, captured.ExitNodeIDSet)
|
||||
assert.Equal(t, tailcfg.StableNodeID("nABC123"), captured.ExitNodeID)
|
||||
// ExitNodeIPSet must also be set so a stale legacy ExitNodeIP cannot
|
||||
// override the ID-based selection (mirrors `tailscale set --exit-node`).
|
||||
assert.True(t, captured.ExitNodeIPSet)
|
||||
|
||||
require.NoError(t, m.SetExitNode(""))
|
||||
assert.True(t, captured.ExitNodeIDSet)
|
||||
assert.Equal(t, tailcfg.StableNodeID(""), captured.ExitNodeID)
|
||||
// Clearing must zero both the ID and any legacy IP-based exit node.
|
||||
assert.True(t, captured.ExitNodeIPSet)
|
||||
|
||||
require.NoError(t, m.SetAllowLANAccess(true))
|
||||
assert.True(t, captured.ExitNodeAllowLANAccessSet)
|
||||
assert.True(t, captured.ExitNodeAllowLANAccess)
|
||||
}
|
||||
|
||||
func TestManager_Actions_PropagateError(t *testing.T) {
|
||||
client := &mockClient{
|
||||
watchFn: blockingWatch,
|
||||
statusFn: func(ctx context.Context) (*ipnstate.Status, error) { return runningStatus(), nil },
|
||||
editPrefsFn: func(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) {
|
||||
return nil, fmt.Errorf("backend rejected edit")
|
||||
},
|
||||
}
|
||||
|
||||
m := newManager(client)
|
||||
defer m.Close()
|
||||
|
||||
assert.Error(t, m.Connect())
|
||||
assert.Error(t, m.SetExitNode("nABC123"))
|
||||
assert.Error(t, m.SetAllowLANAccess(true))
|
||||
}
|
||||
|
||||
@@ -2,30 +2,32 @@ package tailscale
|
||||
|
||||
// TailscaleState represents the current state of the Tailscale daemon.
|
||||
type TailscaleState struct {
|
||||
Connected bool `json:"connected"`
|
||||
Version string `json:"version"`
|
||||
BackendState string `json:"backendState"`
|
||||
MagicDNSSuffix string `json:"magicDnsSuffix"`
|
||||
TailnetName string `json:"tailnetName"`
|
||||
Self Peer `json:"self"`
|
||||
Peers []Peer `json:"peers"`
|
||||
Connected bool `json:"connected"`
|
||||
Version string `json:"version"`
|
||||
BackendState string `json:"backendState"`
|
||||
MagicDNSSuffix string `json:"magicDnsSuffix"`
|
||||
TailnetName string `json:"tailnetName"`
|
||||
ExitNodeAllowLANAccess bool `json:"exitNodeAllowLanAccess"`
|
||||
Self Peer `json:"self"`
|
||||
Peers []Peer `json:"peers"`
|
||||
}
|
||||
|
||||
// Peer represents a single node in the Tailscale network.
|
||||
type Peer struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
DNSName string `json:"dnsName"`
|
||||
TailscaleIP string `json:"tailscaleIp"`
|
||||
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
|
||||
OS string `json:"os"`
|
||||
Online bool `json:"online"`
|
||||
LastSeen string `json:"lastSeen,omitempty"`
|
||||
ExitNode bool `json:"exitNode"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
Relay string `json:"relay,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
RxBytes int64 `json:"rxBytes"`
|
||||
TxBytes int64 `json:"txBytes"`
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
DNSName string `json:"dnsName"`
|
||||
TailscaleIP string `json:"tailscaleIp"`
|
||||
TailscaleIPv6 string `json:"tailscaleIpv6,omitempty"`
|
||||
OS string `json:"os"`
|
||||
Online bool `json:"online"`
|
||||
LastSeen string `json:"lastSeen,omitempty"`
|
||||
ExitNode bool `json:"exitNode"`
|
||||
ExitNodeOption bool `json:"exitNodeOption"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
Relay string `json:"relay,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
RxBytes int64 `json:"rxBytes"`
|
||||
TxBytes int64 `json:"txBytes"`
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
# Usage: ./create-source.sh <package-dir> [ubuntu-series]
|
||||
#
|
||||
# Example:
|
||||
# ./create-source.sh ../dms questing # Ubuntu 25.10 (default series in ppa-upload)
|
||||
# ./create-source.sh ../dms resolute # Ubuntu 26.04 LTS
|
||||
# ./create-source.sh ../dms-git questing
|
||||
# ./create-source.sh ../dms resolute # Ubuntu 26.04 LTS (default series in ppa-upload)
|
||||
# ./create-source.sh ../dms stonking # Ubuntu 26.10
|
||||
# ./create-source.sh ../dms-git resolute
|
||||
# ./create-source.sh ../dms-git stonking
|
||||
|
||||
set -e
|
||||
|
||||
@@ -27,13 +27,13 @@ if [ $# -lt 1 ]; then
|
||||
echo "Arguments:"
|
||||
echo " package-dir : Path to package directory (e.g., ../dms)"
|
||||
echo " ubuntu-series : Ubuntu series (optional, default: noble)"
|
||||
echo " Options: noble, jammy, oracular, mantic, questing, resolute"
|
||||
echo " Options: noble, jammy, oracular, mantic, resolute, stonking"
|
||||
echo
|
||||
echo "Examples:"
|
||||
echo " $0 ../dms questing"
|
||||
echo " $0 ../dms resolute"
|
||||
echo " $0 ../dms-git questing"
|
||||
echo " $0 ../dms stonking"
|
||||
echo " $0 ../dms-git resolute"
|
||||
echo " $0 ../dms-git stonking"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -135,7 +135,7 @@ check_ppa_version_exists() {
|
||||
local CHECK_MODE="${4:-commit}"
|
||||
local DISTRO_SERIES="${5:-}"
|
||||
|
||||
# Query Launchpad API (optionally scoped to one Ubuntu series so the same version can ship to questing and resolute)
|
||||
# Query Launchpad API (optionally scoped to one Ubuntu series so the same version can ship to resolute and stonking)
|
||||
local API_URL="https://api.launchpad.net/1.0/~avengemedia/+archive/ubuntu/$PPA_NAME?ws.op=getPublishedSources&source_name=$SOURCE_NAME&status=Published"
|
||||
if [[ -n "$DISTRO_SERIES" ]]; then
|
||||
API_URL+="&distro_series=https://api.launchpad.net/1.0/ubuntu/${DISTRO_SERIES}"
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
PPA_OWNER="avengemedia"
|
||||
LAUNCHPAD_API="https://api.launchpad.net/1.0"
|
||||
# Supported Ubuntu series for PPA builds (25.10 questing + 26.04 LTS resolute)
|
||||
DISTRO_SERIES_LIST=(questing resolute)
|
||||
# Supported Ubuntu series for PPA builds (26.04 LTS resolute + 26.10 stonking)
|
||||
DISTRO_SERIES_LIST=(resolute stonking)
|
||||
|
||||
# Define packages (sync with ppa-upload.sh)
|
||||
ALL_PACKAGES=(dms dms-git dms-greeter)
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
|
||||
PPA_OWNER="avengemedia"
|
||||
LAUNCHPAD_API="https://api.launchpad.net/1.0"
|
||||
SERIES_LIST=(questing resolute)
|
||||
SERIES_LIST=(resolute stonking)
|
||||
PACKAGE_FILTER="dms-git"
|
||||
REBUILD_RELEASE=""
|
||||
JSON=false
|
||||
@@ -72,12 +72,12 @@ embedded_commit() {
|
||||
target_ppa() {
|
||||
local series="$1"
|
||||
if [[ -n "$REBUILD_RELEASE" ]]; then
|
||||
if [[ "$series" == "resolute" ]]; then
|
||||
if [[ "$series" == "stonking" ]]; then
|
||||
echo $((REBUILD_RELEASE + 1))
|
||||
else
|
||||
echo "$REBUILD_RELEASE"
|
||||
fi
|
||||
elif [[ "$series" == "resolute" ]]; then
|
||||
elif [[ "$series" == "stonking" ]]; then
|
||||
echo "2"
|
||||
else
|
||||
echo "1"
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
# Usage: ./ppa-upload.sh [package-name] [ppa-name] [ubuntu-series] [rebuild-number] [--keep-builds] [--rebuild=N]
|
||||
#
|
||||
# Examples:
|
||||
# ./ppa-upload.sh dms # Upload to questing + resolute (default)
|
||||
# ./ppa-upload.sh dms 2 # Native: questing ppa2, resolute ppa3 (auto +1 on second series)
|
||||
# ./ppa-upload.sh dms # Upload to resolute + stonking (default)
|
||||
# ./ppa-upload.sh dms 2 # Native: resolute ppa2, stonking ppa3 (auto +1 on second series)
|
||||
# ./ppa-upload.sh dms --rebuild=2 # Rebuild with ppa2 (flag syntax)
|
||||
# ./ppa-upload.sh dms-git # Single package (both series)
|
||||
# ./ppa-upload.sh all # All packages (each to both series)
|
||||
# ./ppa-upload.sh dms resolute # 26.04 LTS only (same as "dms dms resolute")
|
||||
# ./ppa-upload.sh dms questing # 25.10 only
|
||||
# ./ppa-upload.sh dms stonking # 26.10 only
|
||||
# ./ppa-upload.sh dms dms resolute # Explicit PPA name + one series (optional form)
|
||||
# ./ppa-upload.sh dms dms resolute 2 # One series + rebuild number
|
||||
# ./ppa-upload.sh distro/ubuntu/dms dms # Path-style (backward compatible)
|
||||
@@ -70,8 +70,8 @@ if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Shorthand: "dms resolute" / "dms questing" (package + series; PPA inferred — no need for "dms dms resolute")
|
||||
if [[ ${#POSITIONAL_ARGS[@]} -eq 2 ]] && [[ "${POSITIONAL_ARGS[1]}" == "questing" || "${POSITIONAL_ARGS[1]}" == "resolute" ]]; then
|
||||
# Shorthand: "dms resolute" / "dms stonking" (package + series; PPA inferred — no need for "dms dms resolute")
|
||||
if [[ ${#POSITIONAL_ARGS[@]} -eq 2 ]] && [[ "${POSITIONAL_ARGS[1]}" == "resolute" || "${POSITIONAL_ARGS[1]}" == "stonking" ]]; then
|
||||
PACKAGE_INPUT="${POSITIONAL_ARGS[0]}"
|
||||
PPA_NAME_INPUT=""
|
||||
UBUNTU_SERIES_RAW="${POSITIONAL_ARGS[1]}"
|
||||
@@ -79,11 +79,11 @@ fi
|
||||
|
||||
SERIES_LIST=()
|
||||
if [[ -z "$UBUNTU_SERIES_RAW" ]]; then
|
||||
SERIES_LIST=(questing resolute)
|
||||
elif [[ "$UBUNTU_SERIES_RAW" == "questing" || "$UBUNTU_SERIES_RAW" == "resolute" ]]; then
|
||||
SERIES_LIST=(resolute stonking)
|
||||
elif [[ "$UBUNTU_SERIES_RAW" == "resolute" || "$UBUNTU_SERIES_RAW" == "stonking" ]]; then
|
||||
SERIES_LIST=("$UBUNTU_SERIES_RAW")
|
||||
else
|
||||
error "Invalid Ubuntu series: $UBUNTU_SERIES_RAW (use questing, resolute, or omit for both)"
|
||||
error "Invalid Ubuntu series: $UBUNTU_SERIES_RAW (use resolute, stonking, or omit for both)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -40,10 +40,17 @@ override_dh_auto_install:
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/LICENSE \
|
||||
debian/dms-greeter/usr/share/doc/dms-greeter/LICENSE
|
||||
|
||||
install -Dpm0644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf \
|
||||
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf \
|
||||
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf
|
||||
# Install systemd tmpfiles/sysusers fragments only when present in the fetched source.
|
||||
# sysusers-dms-greeter.conf landed upstream after v1.4.6; guarding both lets older
|
||||
# release tarballs build, while future tags that ship the files install them automatically.
|
||||
if [ -f DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf ]; then \
|
||||
install -Dpm0644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/tmpfiles-dms-greeter.conf \
|
||||
debian/dms-greeter/usr/lib/tmpfiles.d/dms-greeter.conf; \
|
||||
fi
|
||||
if [ -f DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf ]; then \
|
||||
install -Dm644 DankMaterialShell-$(BASE_VERSION)/quickshell/systemd/sysusers-dms-greeter.conf \
|
||||
debian/dms-greeter/usr/lib/sysusers.d/dms-greeter.conf; \
|
||||
fi
|
||||
|
||||
# Create cache directory structure (will be created by postinst)
|
||||
mkdir -p debian/dms-greeter/var/cache/dms-greeter
|
||||
|
||||
+21
-1
@@ -6,6 +6,18 @@ DankMaterialShell provides comprehensive IPC (Inter-Process Communication) funct
|
||||
dms ipc call <target> <function> [parameters...]
|
||||
```
|
||||
|
||||
## Discovering IPC commands
|
||||
|
||||
List all available targets and functions while DMS is running:
|
||||
|
||||
```bash
|
||||
dms ipc list
|
||||
dms ipc # same
|
||||
dms ipc --help # same, plus usage text
|
||||
```
|
||||
|
||||
Live listing requires DMS to be running. If listing fails, use this document or the [Keybinds & IPC docs](https://danklinux.com/docs/dankmaterialshell/keybinds-ipc) as an offline reference.
|
||||
|
||||
## Target: `audio`
|
||||
|
||||
Audio system control and information.
|
||||
@@ -707,7 +719,7 @@ File browser controls for selecting wallpapers and profile images.
|
||||
- Both browsers support common image formats (jpg, jpeg, png, bmp, gif, webp)
|
||||
|
||||
### Target: `color-picker`
|
||||
Color picker modal control.
|
||||
In-shell color picker modal for theme and settings color selection.
|
||||
|
||||
**Functions:**
|
||||
- `open` - Show color picker modal
|
||||
@@ -718,6 +730,14 @@ Color picker modal control.
|
||||
- `toggle` - Toggle color picker modal visibility
|
||||
- `toggleInstant` - Toggle color picker modal visibility without animation on hide
|
||||
|
||||
**Note:** This controls the in-shell modal. To pick a pixel from the screen via CLI, use `dms color pick` instead (see [Color Picker CLI](https://danklinux.com/docs/dankmaterialshell/cli-color-picker)).
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
dms ipc call color-picker toggle
|
||||
dms ipc call color-picker openColor "#3f51b5"
|
||||
```
|
||||
|
||||
### Target: `hypr`
|
||||
Hyprland-specific controls including keybinds cheatsheet and workspace overview (Hyprland only).
|
||||
|
||||
|
||||
@@ -7,29 +7,31 @@ Item {
|
||||
property alias path: socket.path
|
||||
property alias parser: socket.parser
|
||||
property bool connected: false
|
||||
property bool linkUp: false
|
||||
|
||||
property int reconnectBaseMs: 400
|
||||
property int reconnectMaxMs: 15000
|
||||
|
||||
property int _reconnectAttempt: 0
|
||||
|
||||
signal connectionStateChanged()
|
||||
signal connectionStateChanged
|
||||
|
||||
onConnectedChanged: {
|
||||
socket.connected = connected
|
||||
socket.connected = connected;
|
||||
}
|
||||
|
||||
Socket {
|
||||
id: socket
|
||||
|
||||
onConnectionStateChanged: {
|
||||
root.connectionStateChanged()
|
||||
root.linkUp = connected;
|
||||
root.connectionStateChanged();
|
||||
if (connected) {
|
||||
root._reconnectAttempt = 0
|
||||
return
|
||||
root._reconnectAttempt = 0;
|
||||
return;
|
||||
}
|
||||
if (root.connected) {
|
||||
root._scheduleReconnect()
|
||||
root._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,24 +41,24 @@ Item {
|
||||
interval: 0
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
socket.connected = false
|
||||
Qt.callLater(() => socket.connected = true)
|
||||
socket.connected = false;
|
||||
Qt.callLater(() => socket.connected = true);
|
||||
}
|
||||
}
|
||||
|
||||
function send(data) {
|
||||
const json = typeof data === "string" ? data : JSON.stringify(data)
|
||||
const message = json.endsWith("\n") ? json : json + "\n"
|
||||
socket.write(message)
|
||||
socket.flush()
|
||||
const json = typeof data === "string" ? data : JSON.stringify(data);
|
||||
const message = json.endsWith("\n") ? json : json + "\n";
|
||||
socket.write(message);
|
||||
socket.flush();
|
||||
}
|
||||
|
||||
function _scheduleReconnect() {
|
||||
const pow = Math.min(_reconnectAttempt, 10)
|
||||
const base = Math.min(reconnectBaseMs * Math.pow(2, pow), reconnectMaxMs)
|
||||
const jitter = Math.floor(Math.random() * Math.floor(base / 4))
|
||||
reconnectTimer.interval = base + jitter
|
||||
reconnectTimer.restart()
|
||||
_reconnectAttempt++
|
||||
const pow = Math.min(_reconnectAttempt, 10);
|
||||
const base = Math.min(reconnectBaseMs * Math.pow(2, pow), reconnectMaxMs);
|
||||
const jitter = Math.floor(Math.random() * Math.floor(base / 4));
|
||||
reconnectTimer.interval = base + jitter;
|
||||
reconnectTimer.restart();
|
||||
_reconnectAttempt++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,40 @@ const KEY_MAP = {
|
||||
161: "exclamdown"
|
||||
};
|
||||
|
||||
function xkbKeyFromQtKey(qk) {
|
||||
// Numpad (keypad) keys. Qt reuses the same Qt::Key_* values for the numpad and
|
||||
// the main rows/nav cluster; only Qt.KeypadModifier distinguishes them. niri and
|
||||
// the other compositors bind against the xkb KP_* keysym names, so we must emit
|
||||
// those instead of the collapsed twin. With NumLock off the numpad sends the
|
||||
// navigation keysyms (KP_Home, KP_End, ...); with NumLock on it sends KP_0..KP_9
|
||||
// (handled by the digit range in xkbKeyFromQtKey). Operators/Enter are the same
|
||||
// in both states.
|
||||
const KP_MAP = {
|
||||
16777232: "KP_Home",
|
||||
16777235: "KP_Up",
|
||||
16777238: "KP_Prior",
|
||||
16777234: "KP_Left",
|
||||
16777227: "KP_Begin",
|
||||
16777236: "KP_Right",
|
||||
16777233: "KP_End",
|
||||
16777237: "KP_Down",
|
||||
16777239: "KP_Next",
|
||||
16777222: "KP_Insert",
|
||||
16777223: "KP_Delete",
|
||||
16777221: "KP_Enter",
|
||||
43: "KP_Add",
|
||||
45: "KP_Subtract",
|
||||
42: "KP_Multiply",
|
||||
47: "KP_Divide",
|
||||
46: "KP_Decimal"
|
||||
};
|
||||
|
||||
function xkbKeyFromQtKey(qk, isKeypad) {
|
||||
if (isKeypad) {
|
||||
if (qk >= 48 && qk <= 57)
|
||||
return "KP_" + (qk - 48);
|
||||
if (KP_MAP[qk])
|
||||
return KP_MAP[qk];
|
||||
}
|
||||
if (qk >= 65 && qk <= 90)
|
||||
return String.fromCharCode(qk);
|
||||
if (qk >= 97 && qk <= 122)
|
||||
|
||||
@@ -56,6 +56,9 @@ const DMS_ACTIONS = [
|
||||
{ id: "spawn dms ipc call dankdash wallpaper", label: "Wallpaper Browser" },
|
||||
{ id: "spawn dms ipc call file browse wallpaper", label: "File: Browse Wallpaper" },
|
||||
{ id: "spawn dms ipc call file browse profile", label: "File: Browse Profile" },
|
||||
{ id: "spawn dms ipc call color-picker toggle", label: "Color Picker: Toggle" },
|
||||
{ id: "spawn dms ipc call color-picker open", label: "Color Picker: Open" },
|
||||
{ id: "spawn dms ipc call color-picker close", label: "Color Picker: Close" },
|
||||
{ id: "spawn dms ipc call keybinds toggle niri", label: "Keybinds Cheatsheet: Toggle", compositor: "niri" },
|
||||
{ id: "spawn dms ipc call keybinds open niri", label: "Keybinds Cheatsheet: Open", compositor: "niri" },
|
||||
{ id: "spawn dms ipc call keybinds close", label: "Keybinds Cheatsheet: Close" },
|
||||
|
||||
@@ -108,6 +108,8 @@ Singleton {
|
||||
}
|
||||
|
||||
property bool clipboardEnterToPaste: false
|
||||
property bool clipboardRememberTypeFilter: false
|
||||
property string clipboardTypeFilter: "all"
|
||||
property var clipboardVisibleEntryActions: ["pin", "edit", "delete"]
|
||||
|
||||
property var launcherPluginVisibility: ({})
|
||||
@@ -164,6 +166,8 @@ Singleton {
|
||||
property real popupTransparency: 1.0
|
||||
property real dockTransparency: 1
|
||||
property string widgetBackgroundColor: "sch"
|
||||
property string widgetBackgroundCustomColor: "#6750A4"
|
||||
property real widgetBackgroundCustomStrength: 0.50
|
||||
property string widgetColorMode: "default"
|
||||
property string controlCenterTileColorMode: "primary"
|
||||
property string buttonColorMode: "primary"
|
||||
@@ -182,6 +186,7 @@ Singleton {
|
||||
|
||||
property int firstDayOfWeek: -1
|
||||
property bool showWeekNumber: false
|
||||
property string calendarBackend: "auto"
|
||||
property bool use24HourClock: true
|
||||
property bool showSeconds: false
|
||||
property bool padHours12Hour: false
|
||||
@@ -384,11 +389,16 @@ Singleton {
|
||||
property bool dwlShowAllTags: false
|
||||
property bool workspaceActiveAppHighlightEnabled: false
|
||||
property string workspaceColorMode: "default"
|
||||
property string workspaceFocusedCustomColor: "#6750A4"
|
||||
property string workspaceOccupiedColorMode: "none"
|
||||
property string workspaceOccupiedCustomColor: "#625B71"
|
||||
property string workspaceUnfocusedColorMode: "default"
|
||||
property string workspaceUnfocusedCustomColor: "#49454E"
|
||||
property string workspaceUrgentColorMode: "default"
|
||||
property string workspaceUrgentCustomColor: "#B3261E"
|
||||
property bool workspaceFocusedBorderEnabled: false
|
||||
property string workspaceFocusedBorderColor: "primary"
|
||||
property string workspaceFocusedBorderCustomColor: "#6750A4"
|
||||
property int workspaceFocusedBorderThickness: 2
|
||||
property var workspaceNameIcons: ({})
|
||||
property bool waveProgressEnabled: true
|
||||
@@ -397,6 +407,7 @@ Singleton {
|
||||
property bool audioVisualizerEnabled: true
|
||||
property string audioScrollMode: "volume"
|
||||
property int audioWheelScrollAmount: 5
|
||||
property bool audioDeviceScrollVolumeEnabled: false
|
||||
property bool clockCompactMode: false
|
||||
property int focusedWindowSize: 1
|
||||
property bool focusedWindowCompactMode: false
|
||||
@@ -404,6 +415,9 @@ Singleton {
|
||||
property int barMaxVisibleApps: 0
|
||||
property int barMaxVisibleRunningApps: 0
|
||||
property bool barShowOverflowBadge: true
|
||||
property bool trayAutoOverflow: true
|
||||
property bool trayPopupSingleLine: true
|
||||
property int trayMaxVisibleItems: 0
|
||||
property bool appsDockHideIndicators: false
|
||||
property bool appsDockColorizeActive: false
|
||||
property string appsDockActiveColorMode: "primary"
|
||||
@@ -460,6 +474,8 @@ Singleton {
|
||||
property bool launcherUseOverlayLayer: false
|
||||
property string launcherStyle: "full"
|
||||
property bool spotlightBarShowModeChips: false
|
||||
property bool keybindsFloatingWindow: false
|
||||
onKeybindsFloatingWindowChanged: saveSettings()
|
||||
|
||||
property string _legacyWeatherLocation: "New York, NY"
|
||||
property string _legacyWeatherCoordinates: "40.7128,-74.0060"
|
||||
@@ -519,13 +535,39 @@ Singleton {
|
||||
property real notificationSummaryFontSize: Spec.SPEC.notificationSummaryFontSize.def
|
||||
property real notificationBodyFontSize: Spec.SPEC.notificationBodyFontSize.def
|
||||
property bool notepadShowLineNumbers: false
|
||||
property bool notepadAutoSave: false
|
||||
property string notepadSlideoutSide: "right"
|
||||
property string notepadDefaultMode: "slideout"
|
||||
property real notepadTransparencyOverride: -1
|
||||
property real notepadLastCustomTransparency: 0.7
|
||||
property bool notepadUseCompositorGap: false
|
||||
property int notepadEdgeGap: 0
|
||||
|
||||
// Compositor layout gap when enabled and available, else the manual value.
|
||||
readonly property int notepadEffectiveEdgeGap: {
|
||||
if (notepadUseCompositorGap) {
|
||||
var g = -1;
|
||||
if (CompositorService.isNiri)
|
||||
g = niriLayoutGapsOverride;
|
||||
else if (CompositorService.isHyprland)
|
||||
g = hyprlandLayoutGapsOverride;
|
||||
else if (CompositorService.isMango)
|
||||
g = mangoLayoutGapsOverride;
|
||||
if (g >= 0)
|
||||
return g;
|
||||
}
|
||||
return Math.max(0, notepadEdgeGap);
|
||||
}
|
||||
|
||||
onNotepadUseMonospaceChanged: saveSettings()
|
||||
onNotepadFontFamilyChanged: saveSettings()
|
||||
onNotepadFontSizeChanged: saveSettings()
|
||||
onNotepadShowLineNumbersChanged: saveSettings()
|
||||
onNotepadAutoSaveChanged: saveSettings()
|
||||
onNotepadSlideoutSideChanged: saveSettings()
|
||||
onNotepadDefaultModeChanged: saveSettings()
|
||||
onNotepadUseCompositorGapChanged: saveSettings()
|
||||
onNotepadEdgeGapChanged: saveSettings()
|
||||
// onCenteringModeChanged: saveSettings()
|
||||
onNotepadTransparencyOverrideChanged: {
|
||||
if (notepadTransparencyOverride > 0) {
|
||||
@@ -541,6 +583,7 @@ Singleton {
|
||||
property bool soundVolumeChanged: true
|
||||
property bool soundPluggedIn: true
|
||||
property bool soundLogin: false
|
||||
property bool muteSoundsWhenMediaPlaying: true
|
||||
|
||||
property int acMonitorTimeout: 0
|
||||
property int acLockTimeout: 0
|
||||
@@ -555,6 +598,13 @@ Singleton {
|
||||
property string batteryProfileName: ""
|
||||
property int batteryPostLockMonitorTimeout: 0
|
||||
property int batteryChargeLimit: 100
|
||||
property bool batteryNotifyChargeLimit: false
|
||||
property int batteryCriticalThreshold: 10
|
||||
property bool batteryNotifyCritical: true
|
||||
property int batteryLowThreshold: 20
|
||||
property bool batteryNotifyLow: false
|
||||
property int batteryNotificationType: 0
|
||||
property bool batteryAutoPowerSaver: false
|
||||
property bool lockBeforeSuspend: false
|
||||
property bool loginctlLockIntegration: true
|
||||
property bool fadeToLockEnabled: true
|
||||
|
||||
@@ -450,7 +450,9 @@ Singleton {
|
||||
"primaryText": getMatugenColor("on_primary", "#ffffff"),
|
||||
"primaryContainer": getMatugenColor("primary_container", "#1976d2"),
|
||||
"secondary": getMatugenColor("secondary", "#8ab4f8"),
|
||||
"secondaryContainer": getMatugenColor("secondary_container", getMatugenColor("surface_container_high", "#292b2f")),
|
||||
"tertiary": getMatugenColor("tertiary", "#efb8c8"),
|
||||
"tertiaryContainer": getMatugenColor("tertiary_container", getMatugenColor("surface_container_high", "#292b2f")),
|
||||
"surface": getMatugenColor("surface", "#1a1c1e"),
|
||||
"surfaceText": getMatugenColor("on_background", "#e3e8ef"),
|
||||
"surfaceVariant": getMatugenColor("surface_variant", "#44464f"),
|
||||
@@ -521,7 +523,6 @@ Singleton {
|
||||
|
||||
property color primary: currentThemeData.primary
|
||||
property color primaryText: currentThemeData.primaryText
|
||||
property color primaryContainer: currentThemeData.primaryContainer
|
||||
property color secondary: currentThemeData.secondary
|
||||
property color tertiary: currentThemeData.tertiary || currentThemeData.secondary
|
||||
property color surface: currentThemeData.surface
|
||||
@@ -536,6 +537,9 @@ Singleton {
|
||||
property color surfaceContainer: currentThemeData.surfaceContainer
|
||||
property color surfaceContainerHigh: currentThemeData.surfaceContainerHigh
|
||||
property color surfaceContainerHighest: currentThemeData.surfaceContainerHighest || surfaceContainerHigh
|
||||
property color primaryContainer: currentThemeData.primaryContainer || blend(surfaceContainerHigh, primary, 0.45)
|
||||
property color secondaryContainer: currentThemeData.secondaryContainer || blend(surfaceContainerHigh, secondary, 0.35)
|
||||
property color tertiaryContainer: currentThemeData.tertiaryContainer || blend(surfaceContainerHigh, tertiary, 0.35)
|
||||
|
||||
property color onSurface: surfaceText
|
||||
property color onSurfaceVariant: surfaceVariantText
|
||||
@@ -577,6 +581,45 @@ Singleton {
|
||||
readonly property int layerOutlineWidth: BlurService.enabled && layerOutlineOpacity > 0 ? 1 : 0
|
||||
property color surfaceTextHover: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.08)
|
||||
property color surfaceTextAlpha: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.3)
|
||||
|
||||
function roleColor(mode) {
|
||||
switch (mode) {
|
||||
case "primary":
|
||||
case "pri":
|
||||
return primary;
|
||||
case "primaryContainer":
|
||||
return primaryContainer;
|
||||
case "secondary":
|
||||
case "sec":
|
||||
return secondary;
|
||||
case "secondaryContainer":
|
||||
return secondaryContainer;
|
||||
case "tertiary":
|
||||
case "ter":
|
||||
return tertiary;
|
||||
case "tertiaryContainer":
|
||||
return tertiaryContainer;
|
||||
case "surfaceText":
|
||||
return surfaceText;
|
||||
case "surfaceVariant":
|
||||
return surfaceVariant;
|
||||
case "s":
|
||||
return surface;
|
||||
case "sc":
|
||||
return surfaceContainer;
|
||||
case "sch":
|
||||
return surfaceContainerHigh;
|
||||
case "schh":
|
||||
return surfaceContainerHighest;
|
||||
case "sth":
|
||||
return surfaceTextHover;
|
||||
case "error":
|
||||
case "err":
|
||||
return error;
|
||||
default:
|
||||
return "transparent";
|
||||
}
|
||||
}
|
||||
property color surfaceTextLight: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.06)
|
||||
property color surfaceTextMedium: Qt.rgba(surfaceText.r, surfaceText.g, surfaceText.b, 0.7)
|
||||
|
||||
@@ -1430,9 +1473,22 @@ Singleton {
|
||||
|
||||
property bool widgetBackgroundHasAlpha: {
|
||||
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
|
||||
return colorMode === "sth";
|
||||
return colorMode === "sth" || colorMode === "custom";
|
||||
}
|
||||
|
||||
function safeColor(value, fallback) {
|
||||
try {
|
||||
if (value === undefined || value === null || value === "")
|
||||
return fallback;
|
||||
return Qt.color(value);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color widgetBackgroundCustomBaseColor: safeColor(typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundCustomColor : "#6750A4", primaryContainer)
|
||||
readonly property real widgetBackgroundCustomStrength: Math.max(0, Math.min(1, typeof SettingsData !== "undefined" ? (SettingsData.widgetBackgroundCustomStrength ?? 0.4) : 0.4))
|
||||
|
||||
property var widgetBaseBackgroundColor: {
|
||||
const colorMode = typeof SettingsData !== "undefined" ? SettingsData.widgetBackgroundColor : "sch";
|
||||
switch (colorMode) {
|
||||
@@ -1442,6 +1498,14 @@ Singleton {
|
||||
return surfaceContainer;
|
||||
case "sch":
|
||||
return surfaceContainerHigh;
|
||||
case "primaryContainer":
|
||||
return primaryContainer;
|
||||
case "secondaryContainer":
|
||||
return secondaryContainer;
|
||||
case "tertiaryContainer":
|
||||
return tertiaryContainer;
|
||||
case "custom":
|
||||
return blend(surfaceContainerHigh, widgetBackgroundCustomBaseColor, widgetBackgroundCustomStrength);
|
||||
case "sth":
|
||||
default:
|
||||
return surfaceTextHover;
|
||||
|
||||
@@ -19,6 +19,8 @@ var SPEC = {
|
||||
dockTransparency: { def: 1.0, coerce: percentToUnit },
|
||||
|
||||
widgetBackgroundColor: { def: "sch" },
|
||||
widgetBackgroundCustomColor: { def: "#6750A4" },
|
||||
widgetBackgroundCustomStrength: { def: 0.50, coerce: percentToUnit },
|
||||
widgetColorMode: { def: "default" },
|
||||
controlCenterTileColorMode: { def: "primary" },
|
||||
buttonColorMode: { def: "primary" },
|
||||
@@ -37,6 +39,7 @@ var SPEC = {
|
||||
|
||||
firstDayOfWeek: { def: -1 },
|
||||
showWeekNumber: { def: false },
|
||||
calendarBackend: { def: "auto" },
|
||||
use24HourClock: { def: true },
|
||||
showSeconds: { def: false },
|
||||
padHours12Hour: { def: false },
|
||||
@@ -143,11 +146,16 @@ var SPEC = {
|
||||
dwlShowAllTags: { def: false },
|
||||
workspaceActiveAppHighlightEnabled: { def: false },
|
||||
workspaceColorMode: { def: "default" },
|
||||
workspaceFocusedCustomColor: { def: "#6750A4" },
|
||||
workspaceOccupiedColorMode: { def: "none" },
|
||||
workspaceOccupiedCustomColor: { def: "#625B71" },
|
||||
workspaceUnfocusedColorMode: { def: "default" },
|
||||
workspaceUnfocusedCustomColor: { def: "#49454E" },
|
||||
workspaceUrgentColorMode: { def: "default" },
|
||||
workspaceUrgentCustomColor: { def: "#B3261E" },
|
||||
workspaceFocusedBorderEnabled: { def: false },
|
||||
workspaceFocusedBorderColor: { def: "primary" },
|
||||
workspaceFocusedBorderCustomColor: { def: "#6750A4" },
|
||||
workspaceFocusedBorderThickness: { def: 2 },
|
||||
workspaceNameIcons: { def: {} },
|
||||
waveProgressEnabled: { def: true },
|
||||
@@ -156,6 +164,7 @@ var SPEC = {
|
||||
audioVisualizerEnabled: { def: true },
|
||||
audioScrollMode: { def: "volume" },
|
||||
audioWheelScrollAmount: { def: 5 },
|
||||
audioDeviceScrollVolumeEnabled: { def: false },
|
||||
clockCompactMode: { def: false },
|
||||
focusedWindowCompactMode: { def: false },
|
||||
focusedWindowSize: { def: 1 },
|
||||
@@ -163,6 +172,9 @@ var SPEC = {
|
||||
barMaxVisibleApps: { def: 0 },
|
||||
barMaxVisibleRunningApps: { def: 0 },
|
||||
barShowOverflowBadge: { def: true },
|
||||
trayAutoOverflow: { def: true },
|
||||
trayPopupSingleLine: { def: true },
|
||||
trayMaxVisibleItems: { def: 0 },
|
||||
appsDockHideIndicators: { def: false },
|
||||
appsDockColorizeActive: { def: false },
|
||||
appsDockActiveColorMode: { def: "primary" },
|
||||
@@ -225,6 +237,7 @@ var SPEC = {
|
||||
launcherUseOverlayLayer: { def: false },
|
||||
launcherStyle: { def: "full" },
|
||||
spotlightBarShowModeChips: { def: false },
|
||||
keybindsFloatingWindow: { def: false },
|
||||
|
||||
useAutoLocation: { def: false },
|
||||
weatherEnabled: { def: true },
|
||||
@@ -263,8 +276,13 @@ var SPEC = {
|
||||
notificationSummaryFontSize: { def: 0 },
|
||||
notificationBodyFontSize: { def: 0 },
|
||||
notepadShowLineNumbers: { def: false },
|
||||
notepadAutoSave: { def: false },
|
||||
notepadSlideoutSide: { def: "right" },
|
||||
notepadDefaultMode: { def: "slideout" },
|
||||
notepadTransparencyOverride: { def: -1 },
|
||||
notepadLastCustomTransparency: { def: 0.7 },
|
||||
notepadUseCompositorGap: { def: false },
|
||||
notepadEdgeGap: { def: 0 },
|
||||
|
||||
soundsEnabled: { def: true },
|
||||
useSystemSoundTheme: { def: false },
|
||||
@@ -272,6 +290,7 @@ var SPEC = {
|
||||
soundNewNotification: { def: true },
|
||||
soundVolumeChanged: { def: true },
|
||||
soundPluggedIn: { def: true },
|
||||
muteSoundsWhenMediaPlaying: { def: true },
|
||||
|
||||
acMonitorTimeout: { def: 0 },
|
||||
acLockTimeout: { def: 0 },
|
||||
@@ -286,6 +305,13 @@ var SPEC = {
|
||||
batteryProfileName: { def: "" },
|
||||
batteryPostLockMonitorTimeout: { def: 0 },
|
||||
batteryChargeLimit: { def: 100 },
|
||||
batteryNotifyChargeLimit: { def: false },
|
||||
batteryCriticalThreshold: { def: 10 },
|
||||
batteryNotifyCritical: { def: true },
|
||||
batteryLowThreshold: { def: 20 },
|
||||
batteryNotifyLow: { def: false },
|
||||
batteryNotificationType: { def: 0 },
|
||||
batteryAutoPowerSaver: { def: false },
|
||||
lockBeforeSuspend: { def: false },
|
||||
loginctlLockIntegration: { def: true },
|
||||
fadeToLockEnabled: { def: true },
|
||||
@@ -572,6 +598,8 @@ var SPEC = {
|
||||
|
||||
builtInPluginSettings: { def: {} },
|
||||
clipboardEnterToPaste: { def: false },
|
||||
clipboardRememberTypeFilter: { def: false },
|
||||
clipboardTypeFilter: { def: "all" },
|
||||
clipboardVisibleEntryActions: { def: ["pin", "edit", "delete"] },
|
||||
|
||||
launcherPluginVisibility: { def: {} },
|
||||
|
||||
+94
-3
@@ -116,6 +116,12 @@ Item {
|
||||
fadeWindowLoader.item.cancelFade();
|
||||
}
|
||||
}
|
||||
|
||||
function onDismissFadeToLock() {
|
||||
if (fadeWindowLoader.item) {
|
||||
fadeWindowLoader.item.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +323,9 @@ Item {
|
||||
|
||||
property bool hadRealScreen: true
|
||||
property var previousRealScreenNames: []
|
||||
// Guards for the screen-reconnect recovery path (see scheduleScreenReconnectRecovery).
|
||||
property bool _screenRecoveryCooldown: false
|
||||
property bool _screenRecoveryPending: false
|
||||
|
||||
function _getRealScreenNames() {
|
||||
const names = [];
|
||||
@@ -359,15 +368,60 @@ Item {
|
||||
const partialReconnect = root.previousRealScreenNames.length > 0
|
||||
&& currentNames.some(name => !root.previousRealScreenNames.includes(name));
|
||||
if (fullReconnect || partialReconnect) {
|
||||
log.info("Screen reconnect detected, triggering surface recovery",
|
||||
log.info("Screen reconnect detected, scheduling surface recovery",
|
||||
"full:", fullReconnect, "partial:", partialReconnect);
|
||||
root.triggerSurfaceRecovery("screen-reconnect");
|
||||
root.scheduleScreenReconnectRecovery();
|
||||
}
|
||||
root.hadRealScreen = hasReal;
|
||||
root.previousRealScreenNames = currentNames;
|
||||
}
|
||||
}
|
||||
|
||||
// A DPMS off/on cycle removes an output from the screen list and re-adds it,
|
||||
// which is indistinguishable here from a hotplug. Recovering immediately on
|
||||
// every such event lets a flapping monitor (or a recovery that itself perturbs
|
||||
// the output) drive an endless recovery storm that power-cycles the display
|
||||
// (#2642). Debounce a burst of changes into a single pass, then hold a cooldown
|
||||
// so repeated flaps trigger at most one recovery per window. Recovery still runs
|
||||
// once per resume, so a partial DPMS resume keeps redrawing its surfaces (#2579).
|
||||
function scheduleScreenReconnectRecovery() {
|
||||
if (root._screenRecoveryCooldown) {
|
||||
root._screenRecoveryPending = true;
|
||||
return;
|
||||
}
|
||||
screenReconnectDebounce.restart();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: screenReconnectDebounce
|
||||
// Wide enough to collapse the output-remove + output-re-add pair that one
|
||||
// DPMS off/on cycle emits as two near-simultaneous events into one recovery.
|
||||
interval: 450
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root._screenRecoveryCooldown = true;
|
||||
root._screenRecoveryPending = false;
|
||||
screenReconnectCooldown.restart();
|
||||
root.triggerSurfaceRecovery("screen-reconnect");
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: screenReconnectCooldown
|
||||
// Must exceed the full two-pass surfaceResumeRecoveryTimer sequence
|
||||
// (800 + 2000 ms) so the cooldown still covers an in-flight recovery;
|
||||
// raise this if those passes are lengthened.
|
||||
interval: 4000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root._screenRecoveryCooldown = false;
|
||||
if (root._screenRecoveryPending) {
|
||||
root._screenRecoveryPending = false;
|
||||
screenReconnectDebounce.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: surfaceResumeRecoveryTimer
|
||||
interval: 800
|
||||
@@ -653,7 +707,7 @@ Item {
|
||||
if (!wifiPasswordModalLoader.item)
|
||||
return;
|
||||
|
||||
if (wifiPasswordModalLoader.item.visible && timeSinceLastPrompt < 1000) {
|
||||
if (wifiPasswordModalLoader.item.shouldBeVisible && timeSinceLastPrompt < 1000) {
|
||||
NetworkService.cancelCredentials(lastCredentialsToken);
|
||||
lastCredentialsToken = token;
|
||||
lastCredentialsTime = now;
|
||||
@@ -997,6 +1051,14 @@ Item {
|
||||
osdResumeRecreateTimer.interval = 400;
|
||||
osdResumeRecreateTimer.restart();
|
||||
|
||||
// This path runs its own recovery directly, so drop any queued or
|
||||
// in-flight screen-reconnect recovery to avoid a redundant pass once
|
||||
// its cooldown expires.
|
||||
screenReconnectDebounce.stop();
|
||||
screenReconnectCooldown.stop();
|
||||
root._screenRecoveryCooldown = false;
|
||||
root._screenRecoveryPending = false;
|
||||
|
||||
root.triggerSurfaceRecovery("sessionResumed");
|
||||
}
|
||||
}
|
||||
@@ -1093,11 +1155,22 @@ Item {
|
||||
slideoutWidth: 480
|
||||
expandable: true
|
||||
expandedWidthValue: 960
|
||||
edgeGap: SettingsData.notepadEffectiveEdgeGap
|
||||
slideEdge: SettingsData.notepadSlideoutSide
|
||||
|
||||
onIsVisibleChanged: {
|
||||
if (isVisible)
|
||||
PopoutService.notepadPopout?.hide();
|
||||
}
|
||||
|
||||
content: Component {
|
||||
Notepad {
|
||||
slideout: notepadSlideout
|
||||
onHideRequested: notepadSlideout.hide()
|
||||
onPopoutRequested: {
|
||||
notepadSlideout.hide();
|
||||
PopoutService.openNotepadPopout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1114,6 +1187,24 @@ Item {
|
||||
Component.onCompleted: PopoutService.notepadSlideouts = instances
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
id: notepadPopoutLoader
|
||||
active: false
|
||||
|
||||
Component.onCompleted: {
|
||||
PopoutService.notepadPopoutLoader = notepadPopoutLoader;
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item) {
|
||||
PopoutService.notepadPopout = item;
|
||||
PopoutService._onNotepadPopoutLoaded();
|
||||
}
|
||||
}
|
||||
|
||||
NotepadPopoutWindow {}
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
id: powerMenuModalLoader
|
||||
|
||||
|
||||
@@ -373,6 +373,10 @@ Item {
|
||||
}
|
||||
|
||||
function open(): string {
|
||||
if (SettingsData.notepadDefaultMode === "popout") {
|
||||
PopoutService.openNotepadPopout();
|
||||
return "NOTEPAD_OPEN_SUCCESS";
|
||||
}
|
||||
var instance = getActiveNotepadInstance();
|
||||
if (instance) {
|
||||
instance.show();
|
||||
@@ -382,6 +386,10 @@ Item {
|
||||
}
|
||||
|
||||
function close(): string {
|
||||
if (SettingsData.notepadDefaultMode === "popout") {
|
||||
PopoutService.notepadPopout?.hide();
|
||||
return "NOTEPAD_CLOSE_SUCCESS";
|
||||
}
|
||||
var instance = getActiveNotepadInstance();
|
||||
if (instance) {
|
||||
instance.hide();
|
||||
@@ -391,6 +399,10 @@ Item {
|
||||
}
|
||||
|
||||
function toggle(): string {
|
||||
if (SettingsData.notepadDefaultMode === "popout") {
|
||||
PopoutService.toggleNotepadPopout();
|
||||
return "NOTEPAD_TOGGLE_SUCCESS";
|
||||
}
|
||||
var instance = getActiveNotepadInstance();
|
||||
if (instance) {
|
||||
instance.toggle();
|
||||
@@ -944,7 +956,7 @@ Item {
|
||||
|
||||
function tabs(): string {
|
||||
if (!PopoutService.settingsModal)
|
||||
return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_appearance\ndankbar_widgets\nframe\nworkspaces\ncompositor\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nprinters\nlock_screen\npower_sleep\nplugins\nabout";
|
||||
return "wallpaper\ntheme\ntypography\ntime_weather\nsounds\ndankbar\ndankbar_settings\ndankbar_appearance\ndankbar_widgets\nframe\nworkspaces\ncompositor\nmedia_player\nnotifications\nosd\nrunning_apps\nupdater\ndock\nlauncher\nkeybinds\ndisplays\nnetwork\nnetwork_status\nnetwork_ethernet\nnetwork_wifi\nnetwork_vpn\nprinters\nlock_screen\npower_sleep\nplugins\nabout";
|
||||
var modal = PopoutService.settingsModal;
|
||||
var ids = [];
|
||||
var structure = modal.sidebar?.categoryStructure ?? [];
|
||||
|
||||
@@ -11,6 +11,14 @@ Item {
|
||||
property alias searchField: searchField
|
||||
property alias clipboardListView: clipboardListView
|
||||
|
||||
readonly property var filterOptions: [I18n.tr("All"), I18n.tr("Text"), I18n.tr("Long Text"), I18n.tr("Image")]
|
||||
readonly property var filterValues: ["all", "text", "long_text", "image"]
|
||||
|
||||
function closeFilterMenu() {
|
||||
filterMenuLoader.active = false;
|
||||
filterMenuLoader.active = true;
|
||||
}
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
Column {
|
||||
@@ -36,27 +44,81 @@ Item {
|
||||
onCloseClicked: modal.hide()
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: searchField
|
||||
Item {
|
||||
id: searchRow
|
||||
width: parent.width
|
||||
placeholderText: ""
|
||||
leftIconName: "search"
|
||||
showClearButton: true
|
||||
focus: true
|
||||
ignoreTabKeys: true
|
||||
keyForwardTargets: [modal.modalFocusScope]
|
||||
onTextChanged: {
|
||||
modal.searchText = text;
|
||||
modal.updateFilteredModel();
|
||||
implicitHeight: searchField.height
|
||||
|
||||
DankTextField {
|
||||
id: searchField
|
||||
|
||||
width: parent.width
|
||||
rightAccessoryWidth: filterButton.width + Theme.spacingS
|
||||
placeholderText: ""
|
||||
leftIconName: "search"
|
||||
showClearButton: true
|
||||
focus: true
|
||||
ignoreTabKeys: true
|
||||
keyForwardTargets: [modal.modalFocusScope]
|
||||
|
||||
onTextChanged: {
|
||||
modal.searchText = text;
|
||||
modal.updateFilteredModel();
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: function (event) {
|
||||
modal.hide();
|
||||
event.accepted = true;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
Qt.callLater(function () {
|
||||
forceActiveFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
Keys.onEscapePressed: function (event) {
|
||||
modal.hide();
|
||||
event.accepted = true;
|
||||
|
||||
DankActionButton {
|
||||
id: filterButton
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "filter_list"
|
||||
iconColor: modal.activeFilter !== "all" ? Theme.primary : Theme.surfaceText
|
||||
backgroundColor: modal.activeFilter !== "all" ? Theme.primarySelected : "transparent"
|
||||
tooltipText: I18n.tr("Filter by type", "Clipboard history type filter button tooltip")
|
||||
onClicked: filterMenuLoader.item?.openDropdownMenu()
|
||||
}
|
||||
Component.onCompleted: {
|
||||
Qt.callLater(function () {
|
||||
forceActiveFocus();
|
||||
});
|
||||
|
||||
Loader {
|
||||
id: filterMenuLoader
|
||||
|
||||
active: true
|
||||
sourceComponent: filterMenuComponent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: filterMenuComponent
|
||||
|
||||
DankDropdown {
|
||||
showTrigger: false
|
||||
popupAnchorItem: filterButton
|
||||
popupWidth: 180
|
||||
alignPopupRight: true
|
||||
options: clipboardContent.filterOptions
|
||||
currentValue: {
|
||||
const idx = clipboardContent.filterValues.indexOf(clipboardContent.modal.activeFilter);
|
||||
return idx >= 0 ? clipboardContent.filterOptions[idx] : clipboardContent.filterOptions[0];
|
||||
}
|
||||
|
||||
onValueChanged: value => {
|
||||
const idx = clipboardContent.filterOptions.indexOf(value);
|
||||
if (idx >= 0) {
|
||||
clipboardContent.modal.activeFilter = clipboardContent.filterValues[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ Item {
|
||||
font.weight: Font.Medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Row {
|
||||
|
||||
@@ -16,6 +16,7 @@ FocusScope {
|
||||
|
||||
property string mode: "history"
|
||||
property string searchText: ClipboardService.searchText
|
||||
property string activeFilter: SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all"
|
||||
|
||||
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
|
||||
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
|
||||
@@ -50,6 +51,16 @@ FocusScope {
|
||||
}
|
||||
onSearchTextChanged: ClipboardService.searchText = searchText
|
||||
|
||||
onActiveFilterChanged: {
|
||||
ClipboardService.activeFilter = activeFilter;
|
||||
ClipboardService.selectedIndex = 0;
|
||||
ClipboardService.keyboardNavigationActive = false;
|
||||
ClipboardService.updateFilteredModel();
|
||||
if (SettingsData.clipboardRememberTypeFilter) {
|
||||
SettingsData.set("clipboardTypeFilter", activeFilter);
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
closeRequested();
|
||||
}
|
||||
@@ -118,6 +129,8 @@ FocusScope {
|
||||
function resetState() {
|
||||
activeImageLoads = 0;
|
||||
mode = "history";
|
||||
historyContent.closeFilterMenu();
|
||||
activeFilter = SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all";
|
||||
ClipboardService.reset();
|
||||
keyboardController.reset();
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ FocusScope {
|
||||
width: buttonContent.width + Theme.spacingM * 2
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: controller.searchMode === modelData.id || modeArea.containsMouse ? Theme.primaryContainer : "transparent"
|
||||
color: controller.searchMode === modelData.id ? Theme.buttonBg : modeArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
|
||||
|
||||
Row {
|
||||
id: buttonContent
|
||||
@@ -374,14 +374,14 @@ FocusScope {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: modelData.icon
|
||||
size: 14
|
||||
color: controller.searchMode === modelData.id ? Theme.primary : Theme.surfaceVariantText
|
||||
color: controller.searchMode === modelData.id ? Theme.buttonText : Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.label
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: controller.searchMode === modelData.id ? Theme.primary : Theme.surfaceText
|
||||
color: controller.searchMode === modelData.id ? Theme.buttonText : Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,7 +636,7 @@ FocusScope {
|
||||
width: chipContent.width + Theme.spacingM * 2
|
||||
height: sortDropdown.height
|
||||
radius: Theme.cornerRadius
|
||||
color: controller.fileSearchType === modelData.id || chipArea.containsMouse ? Theme.primaryContainer : "transparent"
|
||||
color: controller.fileSearchType === modelData.id ? Theme.buttonBg : chipArea.containsMouse ? Theme.surfaceContainerHighest : "transparent"
|
||||
|
||||
Row {
|
||||
id: chipContent
|
||||
@@ -647,14 +647,14 @@ FocusScope {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: modelData.icon
|
||||
size: 14
|
||||
color: controller.fileSearchType === modelData.id ? Theme.primary : Theme.surfaceVariantText
|
||||
color: controller.fileSearchType === modelData.id ? Theme.buttonText : Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.label
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: controller.fileSearchType === modelData.id ? Theme.primary : Theme.surfaceText
|
||||
color: controller.fileSearchType === modelData.id ? Theme.buttonText : Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,21 @@ FocusScope {
|
||||
keyboardSelectionRequested = true;
|
||||
}
|
||||
|
||||
function activateFile(path, name, isDir) {
|
||||
if (isDir) {
|
||||
navigateTo(path);
|
||||
return;
|
||||
}
|
||||
if (saveMode) {
|
||||
saveRow.fileName = name;
|
||||
pendingFilePath = path;
|
||||
showOverwriteConfirmation = true;
|
||||
} else {
|
||||
fileSelected(path);
|
||||
closeRequested();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveFile(filePath) {
|
||||
var normalizedPath = filePath;
|
||||
if (!normalizedPath.startsWith("file://")) {
|
||||
@@ -652,6 +667,7 @@ FocusScope {
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.bottomMargin: root.saveMode ? 40 + Theme.spacingL * 2 : 0
|
||||
spacing: 0
|
||||
|
||||
Row {
|
||||
@@ -756,12 +772,7 @@ FocusScope {
|
||||
onItemClicked: (index, path, name, isDir) => {
|
||||
selectedIndex = index;
|
||||
setSelectedFileData(path, name, isDir);
|
||||
if (isDir) {
|
||||
navigateTo(path);
|
||||
} else {
|
||||
fileSelected(path);
|
||||
root.closeRequested();
|
||||
}
|
||||
root.activateFile(path, name, isDir);
|
||||
}
|
||||
onItemSelected: (index, path, name, isDir) => {
|
||||
setSelectedFileData(path, name, isDir);
|
||||
@@ -776,12 +787,7 @@ FocusScope {
|
||||
root.keyboardSelectionRequested = false;
|
||||
selectedIndex = index;
|
||||
setSelectedFileData(filePath, fileName, fileIsDir);
|
||||
if (fileIsDir) {
|
||||
navigateTo(filePath);
|
||||
} else {
|
||||
fileSelected(filePath);
|
||||
root.closeRequested();
|
||||
}
|
||||
root.activateFile(filePath, fileName, fileIsDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -817,12 +823,7 @@ FocusScope {
|
||||
onItemClicked: (index, path, name, isDir) => {
|
||||
selectedIndex = index;
|
||||
setSelectedFileData(path, name, isDir);
|
||||
if (isDir) {
|
||||
navigateTo(path);
|
||||
} else {
|
||||
fileSelected(path);
|
||||
root.closeRequested();
|
||||
}
|
||||
root.activateFile(path, name, isDir);
|
||||
}
|
||||
onItemSelected: (index, path, name, isDir) => {
|
||||
setSelectedFileData(path, name, isDir);
|
||||
@@ -837,12 +838,7 @@ FocusScope {
|
||||
root.keyboardSelectionRequested = false;
|
||||
selectedIndex = index;
|
||||
setSelectedFileData(filePath, fileName, fileIsDir);
|
||||
if (fileIsDir) {
|
||||
navigateTo(filePath);
|
||||
} else {
|
||||
fileSelected(filePath);
|
||||
root.closeRequested();
|
||||
}
|
||||
root.activateFile(filePath, fileName, fileIsDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,6 +851,7 @@ FocusScope {
|
||||
}
|
||||
|
||||
FileBrowserSaveRow {
|
||||
id: saveRow
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
@@ -913,21 +910,21 @@ FocusScope {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserOverwriteDialog {
|
||||
anchors.fill: parent
|
||||
showDialog: showOverwriteConfirmation
|
||||
pendingFilePath: root.pendingFilePath
|
||||
onConfirmed: filePath => {
|
||||
showOverwriteConfirmation = false;
|
||||
fileSelected(filePath);
|
||||
pendingFilePath = "";
|
||||
Qt.callLater(() => root.closeRequested());
|
||||
}
|
||||
onCancelled: {
|
||||
showOverwriteConfirmation = false;
|
||||
pendingFilePath = "";
|
||||
}
|
||||
FileBrowserOverwriteDialog {
|
||||
anchors.fill: parent
|
||||
showDialog: showOverwriteConfirmation
|
||||
pendingFilePath: root.pendingFilePath
|
||||
onConfirmed: filePath => {
|
||||
showOverwriteConfirmation = false;
|
||||
fileSelected(filePath);
|
||||
pendingFilePath = "";
|
||||
Qt.callLater(() => root.closeRequested());
|
||||
}
|
||||
onCancelled: {
|
||||
showOverwriteConfirmation = false;
|
||||
pendingFilePath = "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ Item {
|
||||
width: 80
|
||||
height: 36
|
||||
radius: Theme.cornerRadius
|
||||
color: cancelArea.containsMouse ? Theme.surfaceVariantHover : Theme.surfaceVariant
|
||||
color: cancelArea.containsMouse ? Qt.lighter(Theme.surfaceVariant, 1.2) : Theme.surfaceVariant
|
||||
border.color: Theme.outline
|
||||
border.width: 1
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ Row {
|
||||
property bool saveMode: false
|
||||
property string defaultFileName: ""
|
||||
property string currentPath: ""
|
||||
property alias fileName: fileNameInput.text
|
||||
|
||||
signal saveRequested(string filePath)
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import QtQml
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FocusScope {
|
||||
id: content
|
||||
|
||||
property real scrollStep: 60
|
||||
property var activeFlickable: mainFlickable
|
||||
property bool showFloatingToggle: true
|
||||
property bool floating: false
|
||||
property alias searchField: searchField
|
||||
|
||||
signal closeRequested
|
||||
signal floatingToggleRequested
|
||||
|
||||
function scrollDown() {
|
||||
if (!activeFlickable)
|
||||
return;
|
||||
let newY = activeFlickable.contentY + scrollStep;
|
||||
newY = Math.min(newY, activeFlickable.contentHeight - activeFlickable.height);
|
||||
activeFlickable.contentY = newY;
|
||||
}
|
||||
|
||||
function scrollUp() {
|
||||
if (!activeFlickable)
|
||||
return;
|
||||
let newY = activeFlickable.contentY - scrollStep;
|
||||
newY = Math.max(0, newY);
|
||||
activeFlickable.contentY = newY;
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
switch (event.key) {
|
||||
case Qt.Key_J:
|
||||
if (event.modifiers & Qt.ControlModifier) {
|
||||
scrollDown();
|
||||
event.accepted = true;
|
||||
}
|
||||
return;
|
||||
case Qt.Key_K:
|
||||
if (event.modifiers & Qt.ControlModifier) {
|
||||
scrollUp();
|
||||
event.accepted = true;
|
||||
}
|
||||
return;
|
||||
case Qt.Key_Down:
|
||||
scrollDown();
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_Up:
|
||||
scrollUp();
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingL
|
||||
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Bold
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
visible: content.showFloatingToggle
|
||||
iconName: content.floating ? "close_fullscreen" : "open_in_new"
|
||||
tooltipText: content.floating ? I18n.tr("Dock window") : I18n.tr("Open as window")
|
||||
onClicked: content.floatingToggleRequested()
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: searchField
|
||||
Layout.alignment: Qt.AlignRight
|
||||
leftIconName: "search"
|
||||
keyForwardTargets: [content]
|
||||
onTextEdited: searchDebounce.restart()
|
||||
Keys.onEscapePressed: event => {
|
||||
content.closeRequested();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: searchDebounce
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
mainFlickable.categories = mainFlickable.generateCategories(searchField.text);
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
id: mainFlickable
|
||||
width: parent.width
|
||||
height: parent.height - parent.spacing - 40
|
||||
contentWidth: rowLayout.implicitWidth
|
||||
contentHeight: rowLayout.implicitHeight
|
||||
clip: true
|
||||
|
||||
property var rawBinds: KeybindsService.cheatsheet.binds || {}
|
||||
|
||||
function generateCategories(query) {
|
||||
const lowerQuery = query ? query.toLowerCase().trim() : "";
|
||||
const lowerQueryWords = query.split(/\s+/);
|
||||
const processed = {};
|
||||
|
||||
for (const cat in rawBinds) {
|
||||
const binds = rawBinds[cat];
|
||||
const catLower = cat.toLowerCase();
|
||||
const subcats = {};
|
||||
let hasSubcats = false;
|
||||
for (let i = 0; i < binds.length; i++) {
|
||||
const bind = binds[i];
|
||||
const keyLower = (bind.key || "").toLowerCase();
|
||||
const descLower = (bind.desc || "").toLowerCase();
|
||||
const actionLower = (bind.action || "").toLowerCase();
|
||||
|
||||
if (bind.hideOnOverlay)
|
||||
continue;
|
||||
let shouldContinue = false;
|
||||
for (let j = 0; j < lowerQueryWords.length; j++) {
|
||||
const word = lowerQueryWords[j];
|
||||
if (!(word.length === 0 || keyLower.includes(word) || descLower.includes(word) || catLower.includes(word) || actionLower.includes(word))) {
|
||||
shouldContinue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shouldContinue)
|
||||
continue;
|
||||
|
||||
if (bind.subcat) {
|
||||
hasSubcats = true;
|
||||
if (!subcats[bind.subcat])
|
||||
subcats[bind.subcat] = [];
|
||||
subcats[bind.subcat].push(bind);
|
||||
} else {
|
||||
if (!subcats["_root"])
|
||||
subcats["_root"] = [];
|
||||
subcats["_root"].push(bind);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(subcats).length === 0)
|
||||
continue;
|
||||
|
||||
processed[cat] = {
|
||||
hasSubcats: hasSubcats,
|
||||
subcats: subcats,
|
||||
subcatKeys: Object.keys(subcats)
|
||||
};
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
property var categories: generateCategories("")
|
||||
|
||||
function estimateCategoryHeight(catName) {
|
||||
const catData = categories[catName];
|
||||
if (!catData)
|
||||
return 0;
|
||||
let bindCount = 0;
|
||||
for (const key of catData.subcatKeys) {
|
||||
bindCount += catData.subcats[key]?.length || 0;
|
||||
if (key !== "_root")
|
||||
bindCount += 1;
|
||||
}
|
||||
return 40 + bindCount * 28;
|
||||
}
|
||||
|
||||
property var categoryKeys: Object.keys(categories)
|
||||
|
||||
function distributeCategories(cols) {
|
||||
const columns = [];
|
||||
const heights = [];
|
||||
for (let i = 0; i < cols; i++) {
|
||||
columns.push([]);
|
||||
heights.push(0);
|
||||
}
|
||||
const sorted = [...categoryKeys].sort((a, b) => estimateCategoryHeight(b) - estimateCategoryHeight(a));
|
||||
for (const cat of sorted) {
|
||||
let minIdx = 0;
|
||||
for (let i = 1; i < cols; i++) {
|
||||
if (heights[i] < heights[minIdx])
|
||||
minIdx = i;
|
||||
}
|
||||
columns[minIdx].push(cat);
|
||||
heights[minIdx] += estimateCategoryHeight(cat);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
Row {
|
||||
id: rowLayout
|
||||
width: mainFlickable.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
property int numColumns: Math.max(1, Math.min(3, Math.floor(width / 350)))
|
||||
property var columnCategories: mainFlickable.distributeCategories(numColumns)
|
||||
|
||||
Repeater {
|
||||
model: rowLayout.numColumns
|
||||
|
||||
Column {
|
||||
id: masonryColumn
|
||||
width: (rowLayout.width - rowLayout.spacing * (rowLayout.numColumns - 1)) / rowLayout.numColumns
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
Repeater {
|
||||
model: rowLayout.columnCategories[index] || []
|
||||
|
||||
Column {
|
||||
id: categoryColumn
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
property string catName: modelData
|
||||
property var catData: mainFlickable.categories[catName]
|
||||
|
||||
StyledText {
|
||||
text: categoryColumn.catName
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Bold
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.primary
|
||||
opacity: 0.3
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 1
|
||||
height: Theme.spacingXS
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Repeater {
|
||||
model: categoryColumn.catData?.subcatKeys || []
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
property string subcatName: modelData
|
||||
property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || []
|
||||
|
||||
StyledText {
|
||||
visible: parent.subcatName !== "_root"
|
||||
text: parent.subcatName
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.primary
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
Repeater {
|
||||
model: parent.parent.subcatBinds
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 24
|
||||
|
||||
StyledRect {
|
||||
id: keyBadge
|
||||
width: Math.min(keyText.implicitWidth + 12, 160)
|
||||
height: 22
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
StyledText {
|
||||
id: keyText
|
||||
anchors.centerIn: parent
|
||||
color: Theme.secondary
|
||||
text: (modelData.key || "").replace(/\+/g, " + ")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
isMonospace: true
|
||||
elide: Text.ElideRight
|
||||
width: Math.min(implicitWidth, 148)
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 170
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.desc || modelData.action || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
opacity: 0.9
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,334 +1,78 @@
|
||||
import QtQml
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Common
|
||||
import qs.Modals.Common
|
||||
import qs.Modals
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
DankModal {
|
||||
Item {
|
||||
id: root
|
||||
|
||||
layerNamespace: "dms:keybinds"
|
||||
useOverlayLayer: true
|
||||
property real scrollStep: 60
|
||||
property var activeFlickable: null
|
||||
property real _maxW: Math.min(root.screenWidth * 0.92, 1200)
|
||||
property real _maxH: Math.min(root.screenHeight * 0.92, 900)
|
||||
modalWidth: _maxW
|
||||
modalHeight: _maxH
|
||||
onBackgroundClicked: close()
|
||||
onOpened: {
|
||||
Qt.callLater(() => {
|
||||
modalFocusScope.forceActiveFocus();
|
||||
if (contentLoader.item?.searchField)
|
||||
contentLoader.item.searchField.forceActiveFocus();
|
||||
});
|
||||
if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable)
|
||||
KeybindsService.loadCheatsheet();
|
||||
}
|
||||
readonly property bool floating: SettingsData.keybindsFloatingWindow
|
||||
readonly property bool shouldBeVisible: floating ? (windowLoader.item ? windowLoader.item.visible : false) : (overlayLoader.item ? overlayLoader.item.shouldBeVisible : false)
|
||||
|
||||
function scrollDown() {
|
||||
if (!root.activeFlickable)
|
||||
function open() {
|
||||
if (floating) {
|
||||
windowLoader.active = true;
|
||||
windowLoader.item.show();
|
||||
return;
|
||||
let newY = root.activeFlickable.contentY + scrollStep;
|
||||
newY = Math.min(newY, root.activeFlickable.contentHeight - root.activeFlickable.height);
|
||||
root.activeFlickable.contentY = newY;
|
||||
}
|
||||
overlayLoader.active = true;
|
||||
overlayLoader.item.open();
|
||||
}
|
||||
|
||||
function scrollUp() {
|
||||
if (!root.activeFlickable)
|
||||
function close() {
|
||||
if (windowLoader.item)
|
||||
windowLoader.item.hide();
|
||||
if (overlayLoader.item)
|
||||
overlayLoader.item.close();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (shouldBeVisible)
|
||||
close();
|
||||
else
|
||||
open();
|
||||
}
|
||||
|
||||
function _switchFloating(toFloating) {
|
||||
if (toFloating) {
|
||||
if (overlayLoader.item)
|
||||
overlayLoader.item.close();
|
||||
SettingsData.keybindsFloatingWindow = true;
|
||||
windowLoader.active = true;
|
||||
windowLoader.item.show();
|
||||
return;
|
||||
let newY = root.activeFlickable.contentY - root.scrollStep;
|
||||
newY = Math.max(0, newY);
|
||||
root.activeFlickable.contentY = newY;
|
||||
}
|
||||
if (windowLoader.item)
|
||||
windowLoader.item.hide();
|
||||
SettingsData.keybindsFloatingWindow = false;
|
||||
overlayLoader.active = true;
|
||||
overlayLoader.item.open();
|
||||
}
|
||||
|
||||
modalFocusScope.Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) {
|
||||
scrollDown();
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) {
|
||||
scrollUp();
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Down) {
|
||||
scrollDown();
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Up) {
|
||||
scrollUp();
|
||||
event.accepted = true;
|
||||
Loader {
|
||||
id: overlayLoader
|
||||
active: false
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: KeybindsModalOverlay {
|
||||
onFloatingToggleRequested: root._switchFloating(true)
|
||||
onDialogClosed: Qt.callLater(() => {
|
||||
if (!shouldBeVisible)
|
||||
overlayLoader.active = false;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
content: Component {
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
property alias searchField: searchField
|
||||
Loader {
|
||||
id: windowLoader
|
||||
active: false
|
||||
asynchronous: false
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingL
|
||||
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
|
||||
StyledText {
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Bold
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: searchField
|
||||
Layout.alignment: Qt.AlignRight
|
||||
leftIconName: "search"
|
||||
keyForwardTargets: [root.modalFocusScope]
|
||||
onTextEdited: searchDebounce.restart()
|
||||
Keys.onEscapePressed: event => {
|
||||
root.close();
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: searchDebounce
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
mainFlickable.categories = mainFlickable.generateCategories(searchField.text);
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
id: mainFlickable
|
||||
width: parent.width
|
||||
height: parent.height - parent.spacing - 40
|
||||
contentWidth: rowLayout.implicitWidth
|
||||
contentHeight: rowLayout.implicitHeight
|
||||
clip: true
|
||||
|
||||
Component.onCompleted: root.activeFlickable = mainFlickable
|
||||
|
||||
property var rawBinds: KeybindsService.cheatsheet.binds || {}
|
||||
|
||||
function generateCategories(query) {
|
||||
const lowerQuery = query ? query.toLowerCase().trim() : "";
|
||||
const lowerQueryWords = query.split(/\s+/);
|
||||
const processed = {};
|
||||
|
||||
for (const cat in rawBinds) {
|
||||
const binds = rawBinds[cat];
|
||||
const catLower = cat.toLowerCase();
|
||||
const subcats = {};
|
||||
let hasSubcats = false;
|
||||
for (let i = 0; i < binds.length; i++) {
|
||||
const bind = binds[i];
|
||||
const keyLower = (bind.key || "").toLowerCase();
|
||||
const descLower = (bind.desc || "").toLowerCase();
|
||||
const actionLower = (bind.action || "").toLowerCase();
|
||||
|
||||
if (bind.hideOnOverlay)
|
||||
continue;
|
||||
let shouldContinue = false;
|
||||
for (let j = 0; j < lowerQueryWords.length; j++) {
|
||||
const word = lowerQueryWords[j];
|
||||
if (!(word.length === 0 || keyLower.includes(word) || descLower.includes(word) || catLower.includes(word) || actionLower.includes(word))) {
|
||||
shouldContinue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shouldContinue)
|
||||
continue;
|
||||
|
||||
if (bind.subcat) {
|
||||
hasSubcats = true;
|
||||
if (!subcats[bind.subcat])
|
||||
subcats[bind.subcat] = [];
|
||||
subcats[bind.subcat].push(bind);
|
||||
} else {
|
||||
if (!subcats["_root"])
|
||||
subcats["_root"] = [];
|
||||
subcats["_root"].push(bind);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(subcats).length === 0)
|
||||
continue;
|
||||
|
||||
processed[cat] = {
|
||||
hasSubcats: hasSubcats,
|
||||
subcats: subcats,
|
||||
subcatKeys: Object.keys(subcats)
|
||||
};
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
property var categories: generateCategories("")
|
||||
|
||||
function estimateCategoryHeight(catName) {
|
||||
const catData = categories[catName];
|
||||
if (!catData)
|
||||
return 0;
|
||||
let bindCount = 0;
|
||||
for (const key of catData.subcatKeys) {
|
||||
bindCount += catData.subcats[key]?.length || 0;
|
||||
if (key !== "_root")
|
||||
bindCount += 1;
|
||||
}
|
||||
return 40 + bindCount * 28;
|
||||
}
|
||||
|
||||
property var categoryKeys: Object.keys(categories)
|
||||
|
||||
function distributeCategories(cols) {
|
||||
const columns = [];
|
||||
const heights = [];
|
||||
for (let i = 0; i < cols; i++) {
|
||||
columns.push([]);
|
||||
heights.push(0);
|
||||
}
|
||||
const sorted = [...categoryKeys].sort((a, b) => estimateCategoryHeight(b) - estimateCategoryHeight(a));
|
||||
for (const cat of sorted) {
|
||||
let minIdx = 0;
|
||||
for (let i = 1; i < cols; i++) {
|
||||
if (heights[i] < heights[minIdx])
|
||||
minIdx = i;
|
||||
}
|
||||
columns[minIdx].push(cat);
|
||||
heights[minIdx] += estimateCategoryHeight(cat);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
Row {
|
||||
id: rowLayout
|
||||
width: mainFlickable.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
property int numColumns: Math.max(1, Math.min(3, Math.floor(width / 350)))
|
||||
property var columnCategories: mainFlickable.distributeCategories(numColumns)
|
||||
|
||||
Repeater {
|
||||
model: rowLayout.numColumns
|
||||
|
||||
Column {
|
||||
id: masonryColumn
|
||||
width: (rowLayout.width - rowLayout.spacing * (rowLayout.numColumns - 1)) / rowLayout.numColumns
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
Repeater {
|
||||
model: rowLayout.columnCategories[index] || []
|
||||
|
||||
Column {
|
||||
id: categoryColumn
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
property string catName: modelData
|
||||
property var catData: mainFlickable.categories[catName]
|
||||
|
||||
StyledText {
|
||||
text: categoryColumn.catName
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Bold
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.primary
|
||||
opacity: 0.3
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 1
|
||||
height: Theme.spacingXS
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Repeater {
|
||||
model: categoryColumn.catData?.subcatKeys || []
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
property string subcatName: modelData
|
||||
property var subcatBinds: categoryColumn.catData?.subcats?.[subcatName] || []
|
||||
|
||||
StyledText {
|
||||
visible: parent.subcatName !== "_root"
|
||||
text: parent.subcatName
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.primary
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
Repeater {
|
||||
model: parent.parent.subcatBinds
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 24
|
||||
|
||||
StyledRect {
|
||||
id: keyBadge
|
||||
width: Math.min(keyText.implicitWidth + 12, 160)
|
||||
height: 22
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
StyledText {
|
||||
id: keyText
|
||||
anchors.centerIn: parent
|
||||
color: Theme.secondary
|
||||
text: (modelData.key || "").replace(/\+/g, " + ")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
isMonospace: true
|
||||
elide: Text.ElideRight
|
||||
width: Math.min(implicitWidth, 148)
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 170
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.desc || modelData.action || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
opacity: 0.9
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.NoWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceComponent: KeybindsModalWindow {
|
||||
onFloatingToggleRequested: root._switchFloating(false)
|
||||
onVisibleChanged: {
|
||||
if (!visible)
|
||||
Qt.callLater(() => windowLoader.active = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import QtQml
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modals
|
||||
import qs.Modals.Common
|
||||
import qs.Services
|
||||
|
||||
DankModal {
|
||||
id: overlay
|
||||
|
||||
signal floatingToggleRequested
|
||||
|
||||
layerNamespace: "dms:keybinds"
|
||||
useOverlayLayer: true
|
||||
property real _maxW: Math.min(overlay.screenWidth * 0.92, 1200)
|
||||
property real _maxH: Math.min(overlay.screenHeight * 0.92, 900)
|
||||
modalWidth: _maxW
|
||||
modalHeight: _maxH
|
||||
onBackgroundClicked: close()
|
||||
onOpened: {
|
||||
Qt.callLater(() => {
|
||||
modalFocusScope.forceActiveFocus();
|
||||
if (contentLoader.item?.searchField)
|
||||
contentLoader.item.searchField.forceActiveFocus();
|
||||
});
|
||||
if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable)
|
||||
KeybindsService.loadCheatsheet();
|
||||
}
|
||||
|
||||
content: Component {
|
||||
KeybindsContent {
|
||||
showFloatingToggle: true
|
||||
floating: false
|
||||
onCloseRequested: overlay.close()
|
||||
onFloatingToggleRequested: overlay.floatingToggleRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Modals
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
id: win
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property alias shouldBeVisible: win.visible
|
||||
|
||||
signal floatingToggleRequested
|
||||
|
||||
function show() {
|
||||
visible = true;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
visible = !visible;
|
||||
}
|
||||
|
||||
objectName: "keybindsModalWindow"
|
||||
title: I18n.tr("Keybinds")
|
||||
minimumSize: Qt.size(Math.min(560, Screen.width), Math.min(400, Screen.height))
|
||||
implicitWidth: 1000
|
||||
implicitHeight: screen ? Math.min(820, screen.height - 100) : 820
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onVisibleChanged: {
|
||||
if (!visible)
|
||||
return;
|
||||
if (!Object.keys(KeybindsService.cheatsheet).length && KeybindsService.cheatsheetAvailable)
|
||||
KeybindsService.loadCheatsheet();
|
||||
Qt.callLater(() => {
|
||||
keybindsContent.forceActiveFocus();
|
||||
keybindsContent.searchField.forceActiveFocus();
|
||||
});
|
||||
}
|
||||
|
||||
onClosed: win.visible = false
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 48
|
||||
z: 10
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: windowControls.tryStartMove()
|
||||
onDoubleClicked: windowControls.tryToggleMaximize()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.surfaceContainer
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingL
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
name: "keyboard"
|
||||
size: Theme.iconSize
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: KeybindsService.cheatsheet.title || I18n.tr("Keybinds")
|
||||
font.pixelSize: Theme.fontSizeXLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankActionButton {
|
||||
circular: false
|
||||
iconName: "close_fullscreen"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
tooltipText: I18n.tr("Dock window")
|
||||
onClicked: win.floatingToggleRequested()
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
visible: windowControls.canMaximize
|
||||
circular: false
|
||||
iconName: win.maximized ? "fullscreen_exit" : "fullscreen"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: windowControls.tryToggleMaximize()
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
circular: false
|
||||
iconName: "close"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: win.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeybindsContent {
|
||||
id: keybindsContent
|
||||
width: parent.width
|
||||
height: parent.height - 48
|
||||
showFloatingToggle: false
|
||||
floating: true
|
||||
onCloseRequested: win.hide()
|
||||
}
|
||||
}
|
||||
|
||||
FloatingWindowControls {
|
||||
id: windowControls
|
||||
targetWindow: win
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ DankModal {
|
||||
|
||||
layerNamespace: "dms:power-menu"
|
||||
keepPopoutsOpen: true
|
||||
useOverlayLayer: true
|
||||
|
||||
property int selectedIndex: 0
|
||||
property int selectedRow: 0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modules.Settings
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FocusScope {
|
||||
@@ -232,7 +233,52 @@ FocusScope {
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: NetworkTab {}
|
||||
sourceComponent: NetworkStatusTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: networkEthernetLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 39
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: NetworkEthernetTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: networkWifiLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 40
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: NetworkWifiTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: networkVpnLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 41
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: NetworkVpnTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
@@ -640,5 +686,20 @@ FocusScope {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: batteryLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 42
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: BatteryTab {}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,20 +53,21 @@ FloatingWindow {
|
||||
visible = !visible;
|
||||
}
|
||||
|
||||
function setTabIndex(tabIndex: int) {
|
||||
if (tabIndex < 0)
|
||||
return;
|
||||
currentTabIndex = tabIndex;
|
||||
sidebar.autoExpandForTab(tabIndex);
|
||||
}
|
||||
|
||||
function showWithTab(tabIndex: int) {
|
||||
if (tabIndex >= 0) {
|
||||
currentTabIndex = tabIndex;
|
||||
sidebar.autoExpandForTab(tabIndex);
|
||||
}
|
||||
setTabIndex(tabIndex);
|
||||
visible = true;
|
||||
}
|
||||
|
||||
function showWithTabName(tabName: string) {
|
||||
var idx = sidebar.resolveTabIndex(tabName);
|
||||
if (idx >= 0) {
|
||||
currentTabIndex = idx;
|
||||
sidebar.autoExpandForTab(idx);
|
||||
}
|
||||
setTabIndex(idx);
|
||||
visible = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,8 +238,33 @@ Rectangle {
|
||||
"id": "network",
|
||||
"text": I18n.tr("Network"),
|
||||
"icon": "wifi",
|
||||
"tabIndex": 7,
|
||||
"dmsOnly": true
|
||||
"dmsOnly": true,
|
||||
"children": [
|
||||
{
|
||||
"id": "network_status",
|
||||
"text": I18n.tr("Status"),
|
||||
"icon": "lan",
|
||||
"tabIndex": 7
|
||||
},
|
||||
{
|
||||
"id": "network_ethernet",
|
||||
"text": I18n.tr("Ethernet"),
|
||||
"icon": "settings_ethernet",
|
||||
"tabIndex": 39
|
||||
},
|
||||
{
|
||||
"id": "network_wifi",
|
||||
"text": I18n.tr("WiFi"),
|
||||
"icon": "wifi",
|
||||
"tabIndex": 40
|
||||
},
|
||||
{
|
||||
"id": "network_vpn",
|
||||
"text": I18n.tr("VPN"),
|
||||
"icon": "vpn_key",
|
||||
"tabIndex": 41
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "applications",
|
||||
@@ -352,6 +377,12 @@ Rectangle {
|
||||
"text": I18n.tr("Power & Sleep"),
|
||||
"icon": "power_settings_new",
|
||||
"tabIndex": 21
|
||||
},
|
||||
{
|
||||
"id": "battery",
|
||||
"text": I18n.tr("Battery"),
|
||||
"icon": "battery_charging_full",
|
||||
"tabIndex": 42
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Modals.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
DankModal {
|
||||
id: root
|
||||
|
||||
layerNamespace: "dms:wifi-password"
|
||||
keepPopoutsOpen: true
|
||||
allowStacking: true
|
||||
shouldBeVisible: false
|
||||
modalWidth: 420
|
||||
modalHeight: calculatedHeight
|
||||
enableShadow: true
|
||||
onBackgroundClicked: clearAndClose()
|
||||
directContent: contentFocusScope
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property string wifiPasswordSSID: ""
|
||||
property string wifiPasswordInput: ""
|
||||
@@ -102,7 +112,7 @@ FloatingWindow {
|
||||
const network = NetworkService.wifiNetworks.find(n => n.ssid === ssid);
|
||||
requiresEnterprise = network?.enterprise || false;
|
||||
|
||||
visible = true;
|
||||
open();
|
||||
Qt.callLater(focusFirstField);
|
||||
}
|
||||
|
||||
@@ -126,7 +136,7 @@ FloatingWindow {
|
||||
secretValues = {};
|
||||
requiresEnterprise = false;
|
||||
|
||||
visible = true;
|
||||
open();
|
||||
Qt.callLater(focusFirstField);
|
||||
}
|
||||
|
||||
@@ -144,6 +154,7 @@ FloatingWindow {
|
||||
|
||||
isVpnPrompt = (connectionType === "vpn" || connectionType === "wireguard");
|
||||
wifiPasswordSSID = isVpnPrompt ? connectionName : ssid;
|
||||
savePasswordCheckbox.checked = !isVpnPrompt;
|
||||
|
||||
requiresEnterprise = setting === "802-1x";
|
||||
|
||||
@@ -152,7 +163,7 @@ FloatingWindow {
|
||||
wifiAnonymousIdentityInput = "";
|
||||
wifiDomainInput = "";
|
||||
|
||||
visible = true;
|
||||
open();
|
||||
Qt.callLater(() => {
|
||||
if (reason === "wrong-password" && fieldsInfo.length === 0) {
|
||||
passwordInput.text = "";
|
||||
@@ -162,7 +173,7 @@ FloatingWindow {
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible = false;
|
||||
close();
|
||||
}
|
||||
|
||||
function getFieldLabel(fieldName) {
|
||||
@@ -242,23 +253,8 @@ FloatingWindow {
|
||||
secretValues = {};
|
||||
}
|
||||
|
||||
objectName: "wifiPasswordModal"
|
||||
title: {
|
||||
if (promptReason === "pkcs11")
|
||||
return I18n.tr("Smartcard PIN");
|
||||
if (isVpnPrompt)
|
||||
return I18n.tr("VPN Password");
|
||||
if (isHiddenNetwork)
|
||||
return I18n.tr("Hidden Network");
|
||||
return I18n.tr("Wi-Fi Password");
|
||||
}
|
||||
minimumSize: Qt.size(420, calculatedHeight)
|
||||
maximumSize: Qt.size(420, calculatedHeight)
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
onShouldBeVisibleChanged: {
|
||||
if (shouldBeVisible) {
|
||||
Qt.callLater(focusFirstField);
|
||||
return;
|
||||
}
|
||||
@@ -287,7 +283,7 @@ FloatingWindow {
|
||||
return;
|
||||
wifiPasswordSSID = NetworkService.connectingSSID;
|
||||
wifiPasswordInput = "";
|
||||
visible = true;
|
||||
open();
|
||||
NetworkService.passwordDialogShouldReopen = false;
|
||||
}
|
||||
}
|
||||
@@ -296,7 +292,7 @@ FloatingWindow {
|
||||
id: contentFocusScope
|
||||
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
focus: root.shouldBeVisible
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
clearAndClose();
|
||||
@@ -318,8 +314,6 @@ FloatingWindow {
|
||||
anchors.right: buttonRow.left
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
height: headerCol.height
|
||||
onPressed: windowControls.tryStartMove()
|
||||
onDoubleClicked: windowControls.tryToggleMaximize()
|
||||
|
||||
Column {
|
||||
id: headerCol
|
||||
@@ -380,14 +374,6 @@ FloatingWindow {
|
||||
anchors.right: parent.right
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankActionButton {
|
||||
visible: windowControls.canMaximize
|
||||
iconName: root.maximized ? "fullscreen_exit" : "fullscreen"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: windowControls.tryToggleMaximize()
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "close"
|
||||
iconSize: Theme.iconSize - 4
|
||||
@@ -419,7 +405,7 @@ FloatingWindow {
|
||||
textColor: Theme.surfaceText
|
||||
placeholderText: I18n.tr("Network Name (SSID)")
|
||||
backgroundColor: "transparent"
|
||||
enabled: root.visible
|
||||
enabled: root.shouldBeVisible
|
||||
keyNavigationTab: passwordInput
|
||||
onAccepted: passwordInput.forceActiveFocus()
|
||||
}
|
||||
@@ -449,7 +435,7 @@ FloatingWindow {
|
||||
echoMode: modelData.isSecret && !passwordVisible ? TextInput.Password : TextInput.Normal
|
||||
placeholderText: getFieldLabel(modelData.name)
|
||||
backgroundColor: "transparent"
|
||||
enabled: root.visible
|
||||
enabled: root.shouldBeVisible
|
||||
|
||||
Keys.onTabPressed: event => {
|
||||
if (index < fieldsInfo.length - 1) {
|
||||
@@ -519,7 +505,7 @@ FloatingWindow {
|
||||
text: wifiUsernameInput
|
||||
placeholderText: I18n.tr("Username")
|
||||
backgroundColor: "transparent"
|
||||
enabled: root.visible
|
||||
enabled: root.shouldBeVisible
|
||||
keyNavigationTab: passwordInput
|
||||
keyNavigationBacktab: domainMatchInput
|
||||
onTextEdited: wifiUsernameInput = text
|
||||
@@ -552,7 +538,7 @@ FloatingWindow {
|
||||
echoMode: passwordVisible ? TextInput.Normal : TextInput.Password
|
||||
placeholderText: (requiresEnterprise && !isVpnPrompt) ? I18n.tr("Password") : ""
|
||||
backgroundColor: "transparent"
|
||||
enabled: root.visible
|
||||
enabled: root.shouldBeVisible
|
||||
keyNavigationTab: (requiresEnterprise && !isVpnPrompt) ? anonInput : null
|
||||
keyNavigationBacktab: (requiresEnterprise && !isVpnPrompt) ? usernameInput : null
|
||||
onTextEdited: wifiPasswordInput = text
|
||||
@@ -589,7 +575,7 @@ FloatingWindow {
|
||||
text: wifiAnonymousIdentityInput
|
||||
placeholderText: I18n.tr("Anonymous Identity (optional)")
|
||||
backgroundColor: "transparent"
|
||||
enabled: root.visible
|
||||
enabled: root.shouldBeVisible
|
||||
keyNavigationTab: domainMatchInput
|
||||
keyNavigationBacktab: passwordInput
|
||||
onTextEdited: wifiAnonymousIdentityInput = text
|
||||
@@ -620,7 +606,7 @@ FloatingWindow {
|
||||
text: wifiDomainInput
|
||||
placeholderText: I18n.tr("Domain (optional)")
|
||||
backgroundColor: "transparent"
|
||||
enabled: root.visible
|
||||
enabled: root.shouldBeVisible
|
||||
keyNavigationTab: usernameInput
|
||||
keyNavigationBacktab: anonInput
|
||||
onTextEdited: wifiDomainInput = text
|
||||
@@ -757,8 +743,5 @@ FloatingWindow {
|
||||
}
|
||||
}
|
||||
|
||||
FloatingWindowControls {
|
||||
id: windowControls
|
||||
targetWindow: root
|
||||
}
|
||||
onOpened: Qt.callLater(() => contentFocusScope.forceActiveFocus())
|
||||
}
|
||||
|
||||
@@ -25,7 +25,14 @@ PluginComponent {
|
||||
}
|
||||
ccWidgetIsActive: TailscaleService.connected
|
||||
|
||||
onCcWidgetToggled: {}
|
||||
onCcWidgetToggled: {
|
||||
if (!TailscaleService.available)
|
||||
return;
|
||||
if (TailscaleService.connected)
|
||||
TailscaleService.disconnectTailscale(null);
|
||||
else
|
||||
TailscaleService.connectTailscale(null);
|
||||
}
|
||||
|
||||
ccDetailContent: Component {
|
||||
Rectangle {
|
||||
@@ -88,6 +95,122 @@ PluginComponent {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
// Connection status + connect/disconnect. Always shown
|
||||
// (when available) so the connection can be toggled from
|
||||
// the detail, including while disconnected.
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Column {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: 1
|
||||
|
||||
StyledText {
|
||||
text: TailscaleService.connected ? I18n.tr("Connected", "Tailscale connection status: connected") : I18n.tr("Disconnected", "Tailscale connection status: disconnected")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: TailscaleService.connected && TailscaleService.tailnetName.length > 0
|
||||
text: TailscaleService.tailnetName
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: connButton
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
height: 28
|
||||
radius: 14
|
||||
width: connButtonRow.implicitWidth + Theme.spacingM * 2
|
||||
|
||||
readonly property bool isConnected: TailscaleService.connected
|
||||
color: isConnected ? (connButtonArea.containsMouse ? Theme.errorHover : Theme.surfaceLight) : (connButtonArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight)
|
||||
|
||||
Row {
|
||||
id: connButtonRow
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankIcon {
|
||||
name: connButton.isConnected ? "link_off" : "link"
|
||||
size: Theme.fontSizeSmall
|
||||
color: connButton.isConnected ? Theme.surfaceText : Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: connButton.isConnected ? I18n.tr("Disconnect", "Tailscale disconnect button") : I18n.tr("Connect", "Tailscale connect button")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: connButton.isConnected ? Theme.surfaceText : Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: connButtonArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (TailscaleService.connected)
|
||||
TailscaleService.disconnectTailscale(null);
|
||||
else
|
||||
TailscaleService.connectTailscale(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection controls: exit node picker + LAN access.
|
||||
// Only meaningful while the backend is connected.
|
||||
Column {
|
||||
id: controlsColumn
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
visible: TailscaleService.connected
|
||||
|
||||
readonly property string noneLabel: I18n.tr("None", "Tailscale exit node: none selected")
|
||||
|
||||
DankDropdown {
|
||||
width: parent.width
|
||||
text: I18n.tr("Exit node", "Tailscale exit node selector label")
|
||||
currentValue: TailscaleService.currentExitNode ? TailscaleService.currentExitNode.hostname : controlsColumn.noneLabel
|
||||
options: {
|
||||
const opts = [controlsColumn.noneLabel];
|
||||
for (const p of TailscaleService.exitNodeOptions)
|
||||
opts.push(p.hostname);
|
||||
return opts;
|
||||
}
|
||||
onValueChanged: value => {
|
||||
if (value === controlsColumn.noneLabel) {
|
||||
TailscaleService.clearExitNode(null);
|
||||
return;
|
||||
}
|
||||
const peer = TailscaleService.exitNodeOptions.find(p => p.hostname === value);
|
||||
if (peer)
|
||||
TailscaleService.setExitNode(peer.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
width: parent.width
|
||||
text: I18n.tr("Allow LAN access", "Tailscale allow LAN access toggle")
|
||||
description: I18n.tr("Reach local network devices while using an exit node", "Tailscale allow LAN access description")
|
||||
visible: TailscaleService.currentExitNode !== null
|
||||
checked: TailscaleService.exitNodeAllowLanAccess
|
||||
onToggled: value => TailscaleService.setAllowLanAccess(value, null)
|
||||
}
|
||||
}
|
||||
|
||||
// Search bar + refresh button
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
|
||||
@@ -93,7 +93,7 @@ DankPopout {
|
||||
shouldBeVisible: false
|
||||
|
||||
property bool credentialsPromptOpen: NetworkService.credentialsRequested
|
||||
property bool wifiPasswordModalOpen: PopoutService.wifiPasswordModal?.visible ?? false
|
||||
property bool wifiPasswordModalOpen: PopoutService.wifiPasswordModal?.shouldBeVisible ?? false
|
||||
property bool polkitModalOpen: PopoutService.polkitAuthModal?.visible ?? false
|
||||
property bool anyModalOpen: credentialsPromptOpen || wifiPasswordModalOpen || polkitModalOpen || powerMenuOpen
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Modules.Network
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modals
|
||||
@@ -151,7 +152,7 @@ Rectangle {
|
||||
iconColor: Theme.surfaceVariantText
|
||||
onClicked: {
|
||||
PopoutService.closeControlCenter();
|
||||
PopoutService.openSettingsWithTab("network");
|
||||
PopoutService.openSettingsWithTab(currentPreferenceIndex === 0 ? "network_ethernet" : "network_wifi");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -721,7 +722,7 @@ Rectangle {
|
||||
|
||||
DankActionButton {
|
||||
id: qrCodeButton
|
||||
visible: modelData.secured && modelData.saved
|
||||
visible: modelData.secured && modelData.saved && !(modelData.enterprise || false)
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: optionsButton.width + pinWifiRow.width + 3 * Theme.spacingM + Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -749,11 +750,9 @@ Rectangle {
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
if (modelData.secured && !modelData.saved && (DMSService.apiVersion < 7 || modelData.enterprise)) {
|
||||
PopoutService.showWifiPasswordModal(modelData.ssid);
|
||||
} else {
|
||||
NetworkService.connectToWifi(modelData.ssid);
|
||||
}
|
||||
WifiConnectionActions.connectToNetwork(modelData, {
|
||||
connected: wifiDelegate.isConnected
|
||||
});
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
@@ -804,15 +803,9 @@ Rectangle {
|
||||
}
|
||||
|
||||
onTriggered: {
|
||||
if (networkContextMenu.currentConnected) {
|
||||
NetworkService.disconnectWifi();
|
||||
return;
|
||||
}
|
||||
if (networkContextMenu.currentSecured && !networkContextMenu.currentSaved && (DMSService.apiVersion < 7 || networkContextMenu.currentEnterprise)) {
|
||||
PopoutService.showWifiPasswordModal(networkContextMenu.currentSSID);
|
||||
return;
|
||||
}
|
||||
NetworkService.connectToWifi(networkContextMenu.currentSSID);
|
||||
WifiConnectionActions.connectToNetworkFromDetails(networkContextMenu.currentSSID, networkContextMenu.currentSecured, networkContextMenu.currentSaved, networkContextMenu.currentEnterprise, networkContextMenu.currentConnected, {
|
||||
disconnectWhenConnected: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ Item {
|
||||
property real barSpacing: 4
|
||||
property var barConfig: null
|
||||
property var blurBarWindow: null
|
||||
property real sectionAvailablePrimarySize: 0
|
||||
property bool overrideAxisLayout: false
|
||||
property bool forceVerticalLayout: false
|
||||
|
||||
@@ -359,6 +360,7 @@ Item {
|
||||
barSpacing: root.barSpacing
|
||||
barConfig: root.barConfig
|
||||
blurBarWindow: root.blurBarWindow
|
||||
sectionAvailablePrimarySize: root.sectionAvailablePrimarySize
|
||||
isFirst: index === 0
|
||||
isLast: index === centerRepeater.count - 1
|
||||
sectionSpacing: parent.itemSpacing
|
||||
|
||||
@@ -497,6 +497,7 @@ Item {
|
||||
widgetThickness: barWindow.widgetThickness
|
||||
barThickness: barWindow.effectiveBarThickness
|
||||
barSpacing: barConfig?.spacing ?? 4
|
||||
sectionAvailablePrimarySize: Math.max(1, hCenterSection.x > 0 ? hCenterSection.x : parent.width / 3)
|
||||
}
|
||||
|
||||
Binding {
|
||||
@@ -529,6 +530,7 @@ Item {
|
||||
widgetThickness: barWindow.widgetThickness
|
||||
barThickness: barWindow.effectiveBarThickness
|
||||
barSpacing: barConfig?.spacing ?? 4
|
||||
sectionAvailablePrimarySize: Math.max(1, hCenterSection.x > 0 ? parent.width - (hCenterSection.x + hCenterSection.width) : parent.width / 3)
|
||||
}
|
||||
|
||||
Binding {
|
||||
@@ -561,6 +563,7 @@ Item {
|
||||
widgetThickness: barWindow.widgetThickness
|
||||
barThickness: barWindow.effectiveBarThickness
|
||||
barSpacing: barConfig?.spacing ?? 4
|
||||
sectionAvailablePrimarySize: Math.max(1, hRightSection.x > 0 ? hRightSection.x - (hLeftSection.x + hLeftSection.width) : parent.width / 3)
|
||||
}
|
||||
|
||||
Binding {
|
||||
@@ -600,6 +603,7 @@ Item {
|
||||
widgetThickness: barWindow.widgetThickness
|
||||
barThickness: barWindow.effectiveBarThickness
|
||||
barSpacing: barConfig?.spacing ?? 4
|
||||
sectionAvailablePrimarySize: Math.max(1, vCenterSection.y > 0 ? vCenterSection.y : parent.height / 3)
|
||||
}
|
||||
|
||||
Binding {
|
||||
@@ -633,6 +637,7 @@ Item {
|
||||
widgetThickness: barWindow.widgetThickness
|
||||
barThickness: barWindow.effectiveBarThickness
|
||||
barSpacing: barConfig?.spacing ?? 4
|
||||
sectionAvailablePrimarySize: Math.max(1, vRightSection.y > 0 ? vRightSection.y - (vLeftSection.y + vLeftSection.height) : parent.height / 3)
|
||||
}
|
||||
|
||||
Binding {
|
||||
@@ -667,6 +672,7 @@ Item {
|
||||
widgetThickness: barWindow.widgetThickness
|
||||
barThickness: barWindow.effectiveBarThickness
|
||||
barSpacing: barConfig?.spacing ?? 4
|
||||
sectionAvailablePrimarySize: Math.max(1, vCenterSection.y > 0 ? parent.height - (vCenterSection.y + vCenterSection.height) : parent.height / 3)
|
||||
}
|
||||
|
||||
Binding {
|
||||
|
||||
@@ -14,6 +14,7 @@ Item {
|
||||
property real barSpacing: 4
|
||||
property var barConfig: null
|
||||
property var blurBarWindow: null
|
||||
property real sectionAvailablePrimarySize: 0
|
||||
property bool overrideAxisLayout: false
|
||||
property bool forceVerticalLayout: false
|
||||
|
||||
@@ -61,6 +62,7 @@ Item {
|
||||
barSpacing: root.barSpacing
|
||||
barConfig: root.barConfig
|
||||
blurBarWindow: root.blurBarWindow
|
||||
sectionAvailablePrimarySize: root.sectionAvailablePrimarySize
|
||||
isFirst: index === 0
|
||||
isLast: index === rowRepeater.count - 1
|
||||
sectionSpacing: parent.rowSpacing
|
||||
@@ -106,6 +108,7 @@ Item {
|
||||
barSpacing: root.barSpacing
|
||||
barConfig: root.barConfig
|
||||
blurBarWindow: root.blurBarWindow
|
||||
sectionAvailablePrimarySize: root.sectionAvailablePrimarySize
|
||||
isFirst: index === 0
|
||||
isLast: index === columnRepeater.count - 1
|
||||
sectionSpacing: parent.columnSpacing
|
||||
|
||||
@@ -14,6 +14,7 @@ Item {
|
||||
property real barSpacing: 4
|
||||
property var barConfig: null
|
||||
property var blurBarWindow: null
|
||||
property real sectionAvailablePrimarySize: 0
|
||||
property bool overrideAxisLayout: false
|
||||
property bool forceVerticalLayout: false
|
||||
|
||||
@@ -63,6 +64,7 @@ Item {
|
||||
barSpacing: root.barSpacing
|
||||
barConfig: root.barConfig
|
||||
blurBarWindow: root.blurBarWindow
|
||||
sectionAvailablePrimarySize: root.sectionAvailablePrimarySize
|
||||
isFirst: index === 0
|
||||
isLast: index === rowRepeater.count - 1
|
||||
sectionSpacing: parent.rowSpacing
|
||||
@@ -108,6 +110,7 @@ Item {
|
||||
barSpacing: root.barSpacing
|
||||
barConfig: root.barConfig
|
||||
blurBarWindow: root.blurBarWindow
|
||||
sectionAvailablePrimarySize: root.sectionAvailablePrimarySize
|
||||
isFirst: index === 0
|
||||
isLast: index === columnRepeater.count - 1
|
||||
sectionSpacing: parent.columnSpacing
|
||||
|
||||
@@ -17,6 +17,7 @@ Loader {
|
||||
property real barSpacing: 4
|
||||
property var barConfig: null
|
||||
property var blurBarWindow: null
|
||||
property real sectionAvailablePrimarySize: 0
|
||||
property bool isFirst: false
|
||||
property bool isLast: false
|
||||
property real sectionSpacing: 0
|
||||
@@ -141,6 +142,14 @@ Loader {
|
||||
restoreMode: Binding.RestoreNone
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: root.item
|
||||
when: root.item && "sectionAvailablePrimarySize" in root.item
|
||||
property: "sectionAvailablePrimarySize"
|
||||
value: root.sectionAvailablePrimarySize
|
||||
restoreMode: Binding.RestoreNone
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: root.item
|
||||
when: root.item && "isLeftBarEdge" in root.item
|
||||
|
||||
@@ -933,19 +933,17 @@ BasePill {
|
||||
tooltipLoader.active = true;
|
||||
if (tooltipLoader.item) {
|
||||
if (root.isVerticalOrientation) {
|
||||
const globalPos = delegateItem.mapToGlobal(0, delegateItem.height / 2);
|
||||
const screenX = root.parentScreen ? root.parentScreen.x : 0;
|
||||
const screenY = root.parentScreen ? root.parentScreen.y : 0;
|
||||
const localPos = delegateItem.mapToItem(null, 0, delegateItem.height / 2);
|
||||
const isLeft = root.axis?.edge === "left";
|
||||
const tooltipX = isLeft ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
|
||||
const screenRelativeY = globalPos.y - screenY + root.minTooltipY;
|
||||
tooltipLoader.item.show(appItem.tooltipText, screenX + tooltipX, screenRelativeY, root.parentScreen, isLeft, !isLeft);
|
||||
const screenRelativeY = localPos.y + root.minTooltipY;
|
||||
tooltipLoader.item.show(appItem.tooltipText, tooltipX, screenRelativeY, root.parentScreen, isLeft, !isLeft);
|
||||
} else {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height);
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height);
|
||||
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
|
||||
const isBottom = root.axis?.edge === "bottom";
|
||||
const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS);
|
||||
tooltipLoader.item.show(appItem.tooltipText, globalPos.x, tooltipY, root.parentScreen, false, false);
|
||||
tooltipLoader.item.show(appItem.tooltipText, localPos.x, tooltipY, root.parentScreen, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -967,14 +965,12 @@ BasePill {
|
||||
contextMenuLoader.active = true;
|
||||
|
||||
if (contextMenuLoader.item) {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2);
|
||||
const screenX = root.parentScreen ? root.parentScreen.x : 0;
|
||||
const screenY = root.parentScreen ? root.parentScreen.y : 0;
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
|
||||
const isBarVertical = root.axis?.isVertical ?? false;
|
||||
const barEdge = root.axis?.edge ?? "top";
|
||||
|
||||
let x = globalPos.x - screenX;
|
||||
let y = globalPos.y - screenY;
|
||||
let x = localPos.x;
|
||||
let y = localPos.y;
|
||||
|
||||
switch (barEdge) {
|
||||
case "bottom":
|
||||
|
||||
@@ -118,10 +118,18 @@ BasePill {
|
||||
width: battery.width + battery.leftMargin + battery.rightMargin
|
||||
height: battery.height + battery.topMargin + battery.bottomMargin
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
acceptedButtons: Qt.LeftButton
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onPressed: mouse => {
|
||||
battery.triggerRipple(this, mouse.x, mouse.y);
|
||||
toggleBatteryPopup();
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
toggleBatteryPopup();
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
if (PowerProfileWatcher.available) {
|
||||
PowerProfileWatcher.cycleProfile();
|
||||
} else {
|
||||
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
|
||||
}
|
||||
}
|
||||
}
|
||||
onWheel: wheel => {
|
||||
var delta = wheel.angleDelta.y;
|
||||
@@ -131,33 +139,20 @@ BasePill {
|
||||
// Check if this is a touchpad
|
||||
if (delta !== 120 && delta !== -120) {
|
||||
touchpadAccumulator += delta;
|
||||
log.info("Acc: " + touchpadAccumulator);
|
||||
if (Math.abs(touchpadAccumulator) < 500)
|
||||
return;
|
||||
delta = touchpadAccumulator;
|
||||
touchpadAccumulator = 0;
|
||||
}
|
||||
log.info("Trigger! Delta: " + delta);
|
||||
|
||||
// This is after the other delta checks so it only shows on valid Y scroll
|
||||
if (!PowerProfileWatcher.available) {
|
||||
ToastService.showError(I18n.tr("power-profiles-daemon not available"));
|
||||
if (!DisplayService.brightnessAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profiles = PowerProfileWatcher.availableProfiles;
|
||||
var index = profiles.findIndex(profile => PowerProfiles.profile === profile);
|
||||
|
||||
if (delta > 0)
|
||||
index += 1;
|
||||
else
|
||||
index -= 1;
|
||||
|
||||
if (index < 0 || index >= profiles.length)
|
||||
return;
|
||||
|
||||
if (!PowerProfileWatcher.applyProfile(profiles[index]))
|
||||
ToastService.showError(I18n.tr("Failed to set power profile"));
|
||||
const step = 5;
|
||||
const change = delta > 0 ? step : -step;
|
||||
const newBrightness = Math.max(0, Math.min(100, DisplayService.brightnessLevel + change));
|
||||
DisplayService.setBrightness(newBrightness, "", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,15 +276,12 @@ BasePill {
|
||||
if (root.isVerticalOrientation && root.selectedMount) {
|
||||
tooltipLoader.active = true;
|
||||
if (tooltipLoader.item) {
|
||||
const globalPos = mapToGlobal(width / 2, height / 2);
|
||||
const localPos = mapToItem(null, width / 2, height / 2);
|
||||
const currentScreen = root.parentScreen || Screen;
|
||||
const screenX = currentScreen ? currentScreen.x : 0;
|
||||
const screenY = currentScreen ? currentScreen.y : 0;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
const adjustedY = relativeY + root.minTooltipY;
|
||||
const adjustedY = localPos.y + root.minTooltipY;
|
||||
const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (currentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
|
||||
const isLeft = root.axis?.edge === "left";
|
||||
tooltipLoader.item.show(root.selectedMount.mount, screenX + tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
|
||||
tooltipLoader.item.show(root.selectedMount.mount, tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,13 +304,9 @@ BasePill {
|
||||
if (root.isVerticalOrientation && activeWindow && activeWindow.appId && root.parentScreen) {
|
||||
tooltipLoader.active = true;
|
||||
if (tooltipLoader.item) {
|
||||
const globalPos = mapToGlobal(width / 2, height / 2);
|
||||
const localPos = mapToItem(null, width / 2, height / 2);
|
||||
const currentScreen = root.parentScreen;
|
||||
const screenX = currentScreen ? currentScreen.x : 0;
|
||||
const screenY = currentScreen ? currentScreen.y : 0;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
// Add minTooltipY offset to account for top bar
|
||||
const adjustedY = relativeY + root.minTooltipY;
|
||||
const adjustedY = localPos.y + root.minTooltipY;
|
||||
const tooltipX = root.axis?.edge === "left" ? (Theme.barHeight + (barConfig?.spacing ?? 4) + Theme.spacingXS) : (currentScreen.width - Theme.barHeight - (barConfig?.spacing ?? 4) - Theme.spacingXS);
|
||||
|
||||
const appName = Paths.getAppName(activeWindow.appId, activeDesktopEntry);
|
||||
@@ -318,7 +314,7 @@ BasePill {
|
||||
const tooltipText = appName + (title ? " • " + title : "");
|
||||
|
||||
const isLeft = root.axis?.edge === "left";
|
||||
tooltipLoader.item.show(tooltipText, screenX + tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
|
||||
tooltipLoader.item.show(tooltipText, tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,20 @@ BasePill {
|
||||
}
|
||||
|
||||
readonly property var notepadInstance: resolveNotepadInstance()
|
||||
readonly property bool isActive: notepadInstance?.isVisible ?? false
|
||||
readonly property bool popoutDefault: SettingsData.notepadDefaultMode === "popout"
|
||||
readonly property bool isActive: popoutDefault ? (PopoutService.notepadPopout?.visible ?? false) : (notepadInstance?.isVisible ?? false)
|
||||
property bool isAutoHideBar: false
|
||||
|
||||
function showActiveSurface() {
|
||||
if (root.popoutDefault) {
|
||||
PopoutService.openNotepadPopout();
|
||||
return;
|
||||
}
|
||||
const instance = prepareNotepadInstance(root.notepadInstance);
|
||||
if (instance && typeof instance.show === "function")
|
||||
instance.show();
|
||||
}
|
||||
|
||||
function prepareNotepadInstance(instance) {
|
||||
if (instance)
|
||||
instance.triggerUsesOverlayLayer = root.barUsesOverlayLayer;
|
||||
@@ -75,20 +86,14 @@ BasePill {
|
||||
function openTabByIndex(tabIndex) {
|
||||
if (tabIndex < 0)
|
||||
return;
|
||||
const instance = prepareNotepadInstance(root.notepadInstance);
|
||||
if (instance && typeof instance.show === "function") {
|
||||
instance.show();
|
||||
}
|
||||
showActiveSurface();
|
||||
Qt.callLater(() => {
|
||||
NotepadStorageService.switchToTab(tabIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function openNewNote() {
|
||||
const instance = prepareNotepadInstance(root.notepadInstance);
|
||||
if (instance && typeof instance.show === "function") {
|
||||
instance.show();
|
||||
}
|
||||
showActiveSurface();
|
||||
Qt.callLater(() => {
|
||||
NotepadStorageService.createNewTab();
|
||||
});
|
||||
@@ -147,6 +152,10 @@ BasePill {
|
||||
openContextMenu();
|
||||
return;
|
||||
}
|
||||
if (root.popoutDefault) {
|
||||
PopoutService.toggleNotepadPopout();
|
||||
return;
|
||||
}
|
||||
const inst = prepareNotepadInstance(root.notepadInstance);
|
||||
if (inst) {
|
||||
inst.toggle();
|
||||
|
||||
@@ -18,6 +18,14 @@ BasePill {
|
||||
|
||||
property var widgetData: null
|
||||
property var hoveredItem: null
|
||||
|
||||
onHoveredItemChanged: {
|
||||
if (hoveredItem)
|
||||
return;
|
||||
if (tooltipLoader.item)
|
||||
tooltipLoader.item.hide();
|
||||
tooltipLoader.active = false;
|
||||
}
|
||||
property var topBar: null
|
||||
property bool isAutoHideBar: false
|
||||
property Item windowRoot: (Window.window ? Window.window.contentItem : null)
|
||||
@@ -236,6 +244,11 @@ BasePill {
|
||||
delegate: Item {
|
||||
id: delegateItem
|
||||
|
||||
Component.onDestruction: {
|
||||
if (root.hoveredItem === delegateItem)
|
||||
root.hoveredItem = null;
|
||||
}
|
||||
|
||||
property bool isGrouped: root._groupByApp
|
||||
property var groupData: isGrouped ? modelData : null
|
||||
property var toplevelData: isGrouped ? (modelData.windows.length > 0 ? modelData.windows[0].toplevel : null) : modelData
|
||||
@@ -411,22 +424,16 @@ BasePill {
|
||||
windowContextMenuLoader.item.triggerBarThickness = root.barThickness;
|
||||
windowContextMenuLoader.item.triggerBarSpacing = root.barSpacing;
|
||||
if (root.isVerticalOrientation) {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2);
|
||||
const screenX = root.parentScreen ? root.parentScreen.x : 0;
|
||||
const screenY = root.parentScreen ? root.parentScreen.y : 0;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
// Add minTooltipY offset to account for top bar
|
||||
const adjustedY = relativeY + root.minTooltipY;
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
|
||||
const adjustedY = localPos.y + root.minTooltipY;
|
||||
const xPos = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
|
||||
windowContextMenuLoader.item.showAt(xPos, adjustedY, true, root.axis?.edge);
|
||||
} else {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, 0);
|
||||
const screenX = root.parentScreen ? root.parentScreen.x : 0;
|
||||
const relativeX = globalPos.x - screenX;
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, 0);
|
||||
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
|
||||
const isBottom = root.axis?.edge === "bottom";
|
||||
const yPos = isBottom ? (screenHeight - root.barThickness - root.barSpacing - 32 - Theme.spacingXS) : (root.barThickness + root.barSpacing + Theme.spacingXS);
|
||||
windowContextMenuLoader.item.showAt(relativeX, yPos, false, root.axis?.edge);
|
||||
windowContextMenuLoader.item.showAt(localPos.x, yPos, false, root.axis?.edge);
|
||||
}
|
||||
}
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
@@ -442,33 +449,23 @@ BasePill {
|
||||
tooltipLoader.active = true;
|
||||
if (tooltipLoader.item) {
|
||||
if (root.isVerticalOrientation) {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2);
|
||||
const screenX = root.parentScreen ? root.parentScreen.x : 0;
|
||||
const screenY = root.parentScreen ? root.parentScreen.y : 0;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
|
||||
const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
|
||||
const isLeft = root.axis?.edge === "left";
|
||||
const adjustedY = relativeY + root.minTooltipY;
|
||||
const finalX = screenX + tooltipX;
|
||||
tooltipLoader.item.show(delegateItem.tooltipText, finalX, adjustedY, root.parentScreen, isLeft, !isLeft);
|
||||
const adjustedY = localPos.y + root.minTooltipY;
|
||||
tooltipLoader.item.show(delegateItem.tooltipText, tooltipX, adjustedY, root.parentScreen, isLeft, !isLeft);
|
||||
} else {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height);
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height);
|
||||
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
|
||||
const isBottom = root.axis?.edge === "bottom";
|
||||
const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS);
|
||||
tooltipLoader.item.show(delegateItem.tooltipText, globalPos.x, tooltipY, root.parentScreen, false, false);
|
||||
tooltipLoader.item.show(delegateItem.tooltipText, localPos.x, tooltipY, root.parentScreen, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
if (root.hoveredItem === delegateItem) {
|
||||
if (root.hoveredItem === delegateItem)
|
||||
root.hoveredItem = null;
|
||||
if (tooltipLoader.item) {
|
||||
tooltipLoader.item.hide();
|
||||
}
|
||||
|
||||
tooltipLoader.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,6 +488,11 @@ BasePill {
|
||||
delegate: Item {
|
||||
id: delegateItem
|
||||
|
||||
Component.onDestruction: {
|
||||
if (root.hoveredItem === delegateItem)
|
||||
root.hoveredItem = null;
|
||||
}
|
||||
|
||||
property bool isGrouped: root._groupByApp
|
||||
property var groupData: isGrouped ? modelData : null
|
||||
property var toplevelData: isGrouped ? (modelData.windows.length > 0 ? modelData.windows[0].toplevel : null) : modelData
|
||||
@@ -665,22 +667,16 @@ BasePill {
|
||||
windowContextMenuLoader.item.triggerBarThickness = root.barThickness;
|
||||
windowContextMenuLoader.item.triggerBarSpacing = root.barSpacing;
|
||||
if (root.isVerticalOrientation) {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2);
|
||||
const screenX = root.parentScreen ? root.parentScreen.x : 0;
|
||||
const screenY = root.parentScreen ? root.parentScreen.y : 0;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
// Add minTooltipY offset to account for top bar
|
||||
const adjustedY = relativeY + root.minTooltipY;
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
|
||||
const adjustedY = localPos.y + root.minTooltipY;
|
||||
const xPos = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
|
||||
windowContextMenuLoader.item.showAt(xPos, adjustedY, true, root.axis?.edge);
|
||||
} else {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, 0);
|
||||
const screenX = root.parentScreen ? root.parentScreen.x : 0;
|
||||
const relativeX = globalPos.x - screenX;
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, 0);
|
||||
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
|
||||
const isBottom = root.axis?.edge === "bottom";
|
||||
const yPos = isBottom ? (screenHeight - root.barThickness - root.barSpacing - 32 - Theme.spacingXS) : (root.barThickness + root.barSpacing + Theme.spacingXS);
|
||||
windowContextMenuLoader.item.showAt(relativeX, yPos, false, root.axis?.edge);
|
||||
windowContextMenuLoader.item.showAt(localPos.x, yPos, false, root.axis?.edge);
|
||||
}
|
||||
}
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
@@ -696,33 +692,23 @@ BasePill {
|
||||
tooltipLoader.active = true;
|
||||
if (tooltipLoader.item) {
|
||||
if (root.isVerticalOrientation) {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height / 2);
|
||||
const screenX = root.parentScreen ? root.parentScreen.x : 0;
|
||||
const screenY = root.parentScreen ? root.parentScreen.y : 0;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height / 2);
|
||||
const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (root.parentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
|
||||
const isLeft = root.axis?.edge === "left";
|
||||
const adjustedY = relativeY + root.minTooltipY;
|
||||
const finalX = screenX + tooltipX;
|
||||
tooltipLoader.item.show(delegateItem.tooltipText, finalX, adjustedY, root.parentScreen, isLeft, !isLeft);
|
||||
const adjustedY = localPos.y + root.minTooltipY;
|
||||
tooltipLoader.item.show(delegateItem.tooltipText, tooltipX, adjustedY, root.parentScreen, isLeft, !isLeft);
|
||||
} else {
|
||||
const globalPos = delegateItem.mapToGlobal(delegateItem.width / 2, delegateItem.height);
|
||||
const localPos = delegateItem.mapToItem(null, delegateItem.width / 2, delegateItem.height);
|
||||
const screenHeight = root.parentScreen ? root.parentScreen.height : Screen.height;
|
||||
const isBottom = root.axis?.edge === "bottom";
|
||||
const tooltipY = isBottom ? (screenHeight - root.barThickness - root.barSpacing - Theme.spacingXS - 35) : (root.barThickness + root.barSpacing + Theme.spacingXS);
|
||||
tooltipLoader.item.show(delegateItem.tooltipText, globalPos.x, tooltipY, root.parentScreen, false, false);
|
||||
tooltipLoader.item.show(delegateItem.tooltipText, localPos.x, tooltipY, root.parentScreen, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
if (root.hoveredItem === delegateItem) {
|
||||
if (root.hoveredItem === delegateItem)
|
||||
root.hoveredItem = null;
|
||||
if (tooltipLoader.item) {
|
||||
tooltipLoader.item.hide();
|
||||
}
|
||||
|
||||
tooltipLoader.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ BasePill {
|
||||
property bool isAtBottom: false
|
||||
property bool isAutoHideBar: false
|
||||
property bool useOverflowPopup: !widgetData?.trayUseInlineExpansion
|
||||
property bool useSingleLineOverflowPopup: widgetData?.trayPopupSingleLine ?? SettingsData.trayPopupSingleLine
|
||||
property bool useAutomaticOverflow: widgetData?.trayAutoOverflow ?? SettingsData.trayAutoOverflow
|
||||
property int configuredMaxVisibleItems: widgetData?.trayMaxVisibleItems ?? SettingsData.trayMaxVisibleItems
|
||||
property real sectionAvailablePrimarySize: 0
|
||||
readonly property var hiddenTrayIds: {
|
||||
const envValue = Quickshell.env("DMS_HIDE_TRAYIDS") || "";
|
||||
return envValue ? envValue.split(",").map(id => id.trim().toLowerCase()) : [];
|
||||
@@ -146,12 +150,32 @@ BasePill {
|
||||
|
||||
readonly property var allSortedTrayItems: sortByPreferredOrder(allTrayItems, _trayOrderTrigger)
|
||||
readonly property var allSortedTrayItemKeys: allSortedTrayItems.map(item => getTrayItemKey(item))
|
||||
readonly property var mainBarItemsRaw: allSortedTrayItems.filter(item => !SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
||||
readonly property var visibleSortedTrayItems: allSortedTrayItems.filter(item => !SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
||||
readonly property int automaticVisibleItemLimit: {
|
||||
if (!root.useAutomaticOverflow)
|
||||
return root.visibleSortedTrayItems.length;
|
||||
|
||||
const explicitLimit = Number(root.configuredMaxVisibleItems || 0);
|
||||
if (explicitLimit > 0)
|
||||
return Math.max(1, Math.min(root.visibleSortedTrayItems.length, explicitLimit));
|
||||
|
||||
const scale = (typeof CompositorService !== "undefined" && CompositorService.getScreenScale) ? Math.max(1, CompositorService.getScreenScale(root.parentScreen)) : 1;
|
||||
const sectionPrimary = root.sectionAvailablePrimarySize > 0 ? root.sectionAvailablePrimarySize : (root.isVerticalOrientation ? (root.parentScreen?.height || 0) : (root.parentScreen?.width || 0));
|
||||
const logicalPrimary = sectionPrimary > 0 ? (sectionPrimary / scale) : 640;
|
||||
const maxTrayShare = root.isVerticalOrientation ? 0.55 : 0.50;
|
||||
const itemSize = Math.max(1, root.trayItemSize);
|
||||
const slots = Math.floor((logicalPrimary * maxTrayShare) / itemSize);
|
||||
return Math.max(2, Math.min(10, Math.min(root.visibleSortedTrayItems.length, slots)));
|
||||
}
|
||||
readonly property var mainBarItemsRaw: visibleSortedTrayItems.slice(0, automaticVisibleItemLimit)
|
||||
readonly property var mainBarItems: mainBarItemsRaw.map((item, idx) => ({
|
||||
key: getTrayItemKey(item),
|
||||
item: item
|
||||
}))
|
||||
readonly property var hiddenBarItems: allSortedTrayItems.filter(item => SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
||||
readonly property var autoOverflowBarItems: visibleSortedTrayItems.slice(automaticVisibleItemLimit)
|
||||
readonly property var manualHiddenBarItems: allSortedTrayItems.filter(item => SessionData.isHiddenTrayId(root.getTrayItemKey(item)))
|
||||
readonly property var hiddenBarItemKeys: manualHiddenBarItems.concat(autoOverflowBarItems).map(item => root.getTrayItemKey(item))
|
||||
readonly property var hiddenBarItems: allSortedTrayItems.filter(item => hiddenBarItemKeys.indexOf(root.getTrayItemKey(item)) !== -1)
|
||||
readonly property string trayIconTintMode: {
|
||||
const configuredMode = SettingsData.systemTrayIconTintMode || "none";
|
||||
switch (configuredMode) {
|
||||
@@ -219,6 +243,10 @@ BasePill {
|
||||
|
||||
const fromKey = mainBarItems[visibleFromIndex]?.key ?? null;
|
||||
const toKey = mainBarItems[visibleToIndex]?.key ?? null;
|
||||
moveTrayItemKeyInFullOrder(fromKey, toKey);
|
||||
}
|
||||
|
||||
function moveTrayItemKeyInFullOrder(fromKey, toKey) {
|
||||
if (!fromKey || !toKey)
|
||||
return;
|
||||
|
||||
@@ -233,10 +261,103 @@ BasePill {
|
||||
SessionData.setTrayItemOrder(fullOrder);
|
||||
}
|
||||
|
||||
function promoteTrayItemToBar(item) {
|
||||
const itemKey = getTrayItemKey(item);
|
||||
if (!itemKey)
|
||||
return;
|
||||
if (SessionData.isHiddenTrayId(itemKey)) {
|
||||
SessionData.showTrayId(itemKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const fullOrder = [...allSortedTrayItemKeys];
|
||||
const fromIndex = fullOrder.indexOf(itemKey);
|
||||
if (fromIndex < 0)
|
||||
return;
|
||||
const movedKey = fullOrder.splice(fromIndex, 1)[0];
|
||||
const targetIndex = Math.max(0, Math.min(root.automaticVisibleItemLimit - 1, fullOrder.length));
|
||||
fullOrder.splice(targetIndex, 0, movedKey);
|
||||
SessionData.setTrayItemOrder(fullOrder);
|
||||
}
|
||||
|
||||
function isManualHiddenTrayItem(item) {
|
||||
return SessionData.isHiddenTrayId(getTrayItemKey(item));
|
||||
}
|
||||
|
||||
function isAutoOverflowTrayItem(item) {
|
||||
const key = getTrayItemKey(item);
|
||||
return key && !isManualHiddenTrayItem(item) && root.autoOverflowBarItems.some(overflowItem => getTrayItemKey(overflowItem) === key);
|
||||
}
|
||||
|
||||
function dragShiftOffset(index, draggedIndex, dropTargetIndex, shiftAmount) {
|
||||
if (draggedIndex < 0 || index === draggedIndex || dropTargetIndex < 0)
|
||||
return 0;
|
||||
if (draggedIndex < dropTargetIndex && index > draggedIndex && index <= dropTargetIndex)
|
||||
return -shiftAmount;
|
||||
if (draggedIndex > dropTargetIndex && index >= dropTargetIndex && index < draggedIndex)
|
||||
return shiftAmount;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function beginMainDrag(visualIndex, reversed) {
|
||||
root.draggedIndex = reversed ? (root.mainBarItems.length - 1 - visualIndex) : visualIndex;
|
||||
root.dropTargetIndex = root.draggedIndex;
|
||||
}
|
||||
|
||||
function updateMainDrag(axisOffset, visualIndex, reversed) {
|
||||
const itemSize = root.trayItemSize;
|
||||
const slotOffset = Math.round(axisOffset / itemSize);
|
||||
const visualTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, visualIndex + slotOffset));
|
||||
const newTargetIndex = reversed ? (root.mainBarItems.length - 1 - visualTargetIndex) : visualTargetIndex;
|
||||
if (newTargetIndex !== root.dropTargetIndex)
|
||||
root.dropTargetIndex = newTargetIndex;
|
||||
}
|
||||
|
||||
function finishMainDrag() {
|
||||
const didReorder = root.dropTargetIndex >= 0 && root.dropTargetIndex !== root.draggedIndex;
|
||||
if (didReorder) {
|
||||
root.suppressShiftAnimation = true;
|
||||
root.moveTrayItemInFullOrder(root.draggedIndex, root.dropTargetIndex);
|
||||
Qt.callLater(() => root.suppressShiftAnimation = false);
|
||||
}
|
||||
root.draggedIndex = -1;
|
||||
root.dropTargetIndex = -1;
|
||||
return didReorder;
|
||||
}
|
||||
|
||||
function beginPopupDrag(index) {
|
||||
root.popupDraggedIndex = index;
|
||||
root.popupDropTargetIndex = index;
|
||||
}
|
||||
|
||||
function updatePopupDrag(axisOffset, index) {
|
||||
const itemSize = root.trayItemSize + 6;
|
||||
const slotOffset = Math.round(axisOffset / itemSize);
|
||||
const newTargetIndex = Math.max(0, Math.min(root.hiddenBarItems.length - 1, index + slotOffset));
|
||||
if (newTargetIndex !== root.popupDropTargetIndex)
|
||||
root.popupDropTargetIndex = newTargetIndex;
|
||||
}
|
||||
|
||||
function finishPopupDrag() {
|
||||
const didReorder = root.popupDropTargetIndex >= 0 && root.popupDropTargetIndex !== root.popupDraggedIndex;
|
||||
if (didReorder) {
|
||||
const fromItem = root.hiddenBarItems[root.popupDraggedIndex];
|
||||
const toItem = root.hiddenBarItems[root.popupDropTargetIndex];
|
||||
root.suppressShiftAnimation = true;
|
||||
root.moveTrayItemKeyInFullOrder(root.getTrayItemKey(fromItem), root.getTrayItemKey(toItem));
|
||||
Qt.callLater(() => root.suppressShiftAnimation = false);
|
||||
}
|
||||
root.popupDraggedIndex = -1;
|
||||
root.popupDropTargetIndex = -1;
|
||||
return didReorder;
|
||||
}
|
||||
|
||||
property int draggedIndex: -1
|
||||
property int dropTargetIndex: -1
|
||||
property int popupDraggedIndex: -1
|
||||
property int popupDropTargetIndex: -1
|
||||
property bool suppressShiftAnimation: false
|
||||
readonly property bool hasHiddenItems: allTrayItems.length > mainBarItems.length
|
||||
readonly property bool hasHiddenItems: hiddenBarItems.length > 0
|
||||
readonly property bool inlineExpanded: hasHiddenItems && !useOverflowPopup && menuOpen
|
||||
visible: allTrayItems.length > 0
|
||||
opacity: allTrayItems.length > 0 ? 1 : 0
|
||||
@@ -351,22 +472,7 @@ BasePill {
|
||||
height: root.barThickness
|
||||
z: dragHandler.dragging ? 100 : 0
|
||||
|
||||
property real shiftOffset: {
|
||||
if (root.draggedIndex < 0)
|
||||
return 0;
|
||||
if (index === root.draggedIndex)
|
||||
return 0;
|
||||
const dragIdx = root.draggedIndex;
|
||||
const dropIdx = root.dropTargetIndex;
|
||||
const shiftAmount = root.trayItemSize;
|
||||
if (dropIdx < 0)
|
||||
return 0;
|
||||
if (dragIdx < dropIdx && index > dragIdx && index <= dropIdx)
|
||||
return -shiftAmount;
|
||||
if (dragIdx > dropIdx && index >= dropIdx && index < dragIdx)
|
||||
return shiftAmount;
|
||||
return 0;
|
||||
}
|
||||
property real shiftOffset: root.dragShiftOffset(index, root.draggedIndex, root.dropTargetIndex, root.trayItemSize)
|
||||
|
||||
transform: Translate {
|
||||
x: delegateRoot.shiftOffset
|
||||
@@ -466,19 +572,12 @@ BasePill {
|
||||
onReleased: mouse => {
|
||||
longPressTimer.stop();
|
||||
const wasDragging = dragHandler.dragging;
|
||||
const didReorder = wasDragging && root.dropTargetIndex >= 0 && root.dropTargetIndex !== root.draggedIndex;
|
||||
|
||||
if (didReorder) {
|
||||
root.suppressShiftAnimation = true;
|
||||
root.moveTrayItemInFullOrder(root.draggedIndex, root.dropTargetIndex);
|
||||
Qt.callLater(() => root.suppressShiftAnimation = false);
|
||||
}
|
||||
if (wasDragging)
|
||||
root.finishMainDrag();
|
||||
|
||||
dragHandler.longPressing = false;
|
||||
dragHandler.dragging = false;
|
||||
dragHandler.dragAxisOffset = 0;
|
||||
root.draggedIndex = -1;
|
||||
root.dropTargetIndex = -1;
|
||||
|
||||
if (wasDragging || mouse.button !== Qt.LeftButton)
|
||||
return;
|
||||
@@ -501,8 +600,7 @@ BasePill {
|
||||
const distance = Math.abs(mouse.x - dragHandler.dragStartPos.x);
|
||||
if (distance > 5) {
|
||||
dragHandler.dragging = true;
|
||||
root.draggedIndex = root.reverseInlineHorizontal ? (root.mainBarItems.length - 1 - index) : index;
|
||||
root.dropTargetIndex = root.draggedIndex;
|
||||
root.beginMainDrag(index, root.reverseInlineHorizontal);
|
||||
}
|
||||
}
|
||||
if (!dragHandler.dragging)
|
||||
@@ -510,13 +608,7 @@ BasePill {
|
||||
|
||||
const axisOffset = mouse.x - dragHandler.dragStartPos.x;
|
||||
dragHandler.dragAxisOffset = axisOffset;
|
||||
const itemSize = root.trayItemSize;
|
||||
const slotOffset = Math.round(axisOffset / itemSize);
|
||||
const visualTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
||||
const newTargetIndex = root.reverseInlineHorizontal ? (root.mainBarItems.length - 1 - visualTargetIndex) : visualTargetIndex;
|
||||
if (newTargetIndex !== root.dropTargetIndex) {
|
||||
root.dropTargetIndex = newTargetIndex;
|
||||
}
|
||||
root.updateMainDrag(axisOffset, index, root.reverseInlineHorizontal);
|
||||
}
|
||||
|
||||
onClicked: mouse => {
|
||||
@@ -706,22 +798,7 @@ BasePill {
|
||||
height: root.trayItemSize
|
||||
z: dragHandler.dragging ? 100 : 0
|
||||
|
||||
property real shiftOffset: {
|
||||
if (root.draggedIndex < 0)
|
||||
return 0;
|
||||
if (index === root.draggedIndex)
|
||||
return 0;
|
||||
const dragIdx = root.draggedIndex;
|
||||
const dropIdx = root.dropTargetIndex;
|
||||
const shiftAmount = root.trayItemSize;
|
||||
if (dropIdx < 0)
|
||||
return 0;
|
||||
if (dragIdx < dropIdx && index > dragIdx && index <= dropIdx)
|
||||
return -shiftAmount;
|
||||
if (dragIdx > dropIdx && index >= dropIdx && index < dragIdx)
|
||||
return shiftAmount;
|
||||
return 0;
|
||||
}
|
||||
property real shiftOffset: root.dragShiftOffset(index, root.draggedIndex, root.dropTargetIndex, root.trayItemSize)
|
||||
|
||||
transform: Translate {
|
||||
y: shiftOffset
|
||||
@@ -821,19 +898,12 @@ BasePill {
|
||||
onReleased: mouse => {
|
||||
longPressTimer.stop();
|
||||
const wasDragging = dragHandler.dragging;
|
||||
const didReorder = wasDragging && root.dropTargetIndex >= 0 && root.dropTargetIndex !== root.draggedIndex;
|
||||
|
||||
if (didReorder) {
|
||||
root.suppressShiftAnimation = true;
|
||||
root.moveTrayItemInFullOrder(root.draggedIndex, root.dropTargetIndex);
|
||||
Qt.callLater(() => root.suppressShiftAnimation = false);
|
||||
}
|
||||
if (wasDragging)
|
||||
root.finishMainDrag();
|
||||
|
||||
dragHandler.longPressing = false;
|
||||
dragHandler.dragging = false;
|
||||
dragHandler.dragAxisOffset = 0;
|
||||
root.draggedIndex = -1;
|
||||
root.dropTargetIndex = -1;
|
||||
|
||||
if (wasDragging || mouse.button !== Qt.LeftButton)
|
||||
return;
|
||||
@@ -856,8 +926,7 @@ BasePill {
|
||||
const distance = Math.abs(mouse.y - dragHandler.dragStartPos.y);
|
||||
if (distance > 5) {
|
||||
dragHandler.dragging = true;
|
||||
root.draggedIndex = index;
|
||||
root.dropTargetIndex = root.draggedIndex;
|
||||
root.beginMainDrag(index, false);
|
||||
}
|
||||
}
|
||||
if (!dragHandler.dragging)
|
||||
@@ -865,12 +934,7 @@ BasePill {
|
||||
|
||||
const axisOffset = mouse.y - dragHandler.dragStartPos.y;
|
||||
dragHandler.dragAxisOffset = axisOffset;
|
||||
const itemSize = root.trayItemSize;
|
||||
const slotOffset = Math.round(axisOffset / itemSize);
|
||||
const newTargetIndex = Math.max(0, Math.min(root.mainBarItems.length - 1, index + slotOffset));
|
||||
if (newTargetIndex !== root.dropTargetIndex) {
|
||||
root.dropTargetIndex = newTargetIndex;
|
||||
}
|
||||
root.updateMainDrag(axisOffset, index, false);
|
||||
}
|
||||
|
||||
onClicked: mouse => {
|
||||
@@ -1115,11 +1179,12 @@ BasePill {
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
const globalPos = root.mapToGlobal(0, 0);
|
||||
const screenX = screen.x || 0;
|
||||
const screenY = screen.y || 0;
|
||||
const relativeX = globalPos.x - screenX;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
// Window-local maps directly to screen-local because the bar window spans the
|
||||
// full screen edge; this avoids mixing mapToGlobal with a separately-tracked
|
||||
// screen.x/.y origin, which desync on non-primary monitors and after DPMS/hotplug.
|
||||
const localPos = root.mapToItem(null, 0, 0);
|
||||
const relativeX = localPos.x;
|
||||
const relativeY = localPos.y;
|
||||
|
||||
if (root.isVerticalOrientation) {
|
||||
const edge = root.axis?.edge;
|
||||
@@ -1136,20 +1201,38 @@ BasePill {
|
||||
id: menuContainer
|
||||
objectName: "overflowMenuContainer"
|
||||
|
||||
readonly property bool popupUsesVerticalLine: root.useSingleLineOverflowPopup && root.isVerticalOrientation
|
||||
readonly property real popupPadding: Theme.spacingS + (popupUsesVerticalLine ? 3 : 0)
|
||||
|
||||
readonly property real rawWidth: {
|
||||
const itemCount = root.hiddenBarItems.length;
|
||||
const cols = Math.min(5, itemCount);
|
||||
if (itemCount === 0)
|
||||
return 0;
|
||||
if (popupUsesVerticalLine)
|
||||
return root.trayItemSize + 4 + popupPadding * 2;
|
||||
const cols = root.useSingleLineOverflowPopup ? itemCount : Math.min(5, itemCount);
|
||||
const itemSize = root.trayItemSize + 4;
|
||||
const spacing = 2;
|
||||
return cols * itemSize + (cols - 1) * spacing + Theme.spacingS * 2;
|
||||
const desiredWidth = cols * itemSize + (cols - 1) * spacing + popupPadding * 2;
|
||||
if (!root.useSingleLineOverflowPopup)
|
||||
return desiredWidth;
|
||||
const maxWidth = Math.max(itemSize + popupPadding * 2, overflowMenu.maskWidth - 20);
|
||||
return Math.min(desiredWidth, maxWidth);
|
||||
}
|
||||
readonly property real rawHeight: {
|
||||
const itemCount = root.hiddenBarItems.length;
|
||||
const cols = Math.min(5, itemCount);
|
||||
const rows = Math.ceil(itemCount / cols);
|
||||
if (itemCount === 0)
|
||||
return 0;
|
||||
const itemSize = root.trayItemSize + 4;
|
||||
const spacing = 2;
|
||||
return rows * itemSize + (rows - 1) * spacing + Theme.spacingS * 2;
|
||||
if (popupUsesVerticalLine) {
|
||||
const desiredHeight = itemCount * itemSize + (itemCount - 1) * spacing + popupPadding * 2;
|
||||
const maxHeight = Math.max(itemSize + popupPadding * 2, overflowMenu.maskHeight - 20);
|
||||
return Math.min(desiredHeight, maxHeight);
|
||||
}
|
||||
const cols = root.useSingleLineOverflowPopup ? itemCount : Math.min(5, itemCount);
|
||||
const rows = Math.ceil(itemCount / cols);
|
||||
return rows * itemSize + (rows - 1) * spacing + popupPadding * 2;
|
||||
}
|
||||
|
||||
readonly property real alignedWidth: Theme.px(rawWidth, overflowMenu.dpr)
|
||||
@@ -1230,76 +1313,161 @@ BasePill {
|
||||
z: 100
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: menuGrid
|
||||
Flickable {
|
||||
anchors.centerIn: parent
|
||||
columns: Math.min(5, root.hiddenBarItems.length)
|
||||
spacing: 2
|
||||
rowSpacing: 2
|
||||
width: parent.width - menuContainer.popupPadding * 2
|
||||
height: parent.height - menuContainer.popupPadding * 2
|
||||
contentWidth: menuGrid.implicitWidth
|
||||
contentHeight: menuGrid.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
interactive: root.useSingleLineOverflowPopup && (menuContainer.popupUsesVerticalLine ? contentHeight > height : contentWidth > width)
|
||||
|
||||
Repeater {
|
||||
model: root.hiddenBarItems
|
||||
Grid {
|
||||
id: menuGrid
|
||||
anchors.verticalCenter: menuContainer.popupUsesVerticalLine ? undefined : parent.verticalCenter
|
||||
anchors.horizontalCenter: menuContainer.popupUsesVerticalLine ? parent.horizontalCenter : undefined
|
||||
columns: menuContainer.popupUsesVerticalLine ? 1 : (root.useSingleLineOverflowPopup ? root.hiddenBarItems.length : Math.min(5, root.hiddenBarItems.length))
|
||||
spacing: 2
|
||||
rowSpacing: 2
|
||||
|
||||
delegate: Rectangle {
|
||||
property var trayItem: modelData
|
||||
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||
Repeater {
|
||||
model: root.hiddenBarItems
|
||||
|
||||
width: root.trayItemSize + 4
|
||||
height: root.trayItemSize + 4
|
||||
radius: Theme.cornerRadius
|
||||
color: itemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
|
||||
delegate: Rectangle {
|
||||
id: overflowItemRoot
|
||||
property var trayItem: modelData
|
||||
property string itemKey: root.getTrayItemKey(trayItem)
|
||||
property string iconSource: root.trayIconSourceFor(trayItem)
|
||||
|
||||
IconImage {
|
||||
id: menuIconImg
|
||||
anchors.centerIn: parent
|
||||
width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
source: parent.iconSource
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
mipmap: true
|
||||
visible: status === Image.Ready
|
||||
layer.enabled: root.trayIconTintEnabled
|
||||
layer.effect: MultiEffect {
|
||||
saturation: root.trayIconSaturation
|
||||
colorization: root.trayIconColorization
|
||||
colorizationColor: root.trayIconTintColor
|
||||
}
|
||||
}
|
||||
width: root.trayItemSize + 4
|
||||
height: root.trayItemSize + 4
|
||||
z: popupDragHandler.dragging ? 100 : 0
|
||||
radius: Theme.cornerRadius
|
||||
color: itemArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : Theme.withAlpha(Theme.surfaceContainer, 0)
|
||||
border.width: popupDragHandler.dragging ? 2 : 0
|
||||
border.color: Theme.primary
|
||||
opacity: popupDragHandler.dragging ? 0.8 : 1.0
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
visible: !menuIconImg.visible
|
||||
text: {
|
||||
const itemId = trayItem?.id || "";
|
||||
if (!itemId)
|
||||
return "?";
|
||||
return itemId.charAt(0).toUpperCase();
|
||||
}
|
||||
font.pixelSize: 10
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
property real shiftOffset: root.dragShiftOffset(index, root.popupDraggedIndex, root.popupDropTargetIndex, root.trayItemSize + 6)
|
||||
|
||||
MouseArea {
|
||||
id: itemArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: mouse => {
|
||||
if (!trayItem)
|
||||
return;
|
||||
if (mouse.button === Qt.LeftButton && !trayItem.onlyMenu) {
|
||||
trayItem.activate();
|
||||
root.menuOpen = false;
|
||||
return;
|
||||
transform: Translate {
|
||||
x: !menuContainer.popupUsesVerticalLine ? overflowItemRoot.shiftOffset + (popupDragHandler.dragging ? popupDragHandler.dragAxisOffset : 0) : 0
|
||||
y: menuContainer.popupUsesVerticalLine ? overflowItemRoot.shiftOffset + (popupDragHandler.dragging ? popupDragHandler.dragAxisOffset : 0) : 0
|
||||
Behavior on x {
|
||||
enabled: !root.suppressShiftAnimation && !menuContainer.popupUsesVerticalLine
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
if (!trayItem.hasMenu) {
|
||||
const gp = itemArea.mapToGlobal(mouse.x, mouse.y);
|
||||
root.callContextMenuFallback(trayItem.id, Math.round(gp.x), Math.round(gp.y));
|
||||
return;
|
||||
Behavior on y {
|
||||
enabled: !root.suppressShiftAnimation && menuContainer.popupUsesVerticalLine
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: popupDragHandler
|
||||
anchors.fill: parent
|
||||
property bool dragging: false
|
||||
property point dragStartPos: Qt.point(0, 0)
|
||||
property real dragAxisOffset: 0
|
||||
property bool longPressing: false
|
||||
|
||||
Timer {
|
||||
id: popupLongPressTimer
|
||||
interval: 400
|
||||
repeat: false
|
||||
onTriggered: popupDragHandler.longPressing = true
|
||||
}
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: menuIconImg
|
||||
anchors.centerIn: parent
|
||||
width: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
height: Theme.barIconSize(root.barThickness, undefined, root.barConfig?.maximizeWidgetIcons, root.barConfig?.iconScale)
|
||||
source: parent.iconSource
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
mipmap: true
|
||||
visible: status === Image.Ready
|
||||
layer.enabled: root.trayIconTintEnabled
|
||||
layer.effect: MultiEffect {
|
||||
saturation: root.trayIconSaturation
|
||||
colorization: root.trayIconColorization
|
||||
colorizationColor: root.trayIconTintColor
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
visible: !menuIconImg.visible
|
||||
text: {
|
||||
const itemId = trayItem?.id || "";
|
||||
if (!itemId)
|
||||
return "?";
|
||||
return itemId.charAt(0).toUpperCase();
|
||||
}
|
||||
font.pixelSize: 10
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: itemArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: popupDragHandler.longPressing ? Qt.DragMoveCursor : Qt.PointingHandCursor
|
||||
onPressed: mouse => {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
popupDragHandler.dragStartPos = Qt.point(mouse.x, mouse.y);
|
||||
popupLongPressTimer.start();
|
||||
}
|
||||
}
|
||||
onReleased: mouse => {
|
||||
popupLongPressTimer.stop();
|
||||
const wasDragging = popupDragHandler.dragging;
|
||||
if (wasDragging)
|
||||
root.finishPopupDrag();
|
||||
|
||||
popupDragHandler.longPressing = false;
|
||||
popupDragHandler.dragging = false;
|
||||
popupDragHandler.dragAxisOffset = 0;
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
const axisDelta = menuContainer.popupUsesVerticalLine ? (mouse.y - popupDragHandler.dragStartPos.y) : (mouse.x - popupDragHandler.dragStartPos.x);
|
||||
if (popupDragHandler.longPressing && !popupDragHandler.dragging && Math.abs(axisDelta) > 5) {
|
||||
popupDragHandler.dragging = true;
|
||||
root.beginPopupDrag(index);
|
||||
}
|
||||
if (!popupDragHandler.dragging)
|
||||
return;
|
||||
|
||||
popupDragHandler.dragAxisOffset = axisDelta;
|
||||
root.updatePopupDrag(axisDelta, index);
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (popupDragHandler.dragging)
|
||||
return;
|
||||
if (!trayItem)
|
||||
return;
|
||||
if (mouse.button === Qt.LeftButton && !trayItem.onlyMenu) {
|
||||
trayItem.activate();
|
||||
root.menuOpen = false;
|
||||
return;
|
||||
}
|
||||
if (!trayItem.hasMenu) {
|
||||
const gp = itemArea.mapToGlobal(mouse.x, mouse.y);
|
||||
root.callContextMenuFallback(trayItem.id, Math.round(gp.x), Math.round(gp.y));
|
||||
return;
|
||||
}
|
||||
root.showForTrayItem(trayItem, menuContainer, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
root.showForTrayItem(trayItem, menuContainer, parentScreen, root.isAtBottom, root.isVerticalOrientation, root.axis);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1555,11 +1723,13 @@ BasePill {
|
||||
anchorPos = Qt.point(targetX, targetY);
|
||||
}
|
||||
} else {
|
||||
const globalPos = targetItem.mapToGlobal(0, 0);
|
||||
const screenX = screen.x || 0;
|
||||
const screenY = screen.y || 0;
|
||||
const relativeX = globalPos.x - screenX;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
// Window-local maps directly to screen-local because the bar window spans
|
||||
// the full screen edge; this avoids mixing mapToGlobal with a separately-
|
||||
// tracked screen.x/.y origin, which desync on non-primary monitors and after
|
||||
// DPMS/hotplug.
|
||||
const localPos = targetItem.mapToItem(null, 0, 0);
|
||||
const relativeX = localPos.x;
|
||||
const relativeY = localPos.y;
|
||||
|
||||
if (menuRoot.isVertical) {
|
||||
const edge = menuRoot.axis?.edge;
|
||||
@@ -1695,7 +1865,12 @@ BasePill {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: menuRoot.trayItem?.id || "Unknown"
|
||||
text: {
|
||||
const itemId = menuRoot.trayItem?.id || "Unknown";
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
|
||||
return itemId + " · " + I18n.tr("Keep in Bar");
|
||||
return itemId;
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
elide: Text.ElideMiddle
|
||||
@@ -1706,7 +1881,11 @@ BasePill {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
name: SessionData.isHiddenTrayId(root.getTrayItemKey(menuRoot.trayItem)) ? "visibility" : "visibility_off"
|
||||
name: {
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem))
|
||||
return "push_pin";
|
||||
return root.isManualHiddenTrayItem(menuRoot.trayItem) ? "visibility" : "visibility_off";
|
||||
}
|
||||
size: 16
|
||||
color: Theme.widgetTextColor
|
||||
}
|
||||
@@ -1720,7 +1899,9 @@ BasePill {
|
||||
const itemKey = root.getTrayItemKey(menuRoot.trayItem);
|
||||
if (!itemKey)
|
||||
return;
|
||||
if (SessionData.isHiddenTrayId(itemKey)) {
|
||||
if (root.isAutoOverflowTrayItem(menuRoot.trayItem)) {
|
||||
root.promoteTrayItemToBar(menuRoot.trayItem);
|
||||
} else if (root.isManualHiddenTrayItem(menuRoot.trayItem)) {
|
||||
SessionData.showTrayId(itemKey);
|
||||
} else {
|
||||
SessionData.hideTrayId(itemKey);
|
||||
|
||||
@@ -106,18 +106,15 @@ BasePill {
|
||||
}
|
||||
|
||||
if (root.isVerticalOrientation) {
|
||||
const globalPos = mapToGlobal(width / 2, height / 2);
|
||||
const localPos = mapToItem(null, width / 2, height / 2);
|
||||
const currentScreen = root.parentScreen || Screen;
|
||||
const screenX = currentScreen ? currentScreen.x : 0;
|
||||
const screenY = currentScreen ? currentScreen.y : 0;
|
||||
const relativeY = globalPos.y - screenY;
|
||||
const adjustedY = relativeY + root.minTooltipY;
|
||||
const adjustedY = localPos.y + root.minTooltipY;
|
||||
const tooltipX = root.axis?.edge === "left" ? (root.barThickness + root.barSpacing + Theme.spacingXS) : (currentScreen.width - root.barThickness - root.barSpacing - Theme.spacingXS);
|
||||
const isLeft = root.axis?.edge === "left";
|
||||
tooltipLoader.item.show(tooltipText, screenX + tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
|
||||
tooltipLoader.item.show(tooltipText, tooltipX, adjustedY, currentScreen, isLeft, !isLeft);
|
||||
} else {
|
||||
const isBottom = root.axis?.edge === "bottom";
|
||||
const globalPos = mapToGlobal(width / 2, 0);
|
||||
const localPos = mapToItem(null, width / 2, 0);
|
||||
const currentScreen = root.parentScreen || Screen;
|
||||
|
||||
let tooltipY;
|
||||
@@ -128,7 +125,7 @@ BasePill {
|
||||
tooltipY = root.barThickness + root.barSpacing + Theme.spacingXS;
|
||||
}
|
||||
|
||||
tooltipLoader.item.show(tooltipText, globalPos.x, tooltipY, currentScreen, false, false);
|
||||
tooltipLoader.item.show(tooltipText, localPos.x, tooltipY, currentScreen, false, false);
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
|
||||
@@ -9,9 +9,8 @@ BasePill {
|
||||
|
||||
visible: SettingsData.weatherEnabled
|
||||
|
||||
Ref {
|
||||
service: WeatherService
|
||||
}
|
||||
Component.onCompleted: WeatherService.addRef()
|
||||
Component.onDestruction: WeatherService.removeRef()
|
||||
|
||||
content: Component {
|
||||
Item {
|
||||
|
||||
@@ -1192,38 +1192,25 @@ Item {
|
||||
return Math.max(baseHeight + iconsExtraHeight, contentImplicitHeight + padding);
|
||||
}
|
||||
|
||||
readonly property color unfocusedColor: {
|
||||
switch (SettingsData.workspaceUnfocusedColorMode) {
|
||||
case "s":
|
||||
return Theme.surface;
|
||||
case "sc":
|
||||
return Theme.surfaceContainer;
|
||||
case "sch":
|
||||
return Theme.surfaceContainerHigh;
|
||||
default:
|
||||
return Theme.surfaceTextAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color activeColor: {
|
||||
switch (SettingsData.workspaceColorMode) {
|
||||
case "s":
|
||||
return Theme.surface;
|
||||
case "sc":
|
||||
return Theme.surfaceContainer;
|
||||
case "sch":
|
||||
return Theme.surfaceContainerHigh;
|
||||
case "none":
|
||||
return unfocusedColor;
|
||||
default:
|
||||
function colorFromMode(mode, fallbackColor, customColor, customFallbackColor) {
|
||||
switch (mode) {
|
||||
case "primary":
|
||||
case "pri":
|
||||
return Theme.primary;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color occupiedColor: {
|
||||
switch (SettingsData.workspaceOccupiedColorMode) {
|
||||
case "primaryContainer":
|
||||
return Theme.primaryContainer;
|
||||
case "secondary":
|
||||
case "sec":
|
||||
return Theme.secondary;
|
||||
case "secondaryContainer":
|
||||
return Theme.secondaryContainer;
|
||||
case "tertiary":
|
||||
case "ter":
|
||||
return Theme.tertiary;
|
||||
case "tertiaryContainer":
|
||||
return Theme.tertiaryContainer;
|
||||
case "surfaceText":
|
||||
return Theme.surfaceText;
|
||||
case "s":
|
||||
return Theme.surface;
|
||||
case "sc":
|
||||
@@ -1232,37 +1219,34 @@ Item {
|
||||
return Theme.surfaceContainerHigh;
|
||||
case "schh":
|
||||
return Theme.surfaceContainerHighest;
|
||||
default:
|
||||
return unfocusedColor;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color urgentColor: {
|
||||
switch (SettingsData.workspaceUrgentColorMode) {
|
||||
case "primary":
|
||||
return Theme.primary;
|
||||
case "secondary":
|
||||
return Theme.secondary;
|
||||
case "s":
|
||||
return Theme.surface;
|
||||
case "sc":
|
||||
return Theme.surfaceContainer;
|
||||
default:
|
||||
case "error":
|
||||
case "err":
|
||||
return Theme.error;
|
||||
case "custom":
|
||||
return Theme.safeColor(customColor, customFallbackColor);
|
||||
default:
|
||||
return fallbackColor;
|
||||
}
|
||||
}
|
||||
|
||||
readonly property color focusedBorderColor: {
|
||||
switch (SettingsData.workspaceFocusedBorderColor) {
|
||||
case "surfaceText":
|
||||
return Theme.surfaceText;
|
||||
case "secondary":
|
||||
return Theme.secondary;
|
||||
default:
|
||||
return Theme.primary;
|
||||
}
|
||||
readonly property color unfocusedColor: colorFromMode(SettingsData.workspaceUnfocusedColorMode, Theme.surfaceTextAlpha, SettingsData.workspaceUnfocusedCustomColor, Theme.surfaceTextAlpha)
|
||||
|
||||
readonly property color activeColor: {
|
||||
if (SettingsData.workspaceColorMode === "none")
|
||||
return unfocusedColor;
|
||||
return colorFromMode(SettingsData.workspaceColorMode, Theme.primary, SettingsData.workspaceFocusedCustomColor, Theme.primary);
|
||||
}
|
||||
|
||||
readonly property color occupiedColor: {
|
||||
if (SettingsData.workspaceOccupiedColorMode === "none")
|
||||
return unfocusedColor;
|
||||
return colorFromMode(SettingsData.workspaceOccupiedColorMode, unfocusedColor, SettingsData.workspaceOccupiedCustomColor, Theme.secondary);
|
||||
}
|
||||
|
||||
readonly property color urgentColor: colorFromMode(SettingsData.workspaceUrgentColorMode, Theme.error, SettingsData.workspaceUrgentCustomColor, Theme.error)
|
||||
|
||||
readonly property color focusedBorderColor: colorFromMode(SettingsData.workspaceFocusedBorderColor, Theme.primary, SettingsData.workspaceFocusedBorderCustomColor, Theme.primary)
|
||||
|
||||
function getContrastingIconColor(bgColor) {
|
||||
const luminance = 0.299 * bgColor.r + 0.587 * bgColor.g + 0.114 * bgColor.b;
|
||||
return luminance > 0.4 ? Qt.rgba(0.15, 0.15, 0.15, 1) : Qt.rgba(0.8, 0.8, 0.8, 1);
|
||||
|
||||
@@ -108,9 +108,6 @@ DankPopout {
|
||||
MprisController.setActivePlayer(player);
|
||||
root.__hideDropdowns();
|
||||
}
|
||||
onDeviceSelected: device => {
|
||||
root.__hideDropdowns();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +227,13 @@ DankPopout {
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.currentTabIndex === 0 && overviewLoader.item?.handleKeyEvent) {
|
||||
if (overviewLoader.item.handleKeyEvent(event)) {
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (root.currentTabIndex === 1 && mediaLoader.item?.handleKeyEvent) {
|
||||
if (mediaLoader.item.handleKeyEvent(event)) {
|
||||
event.accepted = true;
|
||||
@@ -359,6 +363,7 @@ DankPopout {
|
||||
sourceComponent: Component {
|
||||
OverviewTab {
|
||||
onCloseDash: root.dashVisible = false
|
||||
onNavFocusRequested: mainContainer.forceActiveFocus()
|
||||
onSwitchToWeatherTab: {
|
||||
if (SettingsData.weatherEnabled) {
|
||||
root.currentTabIndex = 3;
|
||||
|
||||
@@ -383,7 +383,27 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onPressed: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
}
|
||||
onWheel: wheelEvent => {
|
||||
if (SettingsData.audioDeviceScrollVolumeEnabled && wheelEvent.x >= deviceMouseArea.width / 2) {
|
||||
AudioService.handleNodeVolumeWheel(modelData, wheelEvent);
|
||||
} else {
|
||||
wheelEvent.accepted = false;
|
||||
}
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
if (modelData && modelData.audio) {
|
||||
SessionData.suppressOSDTemporarily();
|
||||
modelData.audio.muted = !modelData.audio.muted;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (modelData && modelData.name) {
|
||||
AudioService.setDefaultSinkByName(modelData.name);
|
||||
root.deviceSelected(modelData);
|
||||
|
||||
@@ -866,7 +866,27 @@ Item {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onPressed: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
mouse.accepted = true;
|
||||
}
|
||||
}
|
||||
onWheel: wheelEvent => {
|
||||
const delta = wheelEvent.angleDelta.y;
|
||||
if (delta !== 0) {
|
||||
AudioService.cycleAudioOutputDirection(delta < 0);
|
||||
wheelEvent.accepted = true;
|
||||
}
|
||||
}
|
||||
onClicked: mouse => {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
if (AudioService.sink?.audio) {
|
||||
SessionData.suppressOSDTemporarily();
|
||||
AudioService.sink.audio.muted = !AudioService.sink.audio.muted;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (devicesExpanded) {
|
||||
const sinks = AudioService.getAvailableSinks();
|
||||
if (sinks && sinks.length > 1) {
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
property var eventData: null
|
||||
property bool canEdit: false
|
||||
|
||||
signal editRequested
|
||||
signal deleteRequested
|
||||
signal closeRequested
|
||||
|
||||
readonly property bool _descriptionIsHtml: /<[a-z][^>]*>/i.test((eventData && eventData.description) || "")
|
||||
|
||||
function _styleAnchors(html) {
|
||||
return html.replace(/<a\s([^>]*)>/gi, (m, attrs) => {
|
||||
const cleaned = attrs.replace(/style="[^"]*"/gi, "");
|
||||
return "<a style=\"text-decoration:none; color:" + Theme.primary + ";\" " + cleaned + ">";
|
||||
});
|
||||
}
|
||||
|
||||
function _inlineMarkdown(line) {
|
||||
let out = line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
out = out.replace(/\\([\\`*_{}[\]()#+\-.!~>])/g, "$1");
|
||||
out = out.replace(/(?:https?:\/\/|www\.)[^\s<>)\]]*[^\s<>)\].,;:!?"']/g, (m, offset, s) => {
|
||||
const prev = offset > 0 ? s[offset - 1] : "";
|
||||
if (prev === "(" || prev === "[" || prev === "\"" || prev === "'")
|
||||
return m;
|
||||
const href = m.startsWith("www.") ? "https://" + m : m;
|
||||
return "<a href=\"" + href + "\">" + m + "</a>";
|
||||
});
|
||||
out = out.replace(/\[([^\]]+)\]\(([^()\s]+)\)/g, "<a href=\"$2\">$1</a>");
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
|
||||
out = out.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>");
|
||||
return out;
|
||||
}
|
||||
|
||||
// Descriptions arrive as HTML (Google) or markdown/plain text; both render
|
||||
// as RichText so links become clickable anchors recolored to the theme.
|
||||
function _descriptionRichText() {
|
||||
const raw = ((eventData && eventData.description) || "").trim();
|
||||
if (raw === "")
|
||||
return "";
|
||||
if (_descriptionIsHtml)
|
||||
return _styleAnchors(raw);
|
||||
|
||||
const parts = [];
|
||||
let list = "";
|
||||
const closeList = () => {
|
||||
if (list === "")
|
||||
return;
|
||||
parts.push("</" + list + ">");
|
||||
list = "";
|
||||
};
|
||||
|
||||
const lines = raw.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const ul = lines[i].match(/^\s*[-*+]\s+(.+)$/);
|
||||
const ol = lines[i].match(/^\s*\d+[.)]\s+(.+)$/);
|
||||
if (ul || ol) {
|
||||
const tag = ul ? "ul" : "ol";
|
||||
if (list !== tag) {
|
||||
closeList();
|
||||
parts.push("<" + tag + ">");
|
||||
list = tag;
|
||||
}
|
||||
parts.push("<li>" + _inlineMarkdown((ul || ol)[1]) + "</li>");
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
parts.push(_inlineMarkdown(lines[i]) + "<br/>");
|
||||
}
|
||||
closeList();
|
||||
return _styleAnchors(parts.join("").replace(/<br\/>$/, ""));
|
||||
}
|
||||
|
||||
function _timeText() {
|
||||
if (!eventData)
|
||||
return "";
|
||||
const dateStr = Qt.formatDate(eventData.start, "ddd, MMM d");
|
||||
if (eventData.allDay)
|
||||
return I18n.tr("All day") + " · " + dateStr;
|
||||
const fmt = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP";
|
||||
const startStr = Qt.formatTime(eventData.start, fmt);
|
||||
if (eventData.start.getTime() === eventData.end.getTime())
|
||||
return dateStr + " · " + startStr;
|
||||
return dateStr + " · " + startStr + " – " + Qt.formatTime(eventData.end, fmt);
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(0, 0, 0, 0.45)
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.closeRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - Theme.spacingL * 2, 380)
|
||||
height: Math.min(parent.height - Theme.spacingM * 2, body.implicitHeight + Theme.spacingL * 2)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: 1
|
||||
clip: true
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: closeButton
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Theme.spacingXS
|
||||
circular: false
|
||||
iconName: "close"
|
||||
iconSize: 16
|
||||
z: 1
|
||||
onClicked: root.closeRequested()
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
anchors.topMargin: Theme.spacingL
|
||||
contentWidth: width
|
||||
contentHeight: body.implicitHeight
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: body
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Rectangle {
|
||||
width: 4
|
||||
height: titleText.implicitHeight
|
||||
radius: 2
|
||||
anchors.top: parent.top
|
||||
color: (root.eventData && root.eventData.color) ? root.eventData.color : Theme.primary
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: titleText
|
||||
width: parent.width - 4 - Theme.spacingS - closeButton.width
|
||||
text: root.eventData ? root.eventData.title : ""
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 3
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: root._timeText()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: root.eventData && root.eventData.calendar
|
||||
|
||||
DankIcon {
|
||||
name: "calendar_month"
|
||||
size: 14
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 2
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width - 14 - Theme.spacingXS
|
||||
text: {
|
||||
if (!root.eventData)
|
||||
return "";
|
||||
const acc = root.eventData.account || "";
|
||||
return root.eventData.calendar + (acc ? " · " + acc : "");
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: root.eventData && root.eventData.location
|
||||
|
||||
DankIcon {
|
||||
name: "place"
|
||||
size: 14
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 2
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width - 14 - Theme.spacingXS
|
||||
text: root.eventData ? root.eventData.location : ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: root.eventData && root.eventData.url
|
||||
|
||||
DankIcon {
|
||||
name: "link"
|
||||
size: 14
|
||||
color: Theme.primary
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 2
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width - 14 - Theme.spacingXS
|
||||
text: root.eventData ? root.eventData.url : ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
wrapMode: Text.WrapAnywhere
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.eventData && root.eventData.url)
|
||||
Qt.openUrlExternally(root.eventData.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: descriptionText
|
||||
width: parent.width
|
||||
text: root._descriptionRichText()
|
||||
visible: root.eventData && root.eventData.description
|
||||
textFormat: Text.RichText
|
||||
linkColor: Theme.primary
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
wrapMode: Text.Wrap
|
||||
onLinkActivated: link => Qt.openUrlExternally(link)
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: descriptionText.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
visible: root.canEdit
|
||||
topPadding: Theme.spacingXS
|
||||
|
||||
DankButton {
|
||||
text: I18n.tr("Edit")
|
||||
iconName: "edit"
|
||||
buttonHeight: 32
|
||||
onClicked: root.editRequested()
|
||||
}
|
||||
|
||||
DankButton {
|
||||
text: I18n.tr("Delete")
|
||||
iconName: "delete"
|
||||
buttonHeight: 32
|
||||
backgroundColor: Theme.withAlpha(Theme.error, 0.15)
|
||||
textColor: Theme.error
|
||||
onClicked: root.deleteRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
property var eventData: null
|
||||
property date initialDate: new Date()
|
||||
|
||||
signal saved
|
||||
signal closeRequested
|
||||
|
||||
property string fTitle: ""
|
||||
property bool fAllDay: false
|
||||
property date fDate: initialDate
|
||||
property string fStart: "10:00"
|
||||
property string fEnd: "11:00"
|
||||
property string fLocation: ""
|
||||
property string fDescription: ""
|
||||
property string fCalendarId: ""
|
||||
property int fReminder: -1
|
||||
property string errorText: ""
|
||||
property bool saving: false
|
||||
|
||||
readonly property var _cals: CalendarService.writableCalendars()
|
||||
readonly property var _remLabels: [I18n.tr("No reminder"), I18n.tr("At start"), I18n.tr("5 min before"), I18n.tr("10 min before"), I18n.tr("15 min before"), I18n.tr("30 min before"), I18n.tr("1 hour before"), I18n.tr("1 day before")]
|
||||
readonly property var _remMins: [-1, 0, 5, 10, 15, 30, 60, 1440]
|
||||
|
||||
function _parseTime(value) {
|
||||
const m = value.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!m)
|
||||
return null;
|
||||
const h = parseInt(m[1]);
|
||||
const min = parseInt(m[2]);
|
||||
if (h > 23 || min > 59)
|
||||
return null;
|
||||
return {
|
||||
"h": h,
|
||||
"m": min
|
||||
};
|
||||
}
|
||||
|
||||
function _isoFromDateTime(dateObj, h, m) {
|
||||
const d = new Date(dateObj);
|
||||
d.setHours(h, m, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function _allDayIso(dateObj, dayOffset) {
|
||||
return new Date(Date.UTC(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate() + dayOffset)).toISOString();
|
||||
}
|
||||
|
||||
function _calendarName(id) {
|
||||
for (let i = 0; i < _cals.length; i++) {
|
||||
if (_cals[i].id === id)
|
||||
return _cals[i].name;
|
||||
}
|
||||
return _cals.length > 0 ? _cals[0].name : "";
|
||||
}
|
||||
|
||||
function save() {
|
||||
const title = fTitle.trim();
|
||||
if (!title) {
|
||||
errorText = I18n.tr("Title is required");
|
||||
return;
|
||||
}
|
||||
let calId = fCalendarId;
|
||||
if (!calId) {
|
||||
const def = CalendarService.defaultCalendar();
|
||||
calId = def ? def.id : "";
|
||||
}
|
||||
if (!calId) {
|
||||
errorText = I18n.tr("No writable calendar available");
|
||||
return;
|
||||
}
|
||||
let startIso, endIso;
|
||||
if (fAllDay) {
|
||||
startIso = _allDayIso(fDate, 0);
|
||||
endIso = _allDayIso(fDate, 1);
|
||||
} else {
|
||||
const s = _parseTime(fStart);
|
||||
const e = _parseTime(fEnd);
|
||||
if (!s || !e) {
|
||||
errorText = I18n.tr("Use HH:MM time format");
|
||||
return;
|
||||
}
|
||||
startIso = _isoFromDateTime(fDate, s.h, s.m);
|
||||
endIso = _isoFromDateTime(fDate, e.h, e.m);
|
||||
if (new Date(endIso).getTime() <= new Date(startIso).getTime()) {
|
||||
errorText = I18n.tr("End must be after start");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const fields = {
|
||||
"calendarId": calId,
|
||||
"summary": title,
|
||||
"description": fDescription,
|
||||
"location": fLocation,
|
||||
"start": startIso,
|
||||
"end": endIso,
|
||||
"allDay": fAllDay,
|
||||
"reminders": fReminder >= 0 ? [
|
||||
{
|
||||
"method": "popup",
|
||||
"minutes": fReminder
|
||||
}
|
||||
] : []
|
||||
};
|
||||
saving = true;
|
||||
errorText = "";
|
||||
const cb = response => {
|
||||
saving = false;
|
||||
if (response.error) {
|
||||
errorText = response.error;
|
||||
return;
|
||||
}
|
||||
root.saved();
|
||||
};
|
||||
if (eventData && eventData.id)
|
||||
CalendarService.updateEvent(eventData.id, fields, cb);
|
||||
else
|
||||
CalendarService.createEvent(fields, cb);
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (!eventData) {
|
||||
fCalendarId = CalendarService.defaultCalendar() ? CalendarService.defaultCalendar().id : "";
|
||||
return;
|
||||
}
|
||||
fTitle = eventData.title || "";
|
||||
fAllDay = !!eventData.allDay;
|
||||
fDate = eventData.start;
|
||||
const fmt = "HH:mm";
|
||||
fStart = Qt.formatTime(eventData.start, fmt);
|
||||
fEnd = Qt.formatTime(eventData.end, fmt);
|
||||
fLocation = eventData.location || "";
|
||||
fDescription = eventData.description || "";
|
||||
fCalendarId = eventData.calendarId || "";
|
||||
if (eventData.reminders && eventData.reminders.length > 0)
|
||||
fReminder = eventData.reminders[0].minutes;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(0, 0, 0, 0.45)
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.closeRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - Theme.spacingL * 2, 400)
|
||||
height: Math.min(parent.height - Theme.spacingM, 300)
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: 1
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
contentWidth: width
|
||||
contentHeight: form.implicitHeight
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: form
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: root.eventData ? I18n.tr("Edit event") : I18n.tr("New event")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
labelText: I18n.tr("Title")
|
||||
leftIconName: "title"
|
||||
leftIconSize: Theme.iconSize - 6
|
||||
placeholderText: I18n.tr("Event title")
|
||||
text: root.fTitle
|
||||
onTextChanged: root.fTitle = text
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
width: parent.width
|
||||
text: I18n.tr("All day")
|
||||
checked: root.fAllDay
|
||||
onToggled: checked => root.fAllDay = checked
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankActionButton {
|
||||
circular: false
|
||||
iconName: "chevron_left"
|
||||
iconSize: 16
|
||||
onClicked: {
|
||||
let d = new Date(root.fDate);
|
||||
d.setDate(d.getDate() - 1);
|
||||
root.fDate = d;
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width - 72
|
||||
text: Qt.formatDate(root.fDate, "ddd, MMM d yyyy")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
height: 32
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
circular: false
|
||||
iconName: "chevron_right"
|
||||
iconSize: 16
|
||||
onClicked: {
|
||||
let d = new Date(root.fDate);
|
||||
d.setDate(d.getDate() + 1);
|
||||
root.fDate = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
visible: !root.fAllDay
|
||||
|
||||
DankTextField {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
labelText: I18n.tr("Start")
|
||||
leftIconName: "schedule"
|
||||
leftIconSize: Theme.iconSize - 6
|
||||
placeholderText: "HH:MM"
|
||||
text: root.fStart
|
||||
onTextChanged: root.fStart = text
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: (parent.width - Theme.spacingS) / 2
|
||||
labelText: I18n.tr("End")
|
||||
placeholderText: "HH:MM"
|
||||
text: root.fEnd
|
||||
onTextChanged: root.fEnd = text
|
||||
}
|
||||
}
|
||||
|
||||
DankDropdown {
|
||||
width: parent.width
|
||||
text: I18n.tr("Calendar")
|
||||
options: root._cals.map(c => c.name)
|
||||
currentValue: root._calendarName(root.fCalendarId)
|
||||
onValueChanged: value => {
|
||||
for (let i = 0; i < root._cals.length; i++) {
|
||||
if (root._cals[i].name === value) {
|
||||
root.fCalendarId = root._cals[i].id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DankDropdown {
|
||||
width: parent.width
|
||||
text: I18n.tr("Reminder")
|
||||
options: root._remLabels
|
||||
currentValue: root._remLabels[Math.max(0, root._remMins.indexOf(root.fReminder))]
|
||||
onValueChanged: value => {
|
||||
const idx = root._remLabels.indexOf(value);
|
||||
if (idx >= 0)
|
||||
root.fReminder = root._remMins[idx];
|
||||
}
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
labelText: I18n.tr("Location")
|
||||
leftIconName: "place"
|
||||
leftIconSize: Theme.iconSize - 6
|
||||
placeholderText: I18n.tr("Add location")
|
||||
text: root.fLocation
|
||||
onTextChanged: root.fLocation = text
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
labelText: I18n.tr("Notes")
|
||||
leftIconName: "notes"
|
||||
leftIconSize: Theme.iconSize - 6
|
||||
placeholderText: I18n.tr("Add notes")
|
||||
text: root.fDescription
|
||||
onTextChanged: root.fDescription = text
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: root.errorText
|
||||
visible: root.errorText !== ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.error
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankButton {
|
||||
text: root.saving ? I18n.tr("Saving…") : I18n.tr("Save")
|
||||
iconName: "check"
|
||||
buttonHeight: 32
|
||||
backgroundColor: Theme.primary
|
||||
textColor: Theme.primaryText
|
||||
enabled: !root.saving
|
||||
onClicked: root.save()
|
||||
}
|
||||
|
||||
DankButton {
|
||||
text: I18n.tr("Cancel")
|
||||
buttonHeight: 32
|
||||
onClicked: root.closeRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,21 @@ Rectangle {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("CalendarOverviewCard")
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
implicitWidth: SettingsData.showWeekNumber ? 736 : 700
|
||||
|
||||
property bool showEventDetails: false
|
||||
property date selectedDate: systemClock.date
|
||||
property var selectedDateEvents: []
|
||||
property bool hasEvents: selectedDateEvents && selectedDateEvents.length > 0
|
||||
property var detailEvent: null
|
||||
property bool showEditor: false
|
||||
property var editorEvent: null
|
||||
|
||||
signal closeDash
|
||||
signal navFocusRequested
|
||||
|
||||
function weekStartQt() {
|
||||
if (SettingsData.firstDayOfWeek >= 7 || SettingsData.firstDayOfWeek < 0) {
|
||||
@@ -79,7 +86,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
function updateSelectedDateEvents() {
|
||||
if (CalendarService && CalendarService.khalAvailable) {
|
||||
if (CalendarService && CalendarService.calendarAvailable) {
|
||||
const events = CalendarService.getEventsForDate(selectedDate);
|
||||
selectedDateEvents = events;
|
||||
} else {
|
||||
@@ -88,7 +95,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
function loadEventsForMonth() {
|
||||
if (!CalendarService || !CalendarService.khalAvailable) {
|
||||
if (!CalendarService || !CalendarService.calendarAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -104,11 +111,83 @@ Rectangle {
|
||||
CalendarService.loadEvents(startDate, endDate);
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const now = systemClock.date;
|
||||
calendarGrid.selectedDate = now;
|
||||
calendarGrid.displayDate = now;
|
||||
root.selectedDate = now;
|
||||
loadEventsForMonth();
|
||||
}
|
||||
|
||||
function moveSelection(days) {
|
||||
let d = new Date(calendarGrid.selectedDate);
|
||||
d.setDate(d.getDate() + days);
|
||||
calendarGrid.selectedDate = d;
|
||||
root.selectedDate = d;
|
||||
if (d.getMonth() !== calendarGrid.displayDate.getMonth() || d.getFullYear() !== calendarGrid.displayDate.getFullYear()) {
|
||||
calendarGrid.displayDate = d;
|
||||
loadEventsForMonth();
|
||||
}
|
||||
}
|
||||
|
||||
function shiftMonth(delta) {
|
||||
let d = new Date(calendarGrid.displayDate);
|
||||
d.setMonth(d.getMonth() + delta);
|
||||
calendarGrid.displayDate = d;
|
||||
loadEventsForMonth();
|
||||
}
|
||||
|
||||
function handleKeyEvent(event) {
|
||||
if (showEventDetails) {
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
showEventDetails = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
switch (event.key) {
|
||||
case Qt.Key_Left:
|
||||
case Qt.Key_H:
|
||||
moveSelection(I18n.isRtl ? 1 : -1);
|
||||
return true;
|
||||
case Qt.Key_Right:
|
||||
case Qt.Key_L:
|
||||
moveSelection(I18n.isRtl ? -1 : 1);
|
||||
return true;
|
||||
case Qt.Key_Up:
|
||||
case Qt.Key_K:
|
||||
moveSelection(-7);
|
||||
return true;
|
||||
case Qt.Key_Down:
|
||||
case Qt.Key_J:
|
||||
moveSelection(7);
|
||||
return true;
|
||||
case Qt.Key_PageUp:
|
||||
shiftMonth(-1);
|
||||
return true;
|
||||
case Qt.Key_PageDown:
|
||||
shiftMonth(1);
|
||||
return true;
|
||||
case Qt.Key_T:
|
||||
goToToday();
|
||||
return true;
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
case Qt.Key_Space:
|
||||
root.selectedDate = calendarGrid.selectedDate;
|
||||
showEventDetails = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onSelectedDateChanged: updateSelectedDateEvents()
|
||||
|
||||
onShowEventDetailsChanged: {
|
||||
if (showEventDetails) {
|
||||
taskInput.forceActiveFocus();
|
||||
} else {
|
||||
navFocusRequested();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,8 +201,8 @@ Rectangle {
|
||||
updateSelectedDateEvents();
|
||||
}
|
||||
|
||||
function onKhalAvailableChanged() {
|
||||
if (CalendarService && CalendarService.khalAvailable) {
|
||||
function onCalendarAvailableChanged() {
|
||||
if (CalendarService && CalendarService.calendarAvailable) {
|
||||
loadEventsForMonth();
|
||||
}
|
||||
updateSelectedDateEvents();
|
||||
@@ -143,6 +222,55 @@ Rectangle {
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Rectangle {
|
||||
id: dankWarning
|
||||
width: parent.width
|
||||
visible: CalendarService && CalendarService.dankNeedsLaunch
|
||||
height: visible ? Math.max(28, warningRow.implicitHeight) + Theme.spacingS : 0
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12)
|
||||
border.color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.35)
|
||||
border.width: 1
|
||||
|
||||
Row {
|
||||
id: warningRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Theme.spacingS
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "warning"
|
||||
size: 16
|
||||
color: Theme.warning
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width - 16 - Theme.spacingS - (launchButton.visible ? launchButton.width + Theme.spacingS : 0)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: (CalendarService && CalendarService.dankBinaryExists) ? I18n.tr("DankCalendar isn't running") : I18n.tr("DankCalendar isn't installed")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: launchButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: CalendarService && CalendarService.dankBinaryExists
|
||||
text: I18n.tr("Launch")
|
||||
buttonHeight: 26
|
||||
backgroundColor: Theme.primary
|
||||
textColor: Theme.primaryText
|
||||
onClicked: CalendarService.launchDankCalendar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 40
|
||||
@@ -173,11 +301,40 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
radius: Theme.cornerRadius
|
||||
visible: CalendarService && CalendarService.canCreateEvents
|
||||
color: addEventArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "event"
|
||||
size: 16
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: addEventArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.editorEvent = null;
|
||||
root.showEditor = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 32 + Theme.spacingS * 2
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.rightMargin: (CalendarService && CalendarService.canCreateEvents) ? 32 + Theme.spacingS * 2 : Theme.spacingS
|
||||
height: 40
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
@@ -229,7 +386,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width - 56
|
||||
width: parent.width - 84
|
||||
height: 28
|
||||
text: calendarGrid.displayDate.toLocaleDateString(I18n.locale(), "MMMM yyyy")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
@@ -239,6 +396,28 @@ Rectangle {
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: Theme.cornerRadius
|
||||
color: todayArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "today"
|
||||
size: 14
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: todayArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.goToToday()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
@@ -388,6 +567,8 @@ Rectangle {
|
||||
height: width
|
||||
color: isToday ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : dayArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
|
||||
radius: Theme.cornerRadius
|
||||
border.color: (isSelected && !isToday) ? Theme.primary : "transparent"
|
||||
border.width: (isSelected && !isToday) ? 1 : 0
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
@@ -397,21 +578,31 @@ Rectangle {
|
||||
font.weight: isToday ? Font.Medium : Font.Normal
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Row {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottomMargin: 4
|
||||
width: 12
|
||||
height: 2
|
||||
radius: Theme.cornerRadius
|
||||
visible: CalendarService && CalendarService.khalAvailable && CalendarService.hasEventsForDate(dayDate)
|
||||
color: isToday ? Qt.lighter(Theme.primary, 1.3) : Theme.primary
|
||||
opacity: isToday ? 0.9 : 0.7
|
||||
anchors.bottomMargin: 3
|
||||
spacing: 2
|
||||
visible: CalendarService && CalendarService.calendarAvailable && CalendarService.hasEventsForDate(dayDate)
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
Repeater {
|
||||
model: {
|
||||
const evs = CalendarService.getEventsForDate(dayDate);
|
||||
const seen = [];
|
||||
for (let i = 0; i < evs.length && seen.length < 3; i++) {
|
||||
const c = (evs[i].color && evs[i].color.length) ? evs[i].color : "primary";
|
||||
if (seen.indexOf(c) === -1)
|
||||
seen.push(c);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 5
|
||||
height: 5
|
||||
radius: 2.5
|
||||
color: modelData === "primary" ? (isToday ? Qt.lighter(Theme.primary, 1.3) : Theme.primary) : modelData
|
||||
opacity: isToday ? 0.95 : 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,6 +614,7 @@ Rectangle {
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
calendarGrid.selectedDate = dayDate;
|
||||
root.selectedDate = dayDate;
|
||||
root.showEventDetails = true;
|
||||
}
|
||||
@@ -622,7 +814,15 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
color: isDragging ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15) : (eventMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.06) : Theme.nestedSurface)
|
||||
readonly property bool isTask: modelData && modelData.id && modelData.id.startsWith("task_")
|
||||
readonly property color accentColor: {
|
||||
if (isTask)
|
||||
return modelData.completed ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Theme.primary;
|
||||
return (modelData && modelData.color && modelData.color.length) ? modelData.color : Theme.primary;
|
||||
}
|
||||
readonly property color surfaceColor: isDragging ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15) : (eventMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.06) : Theme.nestedSurface)
|
||||
|
||||
color: surfaceColor
|
||||
border.color: isDragging ? Theme.primary : (eventMouseArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15) : Theme.outlineMedium)
|
||||
border.width: (isDragging || eventMouseArea.containsMouse) ? 1 : Theme.layerOutlineWidth
|
||||
|
||||
@@ -660,15 +860,22 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 3
|
||||
height: parent.height - 6
|
||||
Item {
|
||||
id: accentClip
|
||||
width: 4
|
||||
clip: true
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: Theme.cornerRadius
|
||||
color: (modelData && modelData.id && modelData.id.startsWith("task_")) ? (modelData.completed ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Theme.primary) : Theme.primary
|
||||
opacity: 0.8
|
||||
|
||||
Rectangle {
|
||||
width: taskItem.width
|
||||
height: taskItem.height
|
||||
radius: taskItem.radius
|
||||
color: taskItem.accentColor
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
|
||||
// Drag Handle
|
||||
@@ -767,6 +974,7 @@ Rectangle {
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: (modelData && modelData.id && modelData.id.startsWith("task_") && modelData.completed) ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) : Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
@@ -774,21 +982,24 @@ Rectangle {
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: {
|
||||
if (!modelData || modelData.allDay) {
|
||||
return I18n.tr("All day", "calendar task with no specific time");
|
||||
} else if (modelData.start && modelData.end) {
|
||||
if (!modelData)
|
||||
return "";
|
||||
const cal = (modelData.calendar && modelData.calendar.length) ? " · " + modelData.calendar : "";
|
||||
if (modelData.allDay)
|
||||
return I18n.tr("All day", "calendar task with no specific time") + cal;
|
||||
if (modelData.start && modelData.end) {
|
||||
const timeFormat = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP";
|
||||
const startTime = Qt.formatTime(modelData.start, timeFormat);
|
||||
if (modelData.start.toDateString() !== modelData.end.toDateString() || modelData.start.getTime() !== modelData.end.getTime()) {
|
||||
return startTime + " – " + Qt.formatTime(modelData.end, timeFormat);
|
||||
}
|
||||
return startTime;
|
||||
if (modelData.start.toDateString() !== modelData.end.toDateString() || modelData.start.getTime() !== modelData.end.getTime())
|
||||
return startTime + " – " + Qt.formatTime(modelData.end, timeFormat) + cal;
|
||||
return startTime + cal;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
|
||||
font.weight: Font.Normal
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
visible: text !== "" && modelData && modelData.id && !modelData.id.startsWith("task_")
|
||||
}
|
||||
}
|
||||
@@ -824,8 +1035,9 @@ Rectangle {
|
||||
taskItem.isEditing = false;
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: {
|
||||
Keys.onEscapePressed: event => {
|
||||
taskItem.isEditing = false;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -838,18 +1050,15 @@ Rectangle {
|
||||
anchors.leftMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 32 : 6
|
||||
anchors.rightMargin: (modelData && modelData.id && modelData.id.startsWith("task_")) ? 64 : 0
|
||||
hoverEnabled: true
|
||||
cursorShape: (modelData && (modelData.url || (modelData.id && modelData.id.startsWith("task_")))) ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
enabled: modelData && (modelData.url !== "" || (modelData.id && modelData.id.startsWith("task_"))) && !taskItem.isEditing
|
||||
cursorShape: modelData ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
enabled: modelData && !taskItem.isEditing
|
||||
onClicked: {
|
||||
if (modelData && modelData.id && modelData.id.startsWith("task_")) {
|
||||
CalendarService.toggleTask(modelData.id);
|
||||
} else if (modelData && modelData.url && modelData.url !== "") {
|
||||
if (Qt.openUrlExternally(modelData.url) === false) {
|
||||
log.warn("Failed to open URL: " + modelData.url);
|
||||
} else {
|
||||
root.closeDash();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (modelData)
|
||||
root.detailEvent = modelData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,7 +1162,7 @@ Rectangle {
|
||||
Text {
|
||||
text: I18n.tr("Add a task...", "placeholder in the new-task input field")
|
||||
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
|
||||
visible: !taskInput.text && !taskInput.activeFocus
|
||||
visible: taskInput.text.length === 0
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
@@ -965,6 +1174,52 @@ Rectangle {
|
||||
text = "";
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: event => {
|
||||
root.showEventDetails = false;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
z: 1000
|
||||
active: root.detailEvent !== null
|
||||
|
||||
sourceComponent: CalendarEventDetail {
|
||||
eventData: root.detailEvent
|
||||
canEdit: CalendarService && CalendarService.canCreateEvents && root.detailEvent && !root.detailEvent.readOnly && !(root.detailEvent.id && root.detailEvent.id.startsWith("task_"))
|
||||
onCloseRequested: root.detailEvent = null
|
||||
onEditRequested: {
|
||||
root.editorEvent = root.detailEvent;
|
||||
root.detailEvent = null;
|
||||
root.showEditor = true;
|
||||
}
|
||||
onDeleteRequested: {
|
||||
if (root.detailEvent && root.detailEvent.id)
|
||||
CalendarService.deleteEvent(root.detailEvent.id, null);
|
||||
root.detailEvent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
z: 1000
|
||||
active: root.showEditor
|
||||
|
||||
sourceComponent: CalendarEventEditor {
|
||||
eventData: root.editorEvent
|
||||
initialDate: root.selectedDate
|
||||
onCloseRequested: {
|
||||
root.showEditor = false;
|
||||
root.editorEvent = null;
|
||||
}
|
||||
onSaved: {
|
||||
root.showEditor = false;
|
||||
root.editorEvent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ Item {
|
||||
signal switchToWeatherTab
|
||||
signal switchToMediaTab
|
||||
signal closeDash
|
||||
signal navFocusRequested
|
||||
|
||||
function handleKeyEvent(event) {
|
||||
return calendarCard.handleKeyEvent(event);
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
@@ -54,12 +59,14 @@ Item {
|
||||
|
||||
// Calendar - bottom middle (wider and taller)
|
||||
CalendarOverviewCard {
|
||||
id: calendarCard
|
||||
x: parent.width * 0.2 - Theme.spacingM
|
||||
y: 100 + Theme.spacingM
|
||||
width: parent.width * 0.6
|
||||
height: 300
|
||||
|
||||
onCloseDash: root.closeDash()
|
||||
onNavFocusRequested: root.navFocusRequested()
|
||||
}
|
||||
|
||||
// Media - bottom right (narrow and taller)
|
||||
|
||||
@@ -18,6 +18,9 @@ Item {
|
||||
property bool showHourly: false
|
||||
property bool available: WeatherService.weather.available
|
||||
|
||||
Component.onCompleted: WeatherService.addRef()
|
||||
Component.onDestruction: WeatherService.removeRef()
|
||||
|
||||
function syncFrom(type) {
|
||||
if (!dailyLoader.item || !hourlyLoader.item)
|
||||
return;
|
||||
|
||||
@@ -511,13 +511,11 @@ Variants {
|
||||
if (!dock.hoveredButton || !dock.reveal || slideXAnimation.running || slideYAnimation.running)
|
||||
return;
|
||||
|
||||
const buttonGlobalPos = dock.hoveredButton.mapToGlobal(0, 0);
|
||||
const buttonLocalPos = dock.hoveredButton.mapToItem(null, 0, 0);
|
||||
const tooltipText = dock.hoveredButton.tooltipText || "";
|
||||
if (!tooltipText)
|
||||
return;
|
||||
|
||||
const screenX = dock.screen ? (dock.screen.x || 0) : 0;
|
||||
const screenY = dock.screen ? (dock.screen.y || 0) : 0;
|
||||
const screenHeight = dock.screen ? dock.screen.height : 0;
|
||||
|
||||
const gap = Theme.spacingS;
|
||||
@@ -527,19 +525,19 @@ Variants {
|
||||
|
||||
if (!dock.isVertical) {
|
||||
const isBottom = SettingsData.dockPosition === SettingsData.Position.Bottom;
|
||||
const globalX = buttonGlobalPos.x + btnW / 2 + adjacentLeftBarWidth;
|
||||
const tooltipX = buttonLocalPos.x + btnW / 2 + adjacentLeftBarWidth;
|
||||
const tooltipHeight = 32;
|
||||
const totalFromEdge = bgMargin + dockBackground.height + dock.borderThickness + gap;
|
||||
const screenRelativeY = isBottom ? (screenHeight - totalFromEdge - tooltipHeight) : totalFromEdge;
|
||||
dockTooltip.show(tooltipText, globalX, screenRelativeY, dock.screen, false, false);
|
||||
dockTooltip.show(tooltipText, tooltipX, screenRelativeY, dock.screen, false, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isLeft = SettingsData.dockPosition === SettingsData.Position.Left;
|
||||
const screenWidth = dock.screen ? dock.screen.width : 0;
|
||||
const totalFromEdge = bgMargin + dockBackground.width + dock.borderThickness + gap;
|
||||
const tooltipX = isLeft ? (screenX + totalFromEdge) : (screenX + screenWidth - totalFromEdge);
|
||||
const screenRelativeY = buttonGlobalPos.y - screenY + btnH / 2 + adjacentTopBarHeight;
|
||||
const tooltipX = isLeft ? totalFromEdge : (screenWidth - totalFromEdge);
|
||||
const screenRelativeY = buttonLocalPos.y + btnH / 2 + adjacentTopBarHeight;
|
||||
dockTooltip.show(tooltipText, tooltipX, screenRelativeY, dock.screen, isLeft, !isLeft);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ Scope {
|
||||
function lock() {
|
||||
if (SettingsData.customPowerActionLock?.length > 0) {
|
||||
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLock]);
|
||||
// The custom locker manages its own surface; DMS never engages
|
||||
// WlSessionLock here, so isShellLocked stays false and the fade
|
||||
// overlay would never be dismissed. Hand off by dismissing it now.
|
||||
IdleService.dismissFadeToLock();
|
||||
return;
|
||||
}
|
||||
if (shouldLock || pendingLock)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function connectToNetwork(network, options) {
|
||||
if (!network)
|
||||
return;
|
||||
|
||||
const actionOptions = options || {};
|
||||
const ssid = network.ssid || "";
|
||||
if (!ssid)
|
||||
return;
|
||||
|
||||
const connected = actionOptions.connected ?? network.connected ?? (ssid === NetworkService.currentWifiSSID);
|
||||
if (connected) {
|
||||
if (actionOptions.disconnectWhenConnected ?? false)
|
||||
NetworkService.disconnectWifi();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldPromptForCredentials(network)) {
|
||||
PopoutService.showWifiPasswordModal(ssid);
|
||||
return;
|
||||
}
|
||||
|
||||
NetworkService.connectToWifi(ssid);
|
||||
}
|
||||
|
||||
function connectToNetworkFromDetails(ssid, secured, saved, enterprise, connected, options) {
|
||||
connectToNetwork({
|
||||
ssid: ssid,
|
||||
secured: secured,
|
||||
saved: saved,
|
||||
enterprise: enterprise,
|
||||
connected: connected
|
||||
}, options);
|
||||
}
|
||||
|
||||
function shouldPromptForCredentials(network) {
|
||||
return (network.secured ?? false) && !(network.saved ?? false);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
@@ -21,21 +22,71 @@ Item {
|
||||
property var currentTab: NotepadStorageService.tabs.length > NotepadStorageService.currentTabIndex ? NotepadStorageService.tabs[NotepadStorageService.currentTabIndex] : null
|
||||
property bool showSettingsMenu: false
|
||||
property string pendingSaveContent: ""
|
||||
readonly property bool conflictBannerVisible: currentTab !== null && NotepadStorageService.conflictTabId === currentTab.id
|
||||
property var slideout: null
|
||||
property bool inPopout: false
|
||||
property bool surfaceVisible: slideout ? slideout.isVisible : true
|
||||
|
||||
signal hideRequested
|
||||
signal popoutRequested
|
||||
signal dockRequested
|
||||
signal previewRequested(string content)
|
||||
|
||||
function externalSync() {
|
||||
textEditor.syncFromDisk();
|
||||
}
|
||||
|
||||
function flushAutoSave() {
|
||||
textEditor.autoSaveToSession();
|
||||
}
|
||||
|
||||
Ref {
|
||||
service: NotepadStorageService
|
||||
}
|
||||
|
||||
// In connected frame mode the slideout sits on the Overlay layer
|
||||
onFileDialogOpenChanged: {
|
||||
if (slideout)
|
||||
slideout.suppressOverlayLayer = fileDialogOpen;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: slideout
|
||||
enabled: slideout !== null
|
||||
function onAboutToHide() {
|
||||
textEditor.autoSaveToSession();
|
||||
}
|
||||
function onRevealed() {
|
||||
textEditor.syncFromDisk();
|
||||
}
|
||||
}
|
||||
|
||||
function showConflictBanner(diskContent) {
|
||||
if (!currentTab)
|
||||
return;
|
||||
NotepadStorageService.flagConflict(currentTab.id, diskContent);
|
||||
}
|
||||
|
||||
function resolveConflictKeepEdits() {
|
||||
if (!root.conflictBannerVisible)
|
||||
return;
|
||||
NotepadStorageService.clearConflict();
|
||||
if (currentTab && currentTab.filePath && !currentTab.isTemporary) {
|
||||
root.saveToFile("file://" + currentTab.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConflictReload() {
|
||||
if (!root.conflictBannerVisible)
|
||||
return;
|
||||
const diskContent = NotepadStorageService.conflictDiskContent;
|
||||
NotepadStorageService.clearConflict();
|
||||
textEditor.reloadFromDisk(diskContent);
|
||||
}
|
||||
|
||||
function dismissConflictBanner() {
|
||||
if (root.conflictBannerVisible)
|
||||
NotepadStorageService.clearConflict();
|
||||
}
|
||||
|
||||
function hasUnsavedChanges() {
|
||||
@@ -51,10 +102,14 @@ Item {
|
||||
}
|
||||
|
||||
function performCreateNewTab() {
|
||||
textEditor.commitLiveBuffer();
|
||||
NotepadStorageService.createNewTab();
|
||||
textEditor.applyingShared = true;
|
||||
textEditor.text = "";
|
||||
textEditor.lastSavedContent = "";
|
||||
textEditor.loadedTabId = -1;
|
||||
textEditor.contentLoaded = true;
|
||||
textEditor.applyingShared = false;
|
||||
textEditor.textArea.forceActiveFocus();
|
||||
}
|
||||
|
||||
@@ -86,7 +141,6 @@ Item {
|
||||
|
||||
NotepadStorageService.switchToTab(tabIndex);
|
||||
Qt.callLater(() => {
|
||||
textEditor.loadCurrentTabContent();
|
||||
if (currentTab) {
|
||||
root.currentFileName = currentTab.fileName || "";
|
||||
root.currentFileUrl = currentTab.fileUrl || "";
|
||||
@@ -100,6 +154,7 @@ Item {
|
||||
var content = textEditor.text;
|
||||
var filePath = fileUrl.toString().replace(/^file:\/\//, '');
|
||||
|
||||
textEditor.externalWatchPaused = true;
|
||||
saveFileView.path = "";
|
||||
pendingSaveContent = content;
|
||||
saveFileView.path = filePath;
|
||||
@@ -109,6 +164,53 @@ Item {
|
||||
});
|
||||
}
|
||||
|
||||
function saveExternalWithFreshnessCheck() {
|
||||
if (!currentTab || currentTab.isTemporary || !currentTab.filePath)
|
||||
return;
|
||||
const filePath = currentTab.filePath;
|
||||
loadFileView.path = "";
|
||||
loadFileView.path = filePath;
|
||||
|
||||
if (!loadFileView.waitForJob()) {
|
||||
saveToFile("file://" + filePath);
|
||||
return;
|
||||
}
|
||||
Qt.callLater(() => {
|
||||
if (!currentTab || currentTab.isTemporary || currentTab.filePath !== filePath)
|
||||
return;
|
||||
const diskContent = loadFileView.text();
|
||||
if (diskContent !== undefined && diskContent !== null && diskContent !== textEditor.text && diskContent !== textEditor.lastSavedContent) {
|
||||
root.showConflictBanner(diskContent);
|
||||
return;
|
||||
}
|
||||
saveToFile("file://" + filePath);
|
||||
});
|
||||
}
|
||||
|
||||
function autoSaveExternal() {
|
||||
if (!SettingsData.notepadAutoSave)
|
||||
return;
|
||||
if (!currentTab || currentTab.isTemporary || !currentTab.filePath)
|
||||
return;
|
||||
if (!textEditor.hasUnsavedChanges())
|
||||
return;
|
||||
const filePath = currentTab.filePath;
|
||||
loadFileView.path = "";
|
||||
loadFileView.path = filePath;
|
||||
if (!loadFileView.waitForJob())
|
||||
return;
|
||||
Qt.callLater(() => {
|
||||
if (!currentTab || currentTab.isTemporary || currentTab.filePath !== filePath)
|
||||
return;
|
||||
const diskContent = loadFileView.text();
|
||||
if (diskContent === undefined || diskContent === null)
|
||||
return;
|
||||
if (diskContent !== textEditor.lastSavedContent)
|
||||
return;
|
||||
saveToFile("file://" + filePath);
|
||||
});
|
||||
}
|
||||
|
||||
function loadFromFile(fileUrl) {
|
||||
if (hasUnsavedTemporaryContent()) {
|
||||
root.pendingFileUrl = fileUrl;
|
||||
@@ -146,14 +248,155 @@ Item {
|
||||
|
||||
root.currentFileName = fileName;
|
||||
root.currentFileUrl = fileUrl;
|
||||
textEditor.saveCurrentTabContent();
|
||||
textEditor.loadedTabId = currentTab.id;
|
||||
NotepadStorageService.clearSessionBuffer(currentTab.id);
|
||||
if (root.conflictBannerVisible)
|
||||
NotepadStorageService.clearConflict();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: conflictBanner
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: root.conflictBannerVisible ? bannerRect.implicitHeight : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
z: 5
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
id: bannerRect
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
implicitHeight: bannerLayout.implicitHeight + Theme.spacingM * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.warning, 0.12)
|
||||
border.color: Theme.withAlpha(Theme.warning, 0.5)
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: bannerLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
name: "sync_problem"
|
||||
size: Theme.iconSize - 2
|
||||
color: Theme.warning
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: I18n.tr("File changed on disk")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
wrapMode: Text.NoWrap
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
iconName: "close"
|
||||
iconSize: Theme.iconSizeSmall
|
||||
iconColor: Theme.surfaceText
|
||||
buttonSize: 28
|
||||
onClicked: root.dismissConflictBanner()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 32
|
||||
|
||||
Row {
|
||||
id: bannerActions
|
||||
anchors.right: parent.right
|
||||
spacing: Theme.spacingS
|
||||
|
||||
readonly property real available: parent.width
|
||||
|
||||
StyledRect {
|
||||
width: Math.min(keepText.implicitWidth + Theme.spacingM * 2, Math.max(104, (bannerActions.available - bannerActions.spacing) / 2))
|
||||
height: 32
|
||||
radius: Theme.cornerRadius
|
||||
color: "transparent"
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: 1
|
||||
|
||||
StateLayer {
|
||||
anchors.fill: parent
|
||||
cornerRadius: parent.radius
|
||||
stateColor: Theme.surfaceText
|
||||
onClicked: root.resolveConflictKeepEdits()
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: keepText
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Theme.spacingM
|
||||
text: I18n.tr("Keep My Edits")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: Math.min(reloadText.implicitWidth + Theme.spacingM * 2, Math.max(116, (bannerActions.available - bannerActions.spacing) / 2))
|
||||
height: 32
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.primary
|
||||
|
||||
StateLayer {
|
||||
anchors.fill: parent
|
||||
cornerRadius: parent.radius
|
||||
stateColor: Theme.background
|
||||
onClicked: root.resolveConflictReload()
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: reloadText
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Theme.spacingM
|
||||
text: I18n.tr("Reload From Disk")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.background
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.top: conflictBanner.bottom
|
||||
anchors.topMargin: root.conflictBannerVisible ? Theme.spacingM : 0
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
spacing: Theme.spacingM
|
||||
|
||||
NotepadTabs {
|
||||
@@ -178,11 +421,12 @@ Item {
|
||||
id: textEditor
|
||||
width: parent.width
|
||||
height: parent.height - tabBar.height - Theme.spacingM * 2
|
||||
inPopout: root.inPopout
|
||||
surfaceVisible: root.surfaceVisible
|
||||
|
||||
onSaveRequested: {
|
||||
if (currentTab && !currentTab.isTemporary && currentTab.filePath) {
|
||||
var fileUrl = "file://" + currentTab.filePath;
|
||||
saveToFile(fileUrl);
|
||||
root.saveExternalWithFreshnessCheck();
|
||||
} else {
|
||||
root.fileDialogOpen = true;
|
||||
saveBrowserLoader.active = true;
|
||||
@@ -214,12 +458,28 @@ Item {
|
||||
|
||||
onEscapePressed: {
|
||||
textEditor.autoSaveToSession();
|
||||
root.hideRequested();
|
||||
if (showSettingsMenu) {
|
||||
showSettingsMenu = false;
|
||||
return;
|
||||
}
|
||||
if (!root.inPopout) {
|
||||
root.hideRequested();
|
||||
}
|
||||
}
|
||||
|
||||
onSettingsRequested: {
|
||||
showSettingsMenu = !showSettingsMenu;
|
||||
}
|
||||
|
||||
onPopoutRequested: root.popoutRequested()
|
||||
|
||||
onDockRequested: root.dockRequested()
|
||||
|
||||
onConflictDetected: diskContent => {
|
||||
root.showConflictBanner(diskContent);
|
||||
}
|
||||
|
||||
onAutoSaveRequested: root.autoSaveExternal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,17 +502,24 @@ Item {
|
||||
printErrors: true
|
||||
|
||||
onSaved: {
|
||||
if (currentTab && saveFileView.path && pendingSaveContent) {
|
||||
if (currentTab && saveFileView.path) {
|
||||
NotepadStorageService.updateTabMetadata(NotepadStorageService.currentTabIndex, {
|
||||
hasUnsavedChanges: false,
|
||||
lastSavedContent: pendingSaveContent
|
||||
});
|
||||
root.lastSavedFileContent = pendingSaveContent;
|
||||
pendingSaveContent = "";
|
||||
textEditor.lastSavedContent = pendingSaveContent;
|
||||
textEditor.ignoreNextExternalChange = true;
|
||||
textEditor.commitLiveBuffer();
|
||||
if (root.conflictBannerVisible)
|
||||
NotepadStorageService.clearConflict();
|
||||
}
|
||||
textEditor.externalWatchPaused = false;
|
||||
pendingSaveContent = "";
|
||||
}
|
||||
|
||||
onSaveFailed: error => {
|
||||
textEditor.externalWatchPaused = false;
|
||||
pendingSaveContent = "";
|
||||
}
|
||||
}
|
||||
@@ -298,6 +565,7 @@ Item {
|
||||
|
||||
root.currentFileName = fileName;
|
||||
root.currentFileUrl = fileUrl;
|
||||
textEditor.externalWatchPaused = true;
|
||||
|
||||
if (currentTab) {
|
||||
NotepadStorageService.saveTabAs(NotepadStorageService.currentTabIndex, cleanPath);
|
||||
@@ -343,7 +611,7 @@ Item {
|
||||
browserTitle: I18n.tr("Open Notepad File")
|
||||
browserIcon: "folder_open"
|
||||
browserType: "notepad_load"
|
||||
fileExtensions: ["*.txt", "*.md", "*.*"]
|
||||
fileExtensions: ["*"]
|
||||
allowStacking: true
|
||||
|
||||
onFileSelected: path => {
|
||||
@@ -376,6 +644,7 @@ Item {
|
||||
modalHeight: contentLoader.item ? contentLoader.item.implicitHeight + Theme.spacingM * 2 : 180
|
||||
shouldBeVisible: false
|
||||
allowStacking: true
|
||||
useOverlayLayer: true
|
||||
|
||||
onBackgroundClicked: {
|
||||
close();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Notepad
|
||||
|
||||
FloatingWindow {
|
||||
id: win
|
||||
|
||||
property alias shouldBeVisible: win.visible
|
||||
|
||||
function show() {
|
||||
visible = true;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
visible = !visible;
|
||||
}
|
||||
|
||||
title: I18n.tr("Notepad")
|
||||
minimumSize: Qt.size(360, 320)
|
||||
implicitWidth: 640
|
||||
implicitHeight: 760
|
||||
color: Theme.surfaceContainer
|
||||
visible: false
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible) {
|
||||
Qt.callLater(notepad.externalSync);
|
||||
} else {
|
||||
notepad.flushAutoSave();
|
||||
}
|
||||
}
|
||||
|
||||
// A compositor close (e.g. niri close-window)
|
||||
onClosed: win.visible = false
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
|
||||
Item {
|
||||
id: titleBar
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 44
|
||||
z: 10
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: windowControls.tryStartMove()
|
||||
onDoubleClicked: windowControls.tryToggleMaximize()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.surfaceContainerHigh
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "edit_note"
|
||||
size: Theme.iconSize - 2
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Notepad")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankActionButton {
|
||||
visible: windowControls.canMaximize
|
||||
circular: false
|
||||
iconName: win.maximized ? "fullscreen_exit" : "fullscreen"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: windowControls.tryToggleMaximize()
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
circular: false
|
||||
iconName: "close"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: win.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Notepad {
|
||||
id: notepad
|
||||
anchors.top: titleBar.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.topMargin: Theme.spacingM
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.bottomMargin: Theme.spacingM
|
||||
inPopout: true
|
||||
surfaceVisible: win.visible
|
||||
onHideRequested: win.hide()
|
||||
onDockRequested: {
|
||||
win.hide();
|
||||
PopoutService.openNotepadSlideout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingWindowControls {
|
||||
id: windowControls
|
||||
targetWindow: win
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ Item {
|
||||
property var cachedFontFamilies: []
|
||||
property var cachedMonoFamilies: []
|
||||
property bool fontsEnumerated: false
|
||||
property bool shortcutsExpanded: false
|
||||
|
||||
signal settingsRequested
|
||||
signal findRequested
|
||||
@@ -62,11 +63,23 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: root.isVisible
|
||||
onClicked: root.settingsRequested()
|
||||
z: 50
|
||||
color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.85)
|
||||
|
||||
WheelHandler {
|
||||
// Hold scroll so the editor beneath doesn't move while settings are open.
|
||||
onWheel: event => {
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.settingsRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -74,8 +87,8 @@ Item {
|
||||
visible: root.isVisible
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 360
|
||||
height: settingsColumn.implicitHeight + Theme.spacingXL * 2
|
||||
width: Math.min(360, root.width - Theme.spacingL * 2)
|
||||
height: Math.min(settingsColumn.implicitHeight + Theme.spacingXL * 2, root.height - Theme.spacingL * 2)
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, Theme.notepadTransparency)
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||
@@ -93,274 +106,458 @@ Item {
|
||||
z: parent.z - 1
|
||||
}
|
||||
|
||||
Column {
|
||||
id: settingsColumn
|
||||
width: parent.width - Theme.spacingXL * 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: Theme.spacingXL
|
||||
spacing: Theme.spacingS
|
||||
DankFlickable {
|
||||
id: settingsFlickable
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: settingsColumn.implicitHeight + Theme.spacingXL * 2
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 36
|
||||
color: "transparent"
|
||||
Column {
|
||||
id: settingsColumn
|
||||
x: Theme.spacingXL
|
||||
y: Theme.spacingXL
|
||||
width: settingsFlickable.width - Theme.spacingXL * 2
|
||||
spacing: Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: I18n.tr("Notepad Font Settings")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Use Monospace Font")
|
||||
description: I18n.tr("Toggle fonts")
|
||||
checked: SettingsData.notepadUseMonospace
|
||||
onToggled: checked => {
|
||||
SettingsData.notepadUseMonospace = checked;
|
||||
}
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Show Line Numbers")
|
||||
description: I18n.tr("Display line numbers in editor")
|
||||
checked: SettingsData.notepadShowLineNumbers
|
||||
onToggled: checked => {
|
||||
SettingsData.notepadShowLineNumbers = checked;
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: 60
|
||||
radius: Theme.cornerRadius
|
||||
color: "transparent"
|
||||
|
||||
StateLayer {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
stateColor: Theme.primary
|
||||
cornerRadius: parent.radius
|
||||
onClicked: root.findRequested()
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
name: "search"
|
||||
size: Theme.iconSize - 2
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Find in Text")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Open search bar to find text")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: visible ? (fontDropdown.height + Theme.spacingS) : 0
|
||||
color: "transparent"
|
||||
visible: !SettingsData.notepadUseMonospace
|
||||
|
||||
DankDropdown {
|
||||
id: fontDropdown
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Font Family")
|
||||
options: cachedFontFamilies
|
||||
currentValue: {
|
||||
if (!SettingsData.notepadFontFamily || SettingsData.notepadFontFamily === "")
|
||||
return I18n.tr("Default (Global)");
|
||||
else
|
||||
return SettingsData.notepadFontFamily;
|
||||
}
|
||||
enableFuzzySearch: true
|
||||
onValueChanged: value => {
|
||||
if (value && (value.startsWith("Default") || value === "Default (Global)")) {
|
||||
SettingsData.notepadFontFamily = "";
|
||||
} else {
|
||||
SettingsData.notepadFontFamily = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: fontSizeRow.height + Theme.spacingS
|
||||
color: "transparent"
|
||||
|
||||
Row {
|
||||
id: fontSizeRow
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
height: 36
|
||||
color: "transparent"
|
||||
|
||||
Column {
|
||||
width: parent.width - fontSizeControls.width - Theme.spacingM
|
||||
spacing: Theme.spacingXS
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingXS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: I18n.tr("Notepad Settings")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Font Size")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: SettingsData.notepadFontSize + "px"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
}
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Use Monospace Font")
|
||||
description: I18n.tr("Toggle fonts")
|
||||
checked: SettingsData.notepadUseMonospace
|
||||
onToggled: checked => {
|
||||
SettingsData.notepadUseMonospace = checked;
|
||||
}
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Show Line Numbers")
|
||||
description: I18n.tr("Display line numbers in editor")
|
||||
checked: SettingsData.notepadShowLineNumbers
|
||||
onToggled: checked => {
|
||||
SettingsData.notepadShowLineNumbers = checked;
|
||||
}
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Auto-save to disk")
|
||||
description: I18n.tr("Automatically save changes to opened files as you type")
|
||||
checked: SettingsData.notepadAutoSave
|
||||
onToggled: checked => {
|
||||
SettingsData.notepadAutoSave = checked;
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: 60
|
||||
radius: Theme.cornerRadius
|
||||
color: "transparent"
|
||||
|
||||
StateLayer {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
stateColor: Theme.primary
|
||||
cornerRadius: parent.radius
|
||||
onClicked: root.findRequested()
|
||||
}
|
||||
|
||||
Row {
|
||||
id: fontSizeControls
|
||||
spacing: Theme.spacingS
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankActionButton {
|
||||
buttonSize: 32
|
||||
iconName: "remove"
|
||||
iconSize: Theme.iconSizeSmall
|
||||
enabled: SettingsData.notepadFontSize > 8
|
||||
backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: {
|
||||
var newSize = Math.max(8, SettingsData.notepadFontSize - 1);
|
||||
SettingsData.notepadFontSize = newSize;
|
||||
}
|
||||
DankIcon {
|
||||
name: "search"
|
||||
size: Theme.iconSize - 2
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 60
|
||||
height: 32
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||
border.width: 1
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: SettingsData.notepadFontSize + "px"
|
||||
text: I18n.tr("Find in Text")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Open search bar to find text")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: visible ? (fontDropdown.height + Theme.spacingS) : 0
|
||||
color: "transparent"
|
||||
visible: !SettingsData.notepadUseMonospace
|
||||
|
||||
DankDropdown {
|
||||
id: fontDropdown
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Font Family")
|
||||
options: cachedFontFamilies
|
||||
currentValue: {
|
||||
if (!SettingsData.notepadFontFamily || SettingsData.notepadFontFamily === "")
|
||||
return I18n.tr("Default (Global)");
|
||||
else
|
||||
return SettingsData.notepadFontFamily;
|
||||
}
|
||||
enableFuzzySearch: true
|
||||
onValueChanged: value => {
|
||||
if (value && (value.startsWith("Default") || value === "Default (Global)")) {
|
||||
SettingsData.notepadFontFamily = "";
|
||||
} else {
|
||||
SettingsData.notepadFontFamily = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: fontSizeRow.height + Theme.spacingS
|
||||
color: "transparent"
|
||||
|
||||
Row {
|
||||
id: fontSizeRow
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Column {
|
||||
width: parent.width - fontSizeControls.width - Theme.spacingM
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Font Size")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: SettingsData.notepadFontSize + "px"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
buttonSize: 32
|
||||
iconName: "add"
|
||||
iconSize: Theme.iconSizeSmall
|
||||
enabled: SettingsData.notepadFontSize < 48
|
||||
backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: {
|
||||
var newSize = Math.min(48, SettingsData.notepadFontSize + 1);
|
||||
SettingsData.notepadFontSize = newSize;
|
||||
Row {
|
||||
id: fontSizeControls
|
||||
spacing: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
DankActionButton {
|
||||
buttonSize: 32
|
||||
iconName: "remove"
|
||||
iconSize: Theme.iconSizeSmall
|
||||
enabled: SettingsData.notepadFontSize > 8
|
||||
backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: {
|
||||
var newSize = Math.max(8, SettingsData.notepadFontSize - 1);
|
||||
SettingsData.notepadFontSize = newSize;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 60
|
||||
height: 32
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3)
|
||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||
border.width: 1
|
||||
|
||||
StyledText {
|
||||
anchors.centerIn: parent
|
||||
text: SettingsData.notepadFontSize + "px"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
buttonSize: 32
|
||||
iconName: "add"
|
||||
iconSize: Theme.iconSizeSmall
|
||||
enabled: SettingsData.notepadFontSize < 48
|
||||
backgroundColor: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.5)
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: {
|
||||
var newSize = Math.min(48, SettingsData.notepadFontSize + 1);
|
||||
SettingsData.notepadFontSize = newSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: transparencySliderColumn.height + Theme.spacingS
|
||||
color: "transparent"
|
||||
|
||||
Column {
|
||||
id: transparencySliderColumn
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
height: transparencySliderColumn.height + Theme.spacingS
|
||||
color: "transparent"
|
||||
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Custom Transparency")
|
||||
description: I18n.tr("Override global transparency for Notepad")
|
||||
checked: SettingsData.notepadTransparencyOverride >= 0
|
||||
onToggled: checked => {
|
||||
if (checked) {
|
||||
SettingsData.notepadTransparencyOverride = SettingsData.notepadLastCustomTransparency;
|
||||
} else {
|
||||
SettingsData.notepadTransparencyOverride = -1;
|
||||
Column {
|
||||
id: transparencySliderColumn
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Surface Opacity")
|
||||
description: I18n.tr("Override global transparency for Notepad")
|
||||
checked: SettingsData.notepadTransparencyOverride >= 0
|
||||
onToggled: checked => {
|
||||
if (checked) {
|
||||
SettingsData.notepadTransparencyOverride = SettingsData.notepadLastCustomTransparency;
|
||||
} else {
|
||||
SettingsData.notepadTransparencyOverride = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DankSlider {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
height: 24
|
||||
visible: SettingsData.notepadTransparencyOverride >= 0
|
||||
value: Math.round((SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : SettingsData.popupTransparency) * 100)
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
unit: ""
|
||||
showValue: true
|
||||
wheelEnabled: false
|
||||
onSliderValueChanged: newValue => {
|
||||
if (SettingsData.notepadTransparencyOverride >= 0) {
|
||||
SettingsData.notepadTransparencyOverride = newValue / 100;
|
||||
DankSlider {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
height: 24
|
||||
visible: SettingsData.notepadTransparencyOverride >= 0
|
||||
value: Math.round((SettingsData.notepadTransparencyOverride >= 0 ? SettingsData.notepadTransparencyOverride : SettingsData.popupTransparency) * 100)
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
unit: ""
|
||||
showValue: true
|
||||
wheelEnabled: false
|
||||
onSliderValueChanged: newValue => {
|
||||
if (SettingsData.notepadTransparencyOverride >= 0) {
|
||||
SettingsData.notepadTransparencyOverride = newValue / 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: SettingsData.notepadUseMonospace ? I18n.tr("Using global monospace font from Settings → Personalization") : I18n.tr("Global fonts can be configured in Settings → Personalization")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
wrapMode: Text.WordWrap
|
||||
opacity: 0.8
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: gapColumn.height + Theme.spacingS
|
||||
color: "transparent"
|
||||
|
||||
Column {
|
||||
id: gapColumn
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Default Mode")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
DankButtonGroup {
|
||||
model: [I18n.tr("Slideout"), I18n.tr("Popout")]
|
||||
size: "small"
|
||||
currentIndex: SettingsData.notepadDefaultMode === "popout" ? 1 : 0
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (!selected)
|
||||
return;
|
||||
SettingsData.notepadDefaultMode = index === 1 ? "popout" : "slideout";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: SettingsData.notepadDefaultMode !== "popout"
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Open From")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
DankButtonGroup {
|
||||
model: [I18n.tr("Right"), I18n.tr("Left")]
|
||||
size: "small"
|
||||
currentIndex: SettingsData.notepadSlideoutSide === "left" ? 1 : 0
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (!selected)
|
||||
return;
|
||||
SettingsData.notepadSlideoutSide = index === 1 ? "left" : "right";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: -Theme.spacingM
|
||||
width: parent.width + Theme.spacingM
|
||||
text: I18n.tr("Auto Compositor Gaps")
|
||||
description: I18n.tr("Inset the Notepad from screen edges using the compositor's configured gaps")
|
||||
checked: SettingsData.notepadUseCompositorGap
|
||||
onToggled: checked => {
|
||||
SettingsData.notepadUseCompositorGap = checked;
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
visible: !SettingsData.notepadUseCompositorGap
|
||||
text: I18n.tr("Manual Gaps")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
DankSlider {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingXS
|
||||
width: parent.width - Theme.spacingXS * 2
|
||||
height: 24
|
||||
visible: !SettingsData.notepadUseCompositorGap
|
||||
value: SettingsData.notepadEdgeGap
|
||||
minimum: 0
|
||||
maximum: 64
|
||||
unit: "px"
|
||||
showValue: true
|
||||
wheelEnabled: false
|
||||
onSliderValueChanged: newValue => {
|
||||
SettingsData.notepadEdgeGap = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: SettingsData.notepadUseMonospace ? I18n.tr("Using global monospace font from Settings → Personalization") : I18n.tr("Global fonts can be configured in Settings → Personalization")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceTextMedium
|
||||
wrapMode: Text.WordWrap
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
implicitHeight: shortcutsHeader.height + (root.shortcutsExpanded ? shortcutsColumn.implicitHeight + Theme.spacingM : 0)
|
||||
radius: Theme.cornerRadius
|
||||
color: root.shortcutsExpanded ? Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.95) : "transparent"
|
||||
border.color: root.shortcutsExpanded ? Theme.primary : Theme.outlineMedium
|
||||
border.width: root.shortcutsExpanded ? 2 : 1
|
||||
|
||||
StateLayer {
|
||||
anchors.fill: parent
|
||||
stateColor: Theme.primary
|
||||
cornerRadius: parent.radius
|
||||
onClicked: root.shortcutsExpanded = !root.shortcutsExpanded
|
||||
}
|
||||
|
||||
Row {
|
||||
id: shortcutsHeader
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
height: 36
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: root.shortcutsExpanded ? "expand_less" : "expand_more"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Keyboard Shortcuts")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: shortcutsColumn
|
||||
visible: root.shortcutsExpanded
|
||||
width: parent.width - Theme.spacingL * 2
|
||||
anchors.top: shortcutsHeader.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Ctrl+S: Save • Ctrl+O: Open • Ctrl+N: New • Ctrl+F: Find")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("Ctrl+A: Select All • Ctrl+P: Preview • Enter/Shift+Enter: Find Next/Previous • Esc: Close")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,23 @@ Column {
|
||||
property string pluginHighlightedHtml: ""
|
||||
property string lastPluginContent: ""
|
||||
property int loadRequestId: 0
|
||||
property bool ignoreNextExternalChange: false
|
||||
property bool watcherReloadPending: false
|
||||
property bool externalWatchPaused: false
|
||||
property bool inPopout: false
|
||||
property bool surfaceVisible: true
|
||||
// Tab ids are Date.now() timestamps (~1.78e12) which overflow a 32-bit `int`,
|
||||
// corrupting the value (e.g. -946062153) and breaking buffer keying. `var`
|
||||
// holds the full JS-safe integer.
|
||||
property var loadedTabId: -1
|
||||
property bool applyingShared: false
|
||||
property bool showPathInfo: false
|
||||
|
||||
function currentFilePath() {
|
||||
if (!currentTab)
|
||||
return "";
|
||||
return currentTab.isTemporary ? (NotepadStorageService.baseDir + "/" + currentTab.filePath) : currentTab.filePath;
|
||||
}
|
||||
|
||||
signal saveRequested
|
||||
signal openRequested
|
||||
@@ -40,6 +57,10 @@ Column {
|
||||
signal escapePressed
|
||||
signal contentChanged
|
||||
signal settingsRequested
|
||||
signal popoutRequested
|
||||
signal dockRequested
|
||||
signal conflictDetected(string diskContent)
|
||||
signal autoSaveRequested
|
||||
|
||||
function hasUnsavedChanges() {
|
||||
if (!currentTab || !contentLoaded) {
|
||||
@@ -52,6 +73,12 @@ Column {
|
||||
return textArea.text !== lastSavedContent;
|
||||
}
|
||||
|
||||
function commitLiveBuffer() {
|
||||
if (loadedTabId < 0 || !contentLoaded)
|
||||
return;
|
||||
NotepadStorageService.setSessionBuffer(loadedTabId, textArea.text, lastSavedContent);
|
||||
}
|
||||
|
||||
function loadCurrentTabContent() {
|
||||
if (!currentTab)
|
||||
return;
|
||||
@@ -62,8 +89,25 @@ Column {
|
||||
const activeTab = NotepadStorageService.tabs.length > NotepadStorageService.currentTabIndex ? NotepadStorageService.tabs[NotepadStorageService.currentTabIndex] : null;
|
||||
if (requestId !== loadRequestId || !activeTab || activeTab.id !== requestedTabId)
|
||||
return;
|
||||
|
||||
const buffer = NotepadStorageService.getSessionBuffer(requestedTabId);
|
||||
if (buffer !== undefined) {
|
||||
applyingShared = true;
|
||||
lastSavedContent = buffer.baseline;
|
||||
textArea.text = buffer.content;
|
||||
applyingShared = false;
|
||||
loadedTabId = requestedTabId;
|
||||
contentLoaded = true;
|
||||
syncContentToPlugin();
|
||||
applyDiskContent(content);
|
||||
return;
|
||||
}
|
||||
|
||||
applyingShared = true;
|
||||
lastSavedContent = content;
|
||||
textArea.text = content;
|
||||
applyingShared = false;
|
||||
loadedTabId = requestedTabId;
|
||||
contentLoaded = true;
|
||||
syncContentToPlugin();
|
||||
});
|
||||
@@ -72,14 +116,56 @@ Column {
|
||||
function saveCurrentTabContent() {
|
||||
if (!currentTab || !contentLoaded)
|
||||
return;
|
||||
if (!currentTab.isTemporary)
|
||||
return;
|
||||
NotepadStorageService.saveTabContent(NotepadStorageService.currentTabIndex, textArea.text);
|
||||
lastSavedContent = textArea.text;
|
||||
NotepadStorageService.clearSessionBuffer(loadedTabId);
|
||||
}
|
||||
|
||||
function autoSaveToSession() {
|
||||
commitLiveBuffer();
|
||||
if (!currentTab || !contentLoaded)
|
||||
return;
|
||||
saveCurrentTabContent();
|
||||
if (currentTab.isTemporary) {
|
||||
saveCurrentTabContent();
|
||||
} else if (SettingsData.notepadAutoSave) {
|
||||
root.autoSaveRequested();
|
||||
}
|
||||
}
|
||||
|
||||
function syncFromDisk() {
|
||||
if (!currentTab)
|
||||
return;
|
||||
loadCurrentTabContent();
|
||||
}
|
||||
|
||||
function applyDiskContent(diskContent) {
|
||||
if (diskContent === undefined || diskContent === null)
|
||||
return;
|
||||
if (diskContent === textArea.text) {
|
||||
lastSavedContent = diskContent;
|
||||
return;
|
||||
}
|
||||
if (diskContent === lastSavedContent) {
|
||||
return;
|
||||
}
|
||||
if (textArea.text === lastSavedContent) {
|
||||
reloadFromDisk(diskContent);
|
||||
} else if (surfaceVisible) {
|
||||
conflictDetected(diskContent);
|
||||
}
|
||||
}
|
||||
|
||||
function reloadFromDisk(diskContent) {
|
||||
applyingShared = true;
|
||||
contentLoaded = false;
|
||||
textArea.text = diskContent;
|
||||
lastSavedContent = diskContent;
|
||||
contentLoaded = true;
|
||||
applyingShared = false;
|
||||
NotepadStorageService.clearSessionBuffer(loadedTabId);
|
||||
syncContentToPlugin();
|
||||
}
|
||||
|
||||
function setTextDocumentLineHeight() {
|
||||
@@ -202,7 +288,8 @@ Column {
|
||||
if (!currentTab)
|
||||
return;
|
||||
const filePath = currentTab?.filePath || "";
|
||||
const ext = filePath.split('.').pop().toLowerCase();
|
||||
const baseName = filePath.split('/').pop();
|
||||
const ext = baseName.includes('.') ? baseName.split('.').pop().toLowerCase() : "";
|
||||
const content = textArea.text;
|
||||
|
||||
if (content === lastPluginContent && SettingsData.getBuiltInPluginSetting("dankNotepadModule", "previewActive", false) === inlinePreviewVisible) {
|
||||
@@ -550,6 +637,7 @@ Column {
|
||||
Connections {
|
||||
target: NotepadStorageService
|
||||
function onCurrentTabIndexChanged() {
|
||||
root.commitLiveBuffer();
|
||||
loadCurrentTabContent();
|
||||
Qt.callLater(() => {
|
||||
textArea.forceActiveFocus();
|
||||
@@ -570,7 +658,9 @@ Column {
|
||||
}
|
||||
|
||||
onTextChanged: {
|
||||
if (contentLoaded && text !== lastSavedContent) {
|
||||
// Debounced flush to the shared buffer (+ optional disk
|
||||
// autosave) for every loaded tab, not just scratch notes.
|
||||
if (contentLoaded && !applyingShared) {
|
||||
autoSaveTimer.restart();
|
||||
}
|
||||
root.contentChanged();
|
||||
@@ -744,6 +834,7 @@ Column {
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Item {
|
||||
id: buttonBarItem
|
||||
width: parent.width
|
||||
height: 32
|
||||
|
||||
@@ -820,17 +911,98 @@ Column {
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
Row {
|
||||
id: rightButtonRow
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "more_horiz"
|
||||
iconSize: Theme.iconSize - 2
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: root.settingsRequested()
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankActionButton {
|
||||
visible: !root.inPopout
|
||||
iconName: "open_in_new"
|
||||
iconSize: Theme.iconSize - 2
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: root.popoutRequested()
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
visible: root.inPopout
|
||||
iconName: "dock_to_right"
|
||||
iconSize: Theme.iconSize - 2
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: root.dockRequested()
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
iconName: "more_horiz"
|
||||
iconSize: Theme.iconSize - 2
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: root.settingsRequested()
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
id: pathInfoPopup
|
||||
visible: root.showPathInfo
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.top
|
||||
anchors.bottomMargin: Theme.spacingS
|
||||
width: Math.min(root.width, 360)
|
||||
height: pathInfoRow.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: 1
|
||||
z: 10
|
||||
|
||||
Row {
|
||||
id: pathInfoRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: currentTab && currentTab.isTemporary ? "draft" : "description"
|
||||
size: Theme.iconSize - 4
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: pathInfoRow.width - (Theme.iconSize - 4) - copyPathButton.width - Theme.spacingS * 2
|
||||
text: root.currentFilePath()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideMiddle
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: copyPathButton
|
||||
iconName: "content_copy"
|
||||
iconSize: Theme.iconSize - 6
|
||||
iconColor: Theme.surfaceTextMedium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: {
|
||||
const proc = clipboardCopyProcComp.createObject(root, {
|
||||
content: root.currentFilePath(),
|
||||
running: true
|
||||
});
|
||||
proc.exited.connect(() => {
|
||||
ToastService.showInfo(I18n.tr("Path copied to clipboard"));
|
||||
proc.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: statusRow
|
||||
width: parent.width
|
||||
spacing: Theme.spacingL
|
||||
|
||||
@@ -853,35 +1025,46 @@ Column {
|
||||
opacity: 1.0
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (autoSaveTimer.running) {
|
||||
return I18n.tr("Auto-saving...");
|
||||
}
|
||||
Row {
|
||||
visible: textArea.text.length > 0
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
if (hasUnsavedChanges()) {
|
||||
if (currentTab && currentTab.isTemporary) {
|
||||
return I18n.tr("Unsaved note...");
|
||||
} else {
|
||||
return I18n.tr("Unsaved changes");
|
||||
StyledText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
readonly property bool savingToDisk: autoSaveTimer.running && currentTab && (currentTab.isTemporary || SettingsData.notepadAutoSave)
|
||||
text: {
|
||||
if (savingToDisk) {
|
||||
return I18n.tr("Saving...");
|
||||
}
|
||||
} else {
|
||||
return I18n.tr("Saved");
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: {
|
||||
if (autoSaveTimer.running) {
|
||||
return Theme.primary;
|
||||
}
|
||||
|
||||
if (hasUnsavedChanges()) {
|
||||
return Theme.warning;
|
||||
} else {
|
||||
return Theme.success;
|
||||
if (currentTab && currentTab.isTemporary) {
|
||||
return I18n.tr("Auto saved");
|
||||
}
|
||||
|
||||
return hasUnsavedChanges() ? I18n.tr("Unsaved changes") : I18n.tr("Saved");
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: {
|
||||
if (savingToDisk) {
|
||||
return Theme.primary;
|
||||
}
|
||||
|
||||
if (currentTab && currentTab.isTemporary) {
|
||||
return Theme.success;
|
||||
}
|
||||
|
||||
return hasUnsavedChanges() ? Theme.warning : Theme.success;
|
||||
}
|
||||
}
|
||||
opacity: textArea.text.length > 0 ? 1.0 : 0.0
|
||||
|
||||
DankActionButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "info"
|
||||
iconSize: Theme.iconSizeSmall
|
||||
iconColor: root.showPathInfo ? Theme.primary : Theme.surfaceTextMedium
|
||||
buttonSize: 20
|
||||
onClicked: root.showPathInfo = !root.showPathInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -902,6 +1085,38 @@ Column {
|
||||
onTriggered: syncContentToPlugin()
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: externalWatch
|
||||
path: (!root.externalWatchPaused && currentTab && !currentTab.isTemporary && currentTab.filePath) ? currentTab.filePath : ""
|
||||
blockLoading: true
|
||||
preload: true
|
||||
watchChanges: true
|
||||
|
||||
onFileChanged: {
|
||||
root.watcherReloadPending = true;
|
||||
reload();
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
if (root.ignoreNextExternalChange) {
|
||||
root.ignoreNextExternalChange = false;
|
||||
root.lastSavedContent = externalWatch.text();
|
||||
root.watcherReloadPending = false;
|
||||
return;
|
||||
}
|
||||
if (!root.watcherReloadPending)
|
||||
return;
|
||||
root.watcherReloadPending = false;
|
||||
if (!root.contentLoaded || !root.currentTab || root.currentTab.isTemporary)
|
||||
return;
|
||||
if (!root.surfaceVisible)
|
||||
return;
|
||||
root.applyDiskContent(externalWatch.text());
|
||||
}
|
||||
|
||||
onLoadFailed: error => {}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onBuiltInPluginSettingsChanged() {
|
||||
@@ -910,4 +1125,24 @@ Column {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: NotepadStorageService
|
||||
function onSessionBufferRevisionChanged() {
|
||||
if (applyingShared || !contentLoaded || loadedTabId < 0)
|
||||
return;
|
||||
if (textArea.activeFocus)
|
||||
return;
|
||||
var buffer = NotepadStorageService.getSessionBuffer(loadedTabId);
|
||||
if (buffer === undefined || buffer.content === textArea.text)
|
||||
return;
|
||||
if (textArea.text === lastSavedContent) {
|
||||
applyingShared = true;
|
||||
lastSavedContent = buffer.baseline;
|
||||
textArea.text = buffer.content;
|
||||
applyingShared = false;
|
||||
syncContentToPlugin();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Settings.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
Process {
|
||||
id: applyLimitProcess
|
||||
command: ["pkexec", "sh", "-c", "
|
||||
for bat in /sys/class/power_supply/BAT*; do
|
||||
if [ -f \"$bat/charge_control_limit_max\" ]; then
|
||||
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_limit_max\"
|
||||
elif [ -f \"$bat/charge_stop_threshold\" ]; then
|
||||
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_stop_threshold\"
|
||||
elif [ -f \"$bat/charge_control_end_threshold\" ]; then
|
||||
echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_end_threshold\"
|
||||
fi
|
||||
done
|
||||
"]
|
||||
running: false
|
||||
onExited: exitCode => {
|
||||
if (exitCode !== 0) {
|
||||
ToastService.showError(I18n.tr("Failed to apply charge limit to system"), I18n.tr("Process exited with code %1").arg(exitCode));
|
||||
} else {
|
||||
ToastService.showInfo(I18n.tr("Charge limit applied successfully"), I18n.tr("Limit set to %1%").arg(SettingsData.batteryChargeLimit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentHeight: mainColumn.height + Theme.spacingXL
|
||||
contentWidth: width
|
||||
|
||||
Column {
|
||||
id: mainColumn
|
||||
topPadding: 4
|
||||
width: Math.min(550, parent.width - Theme.spacingL * 2)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
// 1. Information Card
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "battery_charging_full"
|
||||
title: I18n.tr("Battery Status")
|
||||
settingKey: "batteryStatusCard"
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
StyledText {
|
||||
text: I18n.tr("Power Source")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width / 2
|
||||
}
|
||||
StyledText {
|
||||
text: BatteryService.isPluggedIn ? I18n.tr("AC Adapter (Plugged In)") : I18n.tr("Battery Power")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width / 2
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outline
|
||||
opacity: 0.1
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
StyledText {
|
||||
text: I18n.tr("Charge Level")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width / 2
|
||||
}
|
||||
StyledText {
|
||||
text: `${BatteryService.batteryLevel}%`
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width / 2
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outline
|
||||
opacity: 0.1
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
StyledText {
|
||||
text: I18n.tr("Status")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width / 2
|
||||
}
|
||||
StyledText {
|
||||
text: BatteryService.batteryStatus
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width / 2
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outline
|
||||
opacity: 0.1
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
StyledText {
|
||||
text: I18n.tr("Estimated Time")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width / 2
|
||||
}
|
||||
StyledText {
|
||||
text: BatteryService.formatTimeRemaining()
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width / 2
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outline
|
||||
opacity: 0.1
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
StyledText {
|
||||
text: I18n.tr("Battery Health")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width / 2
|
||||
}
|
||||
StyledText {
|
||||
text: BatteryService.batteryHealth
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Threshold & Limits Card
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "tune"
|
||||
title: I18n.tr("Battery Protection & Charging")
|
||||
settingKey: "batteryProtection"
|
||||
|
||||
SettingsSliderRow {
|
||||
settingKey: "batteryChargeLimit"
|
||||
text: I18n.tr("Battery Charge Limit")
|
||||
description: I18n.tr("Limit the maximum battery charge level to extend lifespan.")
|
||||
value: SettingsData.batteryChargeLimit
|
||||
minimum: 50
|
||||
maximum: 100
|
||||
defaultValue: 100
|
||||
onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue)
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
height: applyButton.height
|
||||
layoutDirection: Qt.RightToLeft
|
||||
|
||||
DankButton {
|
||||
id: applyButton
|
||||
text: I18n.tr("Apply to Hardware")
|
||||
iconName: "lock"
|
||||
backgroundColor: Theme.primary
|
||||
textColor: Theme.onPrimary
|
||||
onClicked: {
|
||||
applyLimitProcess.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "batteryNotifyChargeLimit"
|
||||
text: I18n.tr("Notify when limit is reached")
|
||||
description: I18n.tr("Show a notification when battery reaches the charge limit.")
|
||||
checked: SettingsData.batteryNotifyChargeLimit
|
||||
onToggled: checked => SettingsData.set("batteryNotifyChargeLimit", checked)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outline
|
||||
opacity: 0.15
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
settingKey: "batteryLowThreshold"
|
||||
text: I18n.tr("Low Battery Threshold")
|
||||
description: I18n.tr("Set the percentage at which the battery is considered low.")
|
||||
value: SettingsData.batteryLowThreshold
|
||||
minimum: 5
|
||||
maximum: 40
|
||||
defaultValue: 20
|
||||
onSliderValueChanged: newValue => SettingsData.set("batteryLowThreshold", newValue)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "batteryNotifyLow"
|
||||
text: I18n.tr("Low Battery Notifications")
|
||||
description: I18n.tr("Show a warning popup when battery is running low.")
|
||||
checked: SettingsData.batteryNotifyLow
|
||||
onToggled: checked => SettingsData.set("batteryNotifyLow", checked)
|
||||
}
|
||||
|
||||
SettingsButtonGroupRow {
|
||||
settingKey: "batteryNotificationType"
|
||||
text: I18n.tr("Notification Type")
|
||||
description: I18n.tr("Choose how to be notified about battery alerts.")
|
||||
model: [I18n.tr("Toast"), I18n.tr("Notification")]
|
||||
currentIndex: SettingsData.batteryNotificationType
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (selected) {
|
||||
SettingsData.set("batteryNotificationType", index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "batteryAutoPowerSaver"
|
||||
text: I18n.tr("Auto Power Saver")
|
||||
description: I18n.tr("Automatically turn on Power Saver profile when battery is low.")
|
||||
checked: SettingsData.batteryAutoPowerSaver
|
||||
onToggled: checked => SettingsData.set("batteryAutoPowerSaver", checked)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outline
|
||||
opacity: 0.15
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Critical Battery Alert")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.DemiBold
|
||||
color: Theme.surfaceText
|
||||
topPadding: Theme.spacingM
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
settingKey: "batteryCriticalThreshold"
|
||||
text: I18n.tr("Critical Threshold")
|
||||
description: I18n.tr("Battery percentage to trigger a critical alert.")
|
||||
value: SettingsData.batteryCriticalThreshold
|
||||
minimum: 1
|
||||
maximum: 30
|
||||
defaultValue: 10
|
||||
onSliderValueChanged: newValue => SettingsData.set("batteryCriticalThreshold", newValue)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "batteryNotifyCritical"
|
||||
text: I18n.tr("Critical Battery Notifications")
|
||||
description: I18n.tr("Show an urgent alert when battery reaches critical level.")
|
||||
checked: SettingsData.batteryNotifyCritical
|
||||
onToggled: checked => SettingsData.set("batteryNotifyCritical", checked)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Power Profiles Card
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "power"
|
||||
title: I18n.tr("Power Profiles Auto-Switching")
|
||||
settingKey: "powerProfilesAuto"
|
||||
|
||||
SettingsDropdownRow {
|
||||
settingKey: "acProfileName"
|
||||
text: I18n.tr("Profile when Plugged In (AC)")
|
||||
description: I18n.tr("Power profile to use when AC power is connected.")
|
||||
options: [I18n.tr("Don't Change"), Theme.getPowerProfileLabel(0), Theme.getPowerProfileLabel(1), Theme.getPowerProfileLabel(2)]
|
||||
currentValue: {
|
||||
const val = SettingsData.acProfileName;
|
||||
const idx = ["", "0", "1", "2"].indexOf(val);
|
||||
return idx >= 0 ? options[idx] : options[0];
|
||||
}
|
||||
onValueChanged: value => {
|
||||
const idx = options.indexOf(value);
|
||||
if (idx >= 0) {
|
||||
SettingsData.set("acProfileName", ["", "0", "1", "2"][idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
settingKey: "batteryProfileName"
|
||||
text: I18n.tr("Profile when on Battery")
|
||||
description: I18n.tr("Power profile to use when running on battery power.")
|
||||
options: [I18n.tr("Don't Change"), Theme.getPowerProfileLabel(0), Theme.getPowerProfileLabel(1), Theme.getPowerProfileLabel(2)]
|
||||
currentValue: {
|
||||
const val = SettingsData.batteryProfileName;
|
||||
const idx = ["", "0", "1", "2"].indexOf(val);
|
||||
return idx >= 0 ? options[idx] : options[0];
|
||||
}
|
||||
onValueChanged: value => {
|
||||
const idx = options.indexOf(value);
|
||||
if (idx >= 0) {
|
||||
SettingsData.set("batteryProfileName", ["", "0", "1", "2"][idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,6 +464,16 @@ Item {
|
||||
onToggled: checked => SettingsData.set("clipboardEnterToPaste", checked)
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "filter", "type", "remember", "behavior"]
|
||||
settingKey: "clipboardRememberTypeFilter"
|
||||
text: I18n.tr("Remember Type Filter", "Clipboard behavior setting title")
|
||||
description: I18n.tr("Keep the clipboard type filter when reopening history", "Clipboard behavior setting description")
|
||||
checked: SettingsData.clipboardRememberTypeFilter
|
||||
onToggled: checked => SettingsData.set("clipboardRememberTypeFilter", checked)
|
||||
}
|
||||
|
||||
SettingsButtonGroupRow {
|
||||
tab: "clipboard"
|
||||
tags: ["clipboard", "actions", "buttons", "hide", "density", "pin", "edit", "delete"]
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Settings.Widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property string description: ""
|
||||
property string settingKey: ""
|
||||
property string tab: ""
|
||||
property var tags: []
|
||||
property var options: []
|
||||
property string currentMode: "default"
|
||||
property color customColor: "#6750A4"
|
||||
property string pickerTitle: text
|
||||
property int dropdownWidth: 230
|
||||
property color defaultColor: Theme.primary
|
||||
|
||||
readonly property var optionColorMap: {
|
||||
var map = {};
|
||||
for (var i = 0; i < options.length; i++)
|
||||
map[options[i].label] = root.colorForValue(options[i].value);
|
||||
return map;
|
||||
}
|
||||
|
||||
function colorForValue(value) {
|
||||
switch (value) {
|
||||
case "custom":
|
||||
return root.customColor;
|
||||
case "none":
|
||||
return "transparent";
|
||||
case "default":
|
||||
return root.defaultColor;
|
||||
default:
|
||||
return Theme.roleColor(value);
|
||||
}
|
||||
}
|
||||
|
||||
signal modeSelected(string mode)
|
||||
signal customColorSelected(color selectedColor)
|
||||
|
||||
width: parent?.width ?? 0
|
||||
spacing: Theme.spacingS
|
||||
|
||||
function optionLabels() {
|
||||
return options.map(option => option.label);
|
||||
}
|
||||
|
||||
function optionLabel(value) {
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
if (options[i].value === value)
|
||||
return options[i].label;
|
||||
}
|
||||
return options.length > 0 ? options[0].label : "";
|
||||
}
|
||||
|
||||
function optionValue(label) {
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
if (options[i].label === label)
|
||||
return options[i].value;
|
||||
}
|
||||
return options.length > 0 ? options[0].value : "default";
|
||||
}
|
||||
|
||||
function openCustomColorPicker() {
|
||||
PopoutService.colorPickerModal.selectedColor = root.customColor;
|
||||
PopoutService.colorPickerModal.pickerTitle = root.pickerTitle;
|
||||
PopoutService.colorPickerModal.onColorSelectedCallback = function (selectedColor) {
|
||||
root.customColorSelected(selectedColor);
|
||||
root.modeSelected("custom");
|
||||
};
|
||||
PopoutService.colorPickerModal.show();
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
text: root.text
|
||||
description: root.description
|
||||
tab: root.tab
|
||||
settingKey: root.settingKey
|
||||
tags: root.tags
|
||||
options: root.optionLabels()
|
||||
optionColorMap: root.optionColorMap
|
||||
currentValue: root.optionLabel(root.currentMode)
|
||||
dropdownWidth: root.dropdownWidth
|
||||
onValueChanged: value => root.modeSelected(root.optionValue(value))
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.currentMode === "custom" ? customChip.height : 0
|
||||
opacity: root.currentMode === "custom" ? 1 : 0
|
||||
clip: true
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: Theme.mediumDuration
|
||||
easing.type: Theme.emphasizedEasing
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation {
|
||||
duration: Theme.mediumDuration
|
||||
easing.type: Theme.emphasizedEasing
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: customChip
|
||||
|
||||
width: parent.width
|
||||
height: 56
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Rectangle {
|
||||
width: 36
|
||||
height: 36
|
||||
radius: 18
|
||||
color: root.customColor
|
||||
border.color: Theme.outline
|
||||
border.width: 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "colorize"
|
||||
size: 16
|
||||
color: (root.customColor.r * 0.299 + root.customColor.g * 0.587 + root.customColor.b * 0.114) > 0.5 ? "#000000" : "#ffffff"
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - 36 - editIcon.width - Theme.spacingM * 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Custom Color")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: root.customColor.toString()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
id: editIcon
|
||||
name: "edit"
|
||||
size: Theme.iconSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
stateColor: Theme.surfaceText
|
||||
onClicked: root.openCustomColorPicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,13 +858,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsControlledByFrame {
|
||||
visible: dankBarTab.appearanceOnly && SettingsData.frameEnabled
|
||||
parentModal: dankBarTab.parentModal
|
||||
settingLabel: I18n.tr("Bar spacing and size")
|
||||
reason: I18n.tr("Managed by Frame")
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
tab: "appearance"
|
||||
iconName: "space_bar"
|
||||
|
||||
@@ -113,6 +113,13 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
text: I18n.tr("Device list scroll volume")
|
||||
description: I18n.tr("Allow adjusting device volume by scrolling on the right half of items in the device list")
|
||||
checked: SettingsData.audioDeviceScrollVolumeEnabled
|
||||
onToggled: checked => SettingsData.set("audioDeviceScrollVolumeEnabled", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modules.Settings.Widgets
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: networkEthernetTab
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
Component.onCompleted: {
|
||||
NetworkService.addRef();
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
NetworkService.removeRef();
|
||||
}
|
||||
|
||||
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.spacingL
|
||||
|
||||
SettingsCard {
|
||||
id: root
|
||||
|
||||
property string expandedEthDevice: ""
|
||||
|
||||
title: I18n.tr("Ethernet")
|
||||
iconName: "settings_ethernet"
|
||||
settingKey: "networkEthernet"
|
||||
tags: ["ethernet", "wired", "network", "adapters", "connection"]
|
||||
|
||||
width: parent.width
|
||||
|
||||
Column {
|
||||
id: ethernetSection
|
||||
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
const devices = NetworkService.ethernetDevices;
|
||||
const connected = devices.filter(d => d.connected).length;
|
||||
if (devices.length === 0)
|
||||
return I18n.tr("No adapters");
|
||||
if (connected === 0)
|
||||
return devices.length === 1 ? I18n.tr("%1 adapter, none connected").arg(devices.length) : I18n.tr("%1 adapters, none connected").arg(devices.length);
|
||||
return I18n.tr("%1 connected").arg(connected);
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: NetworkService.ethernetConnected ? Theme.primary : Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
visible: NetworkService.ethernetDevices.length > 0
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Adapters")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: NetworkService.ethernetDevices
|
||||
|
||||
delegate: Rectangle {
|
||||
id: ethDeviceDelegate
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool isConnected: modelData.connected || false
|
||||
readonly property bool isExpanded: root.expandedEthDevice === modelData.name
|
||||
|
||||
width: parent.width
|
||||
height: isExpanded ? 56 + ethExpandedContent.height : 56
|
||||
radius: Theme.cornerRadius
|
||||
color: ethDeviceMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||
border.width: isConnected ? 2 : 0
|
||||
border.color: Theme.primary
|
||||
clip: true
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 56
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: ethDeviceActions.left
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "lan"
|
||||
size: 20
|
||||
color: isConnected ? Theme.primary : Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
width: parent.width - 20 - Theme.spacingS
|
||||
|
||||
StyledText {
|
||||
text: modelData.name || I18n.tr("Unknown")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: isConnected ? Theme.primary : Theme.surfaceText
|
||||
font.weight: isConnected ? Font.Medium : Font.Normal
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
switch (modelData.state) {
|
||||
case "activated":
|
||||
return I18n.tr("Connected");
|
||||
case "disconnected":
|
||||
return I18n.tr("Disconnected");
|
||||
case "unavailable":
|
||||
return I18n.tr("Unavailable");
|
||||
default:
|
||||
return modelData.state || I18n.tr("Unknown");
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: isConnected ? Theme.primary : Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: "•"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
visible: (modelData.ip || "").length > 0
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.ip || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
visible: (modelData.ip || "").length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: ethDeviceActions
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
color: ethExpandBtn.containsMouse ? Theme.surfacePressed : "transparent"
|
||||
visible: isConnected
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: isExpanded ? "expand_less" : "expand_more"
|
||||
size: 18
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: ethExpandBtn
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (isExpanded) {
|
||||
root.expandedEthDevice = "";
|
||||
} else {
|
||||
root.expandedEthDevice = modelData.name;
|
||||
NetworkService.fetchWiredNetworkInfo(NetworkService.ethernetConnectionUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
color: ethDisconnectBtn.containsMouse ? Theme.errorHover : "transparent"
|
||||
visible: isConnected
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "link_off"
|
||||
size: 18
|
||||
color: ethDisconnectBtn.containsMouse ? Theme.error : Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: ethDisconnectBtn
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: NetworkService.disconnectEthernetDevice(modelData.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: ethDeviceMouseArea
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: ethDeviceActions.width + Theme.spacingM
|
||||
hoverEnabled: true
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: ethExpandedContent
|
||||
width: parent.width
|
||||
visible: isExpanded
|
||||
|
||||
Rectangle {
|
||||
width: parent.width - Theme.spacingM * 2
|
||||
height: 1
|
||||
x: Theme.spacingM
|
||||
color: Theme.outlineLight
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: ethDetailsColumn.implicitHeight + Theme.spacingM * 2
|
||||
|
||||
Column {
|
||||
id: ethDetailsColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
Repeater {
|
||||
model: {
|
||||
const fields = [];
|
||||
const dev = modelData;
|
||||
if (!dev)
|
||||
return fields;
|
||||
|
||||
if (dev.ip)
|
||||
fields.push({
|
||||
label: I18n.tr("IP"),
|
||||
value: dev.ip
|
||||
});
|
||||
if (dev.speed && dev.speed > 0)
|
||||
fields.push({
|
||||
label: I18n.tr("Speed"),
|
||||
value: dev.speed + " Mbps"
|
||||
});
|
||||
if (dev.hwAddress)
|
||||
fields.push({
|
||||
label: I18n.tr("MAC"),
|
||||
value: dev.hwAddress
|
||||
});
|
||||
if (dev.driver)
|
||||
fields.push({
|
||||
label: I18n.tr("Driver"),
|
||||
value: dev.driver
|
||||
});
|
||||
fields.push({
|
||||
label: I18n.tr("State"),
|
||||
value: dev.state || I18n.tr("Unknown")
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: ethFieldContent.width + Theme.spacingM * 2
|
||||
height: 32
|
||||
radius: Theme.cornerRadius - 2
|
||||
color: Theme.surfaceContainerHigh
|
||||
border.width: 1
|
||||
border.color: Theme.outlineLight
|
||||
|
||||
Row {
|
||||
id: ethFieldContent
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: modelData.label + ":"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.value
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: NetworkService.networkWiredInfoLoading ? 40 : 0
|
||||
visible: NetworkService.networkWiredInfoLoading
|
||||
|
||||
DankSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
visible: NetworkService.wiredConnections.length > 0
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Saved Configurations")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: NetworkService.wiredConnections
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
height: 48
|
||||
radius: Theme.cornerRadius
|
||||
color: wiredMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||
border.width: modelData.isActive ? 2 : 0
|
||||
border.color: Theme.primary
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "lan"
|
||||
size: 20
|
||||
color: modelData.isActive ? Theme.primary : Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
text: modelData.id || I18n.tr("Unknown")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: modelData.isActive ? Theme.primary : Theme.surfaceText
|
||||
font.weight: modelData.isActive ? Font.Medium : Font.Normal
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.isActive ? I18n.tr("Active") : ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
visible: modelData.isActive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: wiredMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (!modelData.isActive) {
|
||||
NetworkService.connectToSpecificWiredConfig(modelData.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modules.Settings.Widgets
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: networkStatusTab
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
Component.onCompleted: {
|
||||
NetworkService.addRef();
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
NetworkService.removeRef();
|
||||
}
|
||||
|
||||
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.spacingL
|
||||
|
||||
SettingsCard {
|
||||
id: root
|
||||
|
||||
title: I18n.tr("Network Status")
|
||||
iconName: "lan"
|
||||
settingKey: "networkStatus"
|
||||
tags: ["status", "network", "connectivity", "internet"]
|
||||
|
||||
width: parent.width
|
||||
|
||||
Column {
|
||||
id: overviewSection
|
||||
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Overview of your network connections")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
||||
}
|
||||
|
||||
Grid {
|
||||
columns: 2
|
||||
columnSpacing: Theme.spacingL
|
||||
rowSpacing: Theme.spacingS
|
||||
width: parent.width
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Backend")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
StyledText {
|
||||
text: NetworkService.backend || I18n.tr("Unknown")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Status")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
Row {
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: {
|
||||
switch (NetworkService.networkStatus) {
|
||||
case "ethernet":
|
||||
case "wifi":
|
||||
return Theme.success;
|
||||
case "disconnected":
|
||||
return Theme.error;
|
||||
default:
|
||||
return Theme.warning;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
switch (NetworkService.networkStatus) {
|
||||
case "ethernet":
|
||||
return I18n.tr("Ethernet");
|
||||
case "wifi":
|
||||
return I18n.tr("WiFi");
|
||||
case "disconnected":
|
||||
return I18n.tr("Disconnected");
|
||||
default:
|
||||
return NetworkService.networkStatus || I18n.tr("Unknown");
|
||||
}
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Primary")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
visible: NetworkService.primaryConnection.length > 0
|
||||
}
|
||||
StyledText {
|
||||
text: NetworkService.primaryConnection || "-"
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
visible: NetworkService.primaryConnection.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
visible: NetworkService.backend === "networkmanager" && NetworkService.ethernetConnected && NetworkService.wifiConnected
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Preference")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width - preferenceLabel.width - preferenceButtons.width - Theme.spacingM * 2
|
||||
height: 1
|
||||
}
|
||||
|
||||
DankButtonGroup {
|
||||
id: preferenceButtons
|
||||
model: [I18n.tr("Auto"), I18n.tr("Ethernet"), I18n.tr("WiFi")]
|
||||
currentIndex: {
|
||||
switch (NetworkService.userPreference) {
|
||||
case "ethernet":
|
||||
return 1;
|
||||
case "wifi":
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
onSelectionChanged: (index, selected) => {
|
||||
if (!selected)
|
||||
return;
|
||||
switch (index) {
|
||||
case 0:
|
||||
NetworkService.setNetworkPreference("auto");
|
||||
break;
|
||||
case 1:
|
||||
NetworkService.setNetworkPreference("ethernet");
|
||||
break;
|
||||
case 2:
|
||||
NetworkService.setNetworkPreference("wifi");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: preferenceLabel
|
||||
visible: false
|
||||
text: I18n.tr("Preference")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Modules.Settings.Widgets
|
||||
import qs.Modals.Common
|
||||
import qs.Modals.FileBrowser
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: networkVpnTab
|
||||
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
Component.onCompleted: {
|
||||
NetworkService.addRef();
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
NetworkService.removeRef();
|
||||
}
|
||||
|
||||
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.spacingL
|
||||
|
||||
SettingsCard {
|
||||
id: root
|
||||
|
||||
property string expandedVpnUuid: ""
|
||||
|
||||
title: I18n.tr("VPN")
|
||||
iconName: "vpn_key"
|
||||
settingKey: "networkVpn"
|
||||
tags: ["vpn", "network", "profiles", "import", "openvpn", "wireguard"]
|
||||
|
||||
function openVpnFileBrowser() {
|
||||
vpnFileBrowserLoader.active = true;
|
||||
if (vpnFileBrowserLoader.item)
|
||||
vpnFileBrowserLoader.item.open();
|
||||
}
|
||||
|
||||
property var vpnFileBrowserLoader: LazyLoader {
|
||||
active: false
|
||||
|
||||
FileBrowserModal {
|
||||
browserTitle: I18n.tr("Import VPN")
|
||||
browserIcon: "vpn_key"
|
||||
browserType: "vpn"
|
||||
fileExtensions: VPNService.getFileFilter()
|
||||
|
||||
onFileSelected: path => {
|
||||
VPNService.importVpn(path.replace("file://", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
property var deleteVpnConfirm: ConfirmModal {}
|
||||
|
||||
width: parent.width
|
||||
|
||||
Column {
|
||||
id: vpnSection
|
||||
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Unavailable")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
visible: !DMSNetworkService.vpnAvailable
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
visible: DMSNetworkService.vpnAvailable
|
||||
|
||||
StyledText {
|
||||
text: {
|
||||
if (!DMSNetworkService.connected)
|
||||
return I18n.tr("Disconnected");
|
||||
const names = DMSNetworkService.activeNames || [];
|
||||
if (names.length <= 1)
|
||||
return names[0] || I18n.tr("Connected");
|
||||
return names[0] + " +" + (names.length - 1);
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: DMSNetworkService.connected ? Theme.primary : Theme.surfaceVariantText
|
||||
width: parent.width - vpnHeaderControls.width - Theme.spacingM
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Row {
|
||||
id: vpnHeaderControls
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Rectangle {
|
||||
height: 28
|
||||
radius: 14
|
||||
width: importVpnRow.width + Theme.spacingM * 2
|
||||
color: importVpnArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight
|
||||
opacity: VPNService.importing ? 0.5 : 1.0
|
||||
|
||||
Row {
|
||||
id: importVpnRow
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankIcon {
|
||||
name: VPNService.importing ? "sync" : "add"
|
||||
size: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Import")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: importVpnArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: VPNService.importing ? Qt.BusyCursor : Qt.PointingHandCursor
|
||||
enabled: !VPNService.importing
|
||||
onClicked: root.openVpnFileBrowser()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 28
|
||||
radius: 14
|
||||
width: disconnectAllRow.width + Theme.spacingM * 2
|
||||
color: disconnectAllArea.containsMouse ? Theme.errorHover : Theme.surfaceLight
|
||||
visible: DMSNetworkService.connected
|
||||
opacity: DMSNetworkService.isBusy ? 0.5 : 1.0
|
||||
|
||||
Row {
|
||||
id: disconnectAllRow
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankIcon {
|
||||
name: "link_off"
|
||||
size: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Disconnect")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: disconnectAllArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: DMSNetworkService.isBusy ? Qt.BusyCursor : Qt.PointingHandCursor
|
||||
enabled: !DMSNetworkService.isBusy
|
||||
onClicked: DMSNetworkService.disconnectAllActive()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
|
||||
visible: DMSNetworkService.vpnAvailable
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 100
|
||||
visible: DMSNetworkService.vpnAvailable && DMSNetworkService.profiles.length === 0
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: "vpn_key_off"
|
||||
size: 36
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("No VPN profiles")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Click Import to add a .ovpn or .conf")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
visible: DMSNetworkService.vpnAvailable && DMSNetworkService.profiles.length > 0
|
||||
|
||||
Repeater {
|
||||
model: DMSNetworkService.profiles
|
||||
|
||||
delegate: Rectangle {
|
||||
id: vpnProfileRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool isActive: DMSNetworkService.isActiveUuid(modelData.uuid)
|
||||
readonly property bool isTransient: !!modelData.transient
|
||||
readonly property bool canExpand: modelData.canExpand !== false
|
||||
readonly property bool canDelete: modelData.canDelete !== false
|
||||
readonly property bool isExpanded: root.expandedVpnUuid === modelData.uuid
|
||||
readonly property var configData: (!isTransient && isExpanded) ? VPNService.editConfig : null
|
||||
|
||||
width: parent.width
|
||||
height: isExpanded ? 56 + vpnExpandedContent.height : 56
|
||||
radius: Theme.cornerRadius
|
||||
color: vpnRowArea.containsMouse ? Theme.primaryHoverLight : (isActive ? Theme.primaryPressed : Theme.surfaceLight)
|
||||
border.width: isActive ? 2 : 0
|
||||
border.color: Theme.primary
|
||||
opacity: DMSNetworkService.isBusy ? 0.6 : 1.0
|
||||
clip: true
|
||||
|
||||
Behavior on height {
|
||||
NumberAnimation {
|
||||
duration: 150
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: vpnRowArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: DMSNetworkService.isBusy ? Qt.BusyCursor : Qt.PointingHandCursor
|
||||
enabled: !DMSNetworkService.isBusy
|
||||
onClicked: DMSNetworkService.toggle(modelData.uuid)
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingS
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
height: 56 - Theme.spacingS * 2
|
||||
spacing: Theme.spacingS
|
||||
|
||||
DankIcon {
|
||||
name: isActive ? "vpn_lock" : "vpn_key_off"
|
||||
size: 20
|
||||
color: isActive ? Theme.primary : Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - 20 - ((canExpand ? 28 : 0) + (canDelete ? 28 : 0)) - Theme.spacingS * 4
|
||||
|
||||
StyledText {
|
||||
text: modelData.name
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: isActive ? Theme.primary : Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: VPNService.getVpnTypeFromProfile(modelData)
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: Theme.spacingXS
|
||||
height: 1
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
color: vpnExpandBtn.containsMouse ? Theme.surfacePressed : "transparent"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: canExpand
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: isExpanded ? "expand_less" : "expand_more"
|
||||
size: 18
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: vpnExpandBtn
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (isExpanded) {
|
||||
root.expandedVpnUuid = "";
|
||||
} else {
|
||||
root.expandedVpnUuid = modelData.uuid;
|
||||
VPNService.getConfig(modelData.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: 14
|
||||
color: vpnDeleteBtn.containsMouse ? Theme.errorHover : "transparent"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: canDelete
|
||||
|
||||
DankIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "delete"
|
||||
size: 18
|
||||
color: vpnDeleteBtn.containsMouse ? Theme.error : Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: vpnDeleteBtn
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
deleteVpnConfirm.showWithOptions({
|
||||
title: I18n.tr("Delete VPN"),
|
||||
message: I18n.tr("Delete \"%1\"?").arg(modelData.name),
|
||||
confirmText: I18n.tr("Delete"),
|
||||
confirmColor: Theme.error,
|
||||
onConfirm: () => VPNService.deleteVpn(modelData.uuid)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: vpnExpandedContent
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: !isTransient && isExpanded
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.outlineLight
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: VPNService.configLoading ? 40 : 0
|
||||
visible: VPNService.configLoading
|
||||
|
||||
DankSpinner {
|
||||
anchors.centerIn: parent
|
||||
size: 20
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingXS
|
||||
visible: !VPNService.configLoading && configData
|
||||
|
||||
Repeater {
|
||||
model: {
|
||||
if (!configData)
|
||||
return [];
|
||||
const fields = [];
|
||||
const data = configData.data || {};
|
||||
|
||||
if (data.remote)
|
||||
fields.push({
|
||||
label: I18n.tr("Server"),
|
||||
value: data.remote
|
||||
});
|
||||
if (configData.username || data.username)
|
||||
fields.push({
|
||||
label: I18n.tr("Username"),
|
||||
value: configData.username || data.username
|
||||
});
|
||||
if (data.cipher)
|
||||
fields.push({
|
||||
label: I18n.tr("Cipher"),
|
||||
value: data.cipher
|
||||
});
|
||||
if (data.auth)
|
||||
fields.push({
|
||||
label: I18n.tr("Auth"),
|
||||
value: data.auth
|
||||
});
|
||||
if (data["proto-tcp"] === "yes" || data["proto-tcp"] === "no")
|
||||
fields.push({
|
||||
label: I18n.tr("Protocol"),
|
||||
value: data["proto-tcp"] === "yes" ? "TCP" : "UDP"
|
||||
});
|
||||
if (data["tunnel-mtu"])
|
||||
fields.push({
|
||||
label: I18n.tr("MTU"),
|
||||
value: data["tunnel-mtu"]
|
||||
});
|
||||
if (data["connection-type"])
|
||||
fields.push({
|
||||
label: I18n.tr("Auth Type"),
|
||||
value: data["connection-type"]
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: vpnFieldContent.width + Theme.spacingM * 2
|
||||
height: 32
|
||||
radius: Theme.cornerRadius - 2
|
||||
color: Theme.surfaceContainerHigh
|
||||
border.width: 1
|
||||
border.color: Theme.outlineLight
|
||||
|
||||
Row {
|
||||
id: vpnFieldContent
|
||||
anchors.centerIn: parent
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: modelData.label + ":"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.value
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
width: parent.width
|
||||
text: I18n.tr("Autoconnect")
|
||||
checked: configData ? (configData.autoconnect || false) : false
|
||||
visible: !VPNService.configLoading && configData !== null
|
||||
onToggled: checked => {
|
||||
VPNService.updateConfig(modelData.uuid, {
|
||||
autoconnect: checked
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: 1
|
||||
height: Theme.spacingXS
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user