1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-05 21:15:38 -05:00

feat: Add browser picker modal for URL handling (#815)

* feat: add browser picker for opening URLs

- Introduce a QML modal allowing users to select a web browser to open a given URL.
- Add a CLI command `dms open <url>` that sends a `browser.open` request to the DMS server.
- Implement server‑side Browser manager, request handling, and subscription handling to propagate open events to clients.
- Extend router and server initialization to register the new “browser” capability and include it in advertised capabilities.
- Expose `openUrlRequested` signal in DMSService.qml and connect it to the modal for seamless UI activation.
- Add a desktop entry for the Browser Picker and update the active subscriptions list to include the browser service.

* fix(browser-picker): resolve QML errors in BrowserPickerModal and DMSShell

* fix(browser-picker): fix socket discovery in dms open command

* feat: add keyboard navigation and dynamic model to browser picker

- Replace the static browsers array with a ListModel built from AppSearchService, ensuring robust iteration and future‑proofing of the browser list.
- Introduce keyboard navigation (arrow keys and Enter) using selectedIndex and gridColumns, allowing users to select a browser without a mouse.
- Reset URL, selected index, and navigation flag when the modal closes to avoid stale state.
- Redesign the grid layout to compute cell width from columns, improve focus handling, and use AppLauncherGridDelegate for a consistent UI.
- Enhance delegate behavior to update selection on hover and reset keyboard navigation state appropriately.

* feat: add searchable list/grid view to browser picker

- Introduce view mode setting (list or grid) saved in SettingsData for persistent user preference
- Add search field with real‑time filtering to quickly locate a browser by name
- Sort browsers by usage frequency from AppUsageHistoryData, falling back to alphabetical order
- Provide UI toggle buttons to switch between list and grid layouts, updating the stored setting
- Adjust keyboard navigation logic to support both layouts and improve focus handling
- Refine modal dimensions and header layout for better visual consistency
- Record launched browser usage to keep usage rankings up‑to‑date.

* feat(browser-picker): improve UX with search, view persistence, and usage tracking

Enhance BrowserPickerModal to match AppLauncher design and functionality:

UI/UX Improvements:
- Add search bar with DankTextField for filtering browsers
- Move view mode switcher (list/grid) to header next to title
- Persist view mode preference to SettingsData.browserPickerViewMode
- Match AppLauncher dimensions (520x500)
- Add proper spacing between list items
- Improve URL display with truncation (single line, elide middle)
- Remove redundant close button

Functionality:
- Implement separate browser usage tracking in SettingsData.browserUsageHistory
- Sort browsers by most recently used (independent from app launcher stats)
- Add keyboard navigation auto-scrolling for list and grid views
- Track usage count, last used timestamp, and browser name
- Filter browsers by search query

Technical:
- Add ensureVisible() functions to DankListView and DankGridView
- Store browser usage with count, lastUsed, and name fields
- Update browser list reactively on search query changes

* feat(browser-picker): use appLauncherGridColumns setting for grid layout

Make browser picker grid view respect the same column setting as the app launcher
for consistent UI across both components.

* refactor: make browser picker extensible for any MIME type/category

Refactor browser picker into a generic, reusable application picker
system that can handle any MIME type or application category, similar
to Junction. This addresses the maintainer feedback about making the
functionality "as re-usable as possible."

Frontend (QML):
- Create generic AppPickerModal component (~450 lines)
  - Configurable filtering by application categories
  - Customizable title, view modes, and usage tracking
  - Emits applicationSelected signal for flexibility
- Refactor BrowserPickerModal as thin wrapper (473 → 46 lines)
  - Demonstrates how to create specialized pickers
  - Maintains all existing browser picker functionality

Backend (Go):
- Rename browser package to apppicker for clarity
- Enhance event model to support:
  - MIME types (for future file associations)
  - Application categories (WebBrowser, Office, Graphics, etc.)
  - Request types (url, file, custom)
- Maintain backward compatibility with browser.open method
- Add new apppicker.open method for generic usage

CLI:
- Rename commands_browser.go to commands_open.go
- Add extensibility flags:
  --mime/-m: Filter by MIME type
  --category/-c: Filter by category (repeatable)
  --type/-t: Specify request type
- Examples:
  dms open file.pdf --category Office
  dms open image.png --category Graphics

DMSService:
- Add appPickerRequested signal for generic events
- Smart routing between URL and generic app picker events
- Fully backward compatible

Benefits:
- Easy to create new pickers (~15 lines of wrapper code)
- Foundation for universal file handling system
- Consistent UX across all picker types
- Ready for MIME type associations

Future extensions:
- PDF picker, image viewer picker, text editor picker
- Default application management
- File association UI in settings
- Multi-MIME type desktop file integration

* fix(cli): remove all shorthands from open command flags for consistency

Remove shorthands from --mime, --category, and --type flags to maintain
consistency and avoid conflicts with global flags.

Flags now (all long-form only):
- --category: Application categories
- --mime: MIME type
- --type: Request type

Global flags still available:
- --config, -c: Config directory path

* style: apply gofmt formatting to apppicker files

Fix formatting issues caught by CI:
- Align struct field spacing in OpenEvent
- Align variable declaration spacing
- Fix Args field alignment in cobra.Command

* feat(apppicker): add generic file opener with auto MIME detection

Implements Junction-style generic file opening capabilities:

**Backend (Go):**
- Enhanced CLI to parse file:// URIs and extract file paths
- Auto-detect MIME types from file extensions using Go's mime package
- Auto-map MIME types to desktop categories:
  - Images → Graphics, Viewer
  - Videos → Video, AudioVideo
  - Audio → Audio, AudioVideo
  - Text → TextEditor, Office (or WebBrowser for HTML)
  - PDFs → Office, Viewer
  - Office docs → Office
  - Archives → Archiving, Utility
- Added debug logging to CLI and server handler for troubleshooting

**Frontend (QML):**
- Added generic AppPickerModal (filePickerModal) for file selection
- Connected to DMSService.appPickerRequested signal
- Implemented onApplicationSelected handler with desktop entry field code support:
  - %f/%F for file paths
  - %u/%U for file:// URIs
  - Fallback to appending path if no field codes
- Separate usage tracking: filePickerUsageHistory

**Desktop Integration:**
- Updated dms-open.desktop to handle x-scheme-handler/file
- Changed category from Network;WebBrowser to Utility (more generic)
- Added text/html to MIME types

**Usage:**
Set DMS as default for specific MIME types in ~/.config/mimeapps.list:
  text/plain=dms-open.desktop
  image/png=dms-open.desktop
  application/pdf=dms-open.desktop

Then use:
  xdg-open file.txt
  xdg-open image.png
  dms open document.pdf

The picker will show appropriate apps based on auto-detected categories.

Related to #815

* fix: resolve relative path handling by converting to absolute paths

- Convert file:// URIs to absolute filesystem paths for reliable file resolution
- Convert plain local file arguments to absolute paths to ensure consistent processing
- Update log messages to display absolute paths, improving traceability
- Retain request type detection while using absolute path extensions for MIME type inference

* feat(app-picker): add Tab key view toggle and fix targetData binding

- Add Tab key to toggle between grid and list views for better keyboard UX
- Fix bug where targetData binding broke after first modal close
  - Removed targetData reset from onDialogClosed
  - Parent components (BrowserPickerModal, filePickerModal) now manage targetData
  - Fixes issue where URL/file path disappeared on subsequent opens

* fix(app-picker): properly escape URLs and file paths for shell execution

- Add shellEscape() function to wrap arguments in single quotes
- Prevents shell interpretation of special characters (&, ?, =, spaces, etc.)
- Fixes bug where URLs with query parameters were truncated at first &
- Example: http://localhost:36275/vnc.html?autoconnect=true&reconnect=true
  now properly passes the full URL instead of cutting at first &
- Applied to both BrowserPickerModal (URLs) and filePickerModal (file paths)

* fix: check error return from InitializeAppPickerManager
This commit is contained in:
Jon Rogers
2025-11-30 22:41:37 -05:00
committed by GitHub
parent 7959a79575
commit 1b6d567451
15 changed files with 1125 additions and 8 deletions

10
assets/dms-open.desktop Normal file
View File

@@ -0,0 +1,10 @@
[Desktop Entry]
Type=Application
Name=DMS Application Picker
Comment=Select an application to open links and files
Exec=dms open %u
Icon=danklogo
Terminal=false
NoDisplay=true
MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/file;text/html;
Categories=Utility;

View File

@@ -0,0 +1,224 @@
package main
import (
"encoding/json"
"fmt"
"mime"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/spf13/cobra"
)
var (
openMimeType string
openCategories []string
openRequestType string
)
var openCmd = &cobra.Command{
Use: "open [target]",
Short: "Open a file, URL, or resource with an application picker",
Long: `Open a target (URL, file, or other resource) using the DMS application picker.
By default, this opens URLs with the browser picker. You can customize the behavior
with flags to handle different MIME types or application categories.
Examples:
dms open https://example.com # Open URL with browser picker
dms open file.pdf --mime application/pdf # Open PDF with compatible apps
dms open document.odt --category Office # Open with office applications
dms open --mime image/png image.png # Open image with image viewers`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runOpen(args[0])
},
}
func init() {
rootCmd.AddCommand(openCmd)
openCmd.Flags().StringVar(&openMimeType, "mime", "", "MIME type for filtering applications")
openCmd.Flags().StringSliceVar(&openCategories, "category", []string{}, "Application categories to filter (e.g., WebBrowser, Office, Graphics)")
openCmd.Flags().StringVar(&openRequestType, "type", "url", "Request type (url, file, or custom)")
}
// mimeTypeToCategories maps MIME types to desktop file categories
func mimeTypeToCategories(mimeType string) []string {
// Split MIME type to get the main type
parts := strings.Split(mimeType, "/")
if len(parts) < 1 {
return nil
}
mainType := parts[0]
switch mainType {
case "image":
return []string{"Graphics", "Viewer"}
case "video":
return []string{"Video", "AudioVideo"}
case "audio":
return []string{"Audio", "AudioVideo"}
case "text":
if strings.Contains(mimeType, "html") {
return []string{"WebBrowser"}
}
return []string{"TextEditor", "Office"}
case "application":
if strings.Contains(mimeType, "pdf") {
return []string{"Office", "Viewer"}
}
if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") ||
strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") ||
strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") ||
strings.Contains(mimeType, "opendocument") {
return []string{"Office"}
}
if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") ||
strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") {
return []string{"Archiving", "Utility"}
}
return []string{"Office", "Viewer"}
}
return nil
}
func runOpen(target string) {
socketPath, err := server.FindSocket()
if err != nil {
log.Warnf("DMS socket not found: %v", err)
fmt.Println("DMS is not running. Please start DMS first.")
os.Exit(1)
}
conn, err := net.Dial("unix", socketPath)
if err != nil {
log.Warnf("DMS socket connection failed: %v", err)
fmt.Println("DMS is not running. Please start DMS first.")
os.Exit(1)
}
defer conn.Close()
buf := make([]byte, 1)
for {
_, err := conn.Read(buf)
if err != nil {
return
}
if buf[0] == '\n' {
break
}
}
// Parse file:// URIs to extract the actual file path
actualTarget := target
detectedMimeType := openMimeType
detectedCategories := openCategories
detectedRequestType := openRequestType
log.Infof("Processing target: %s", target)
if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" {
// Extract file path from file:// URI and convert to absolute path
actualTarget = parsedURL.Path
if absPath, err := filepath.Abs(actualTarget); err == nil {
actualTarget = absPath
}
if detectedRequestType == "url" || detectedRequestType == "" {
detectedRequestType = "file"
}
log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget)
// Auto-detect MIME type if not provided
if detectedMimeType == "" {
ext := filepath.Ext(actualTarget)
if ext != "" {
detectedMimeType = mime.TypeByExtension(ext)
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
}
}
// Auto-detect categories based on MIME type if not provided
if len(detectedCategories) == 0 && detectedMimeType != "" {
detectedCategories = mimeTypeToCategories(detectedMimeType)
log.Infof("Detected categories from MIME type: %v", detectedCategories)
}
} else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
// Handle HTTP(S) URLs
if detectedRequestType == "" {
detectedRequestType = "url"
}
log.Infof("Detected HTTP(S) URL")
} else if _, err := os.Stat(target); err == nil {
// Handle local file paths directly (not file:// URIs)
// Convert to absolute path
if absPath, err := filepath.Abs(target); err == nil {
actualTarget = absPath
}
if detectedRequestType == "url" || detectedRequestType == "" {
detectedRequestType = "file"
}
log.Infof("Detected local file path, converted to absolute: %s", actualTarget)
// Auto-detect MIME type if not provided
if detectedMimeType == "" {
ext := filepath.Ext(actualTarget)
if ext != "" {
detectedMimeType = mime.TypeByExtension(ext)
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
}
}
// Auto-detect categories based on MIME type if not provided
if len(detectedCategories) == 0 && detectedMimeType != "" {
detectedCategories = mimeTypeToCategories(detectedMimeType)
log.Infof("Detected categories from MIME type: %v", detectedCategories)
}
}
params := map[string]interface{}{
"target": actualTarget,
}
if detectedMimeType != "" {
params["mimeType"] = detectedMimeType
}
if len(detectedCategories) > 0 {
params["categories"] = detectedCategories
}
if detectedRequestType != "" {
params["requestType"] = detectedRequestType
}
method := "apppicker.open"
if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://")) {
method = "browser.open"
params["url"] = target
}
req := models.Request{
ID: 1,
Method: method,
Params: params,
}
log.Infof("Sending request - Method: %s, Params: %+v", method, params)
if err := json.NewEncoder(conn).Encode(req); err != nil {
log.Fatalf("Failed to send request: %v", err)
}
log.Infof("Request sent successfully")
}

View File

@@ -0,0 +1,64 @@
package apppicker
import (
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
type Request struct {
ID int `json:"id"`
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
}
func HandleRequest(conn net.Conn, req Request, manager *Manager) {
switch req.Method {
case "apppicker.open", "browser.open":
handleOpen(conn, req, manager)
default:
models.RespondError(conn, req.ID, "unknown method")
}
}
func handleOpen(conn net.Conn, req Request, manager *Manager) {
log.Infof("AppPicker: Received %s request with params: %+v", req.Method, req.Params)
target, ok := req.Params["target"].(string)
if !ok {
target, ok = req.Params["url"].(string)
if !ok {
log.Warnf("AppPicker: Invalid target parameter in request")
models.RespondError(conn, req.ID, "invalid target parameter")
return
}
}
event := OpenEvent{
Target: target,
RequestType: "url",
}
if mimeType, ok := req.Params["mimeType"].(string); ok {
event.MimeType = mimeType
}
if categories, ok := req.Params["categories"].([]interface{}); ok {
event.Categories = make([]string, 0, len(categories))
for _, cat := range categories {
if catStr, ok := cat.(string); ok {
event.Categories = append(event.Categories, catStr)
}
}
}
if requestType, ok := req.Params["requestType"].(string); ok {
event.RequestType = requestType
}
log.Infof("AppPicker: Broadcasting event: %+v", event)
manager.RequestOpen(event)
models.Respond(conn, req.ID, "ok")
log.Infof("AppPicker: Request handled successfully")
}

View File

@@ -0,0 +1,48 @@
package apppicker
import (
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
type Manager struct {
subscribers syncmap.Map[string, chan OpenEvent]
closeOnce sync.Once
}
func NewManager() *Manager {
return &Manager{}
}
func (m *Manager) Subscribe(id string) chan OpenEvent {
ch := make(chan OpenEvent, 16)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) RequestOpen(event OpenEvent) {
m.subscribers.Range(func(key string, ch chan OpenEvent) bool {
select {
case ch <- event:
default:
}
return true
})
}
func (m *Manager) Close() {
m.closeOnce.Do(func() {
m.subscribers.Range(func(key string, ch chan OpenEvent) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
})
}

View File

@@ -0,0 +1,8 @@
package apppicker
type OpenEvent struct {
Target string `json:"target"`
MimeType string `json:"mimeType,omitempty"`
Categories []string `json:"categories,omitempty"`
RequestType string `json:"requestType"`
}

View File

@@ -0,0 +1,28 @@
package browser
import (
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
type Request struct {
ID int `json:"id"`
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
}
func HandleRequest(conn net.Conn, req Request, manager *Manager) {
switch req.Method {
case "browser.open":
url, ok := req.Params["url"].(string)
if !ok {
models.RespondError(conn, req.ID, "invalid url parameter")
return
}
manager.RequestOpen(url)
models.Respond(conn, req.ID, "ok")
default:
models.RespondError(conn, req.ID, "unknown method")
}
}

View File

@@ -0,0 +1,49 @@
package browser
import (
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
type Manager struct {
subscribers syncmap.Map[string, chan OpenEvent]
closeOnce sync.Once
}
func NewManager() *Manager {
return &Manager{}
}
func (m *Manager) Subscribe(id string) chan OpenEvent {
ch := make(chan OpenEvent, 16)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) RequestOpen(url string) {
event := OpenEvent{URL: url}
m.subscribers.Range(func(key string, ch chan OpenEvent) bool {
select {
case ch <- event:
default:
}
return true
})
}
func (m *Manager) Close() {
m.closeOnce.Do(func() {
m.subscribers.Range(func(key string, ch chan OpenEvent) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
})
}

View File

@@ -0,0 +1,5 @@
package browser
type OpenEvent struct {
URL string `json:"url"`
}

View File

@@ -5,6 +5,7 @@ import (
"net"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
@@ -96,6 +97,20 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") {
if appPickerManager == nil {
models.RespondError(conn, req.ID, "apppicker manager not initialized")
return
}
appPickerReq := apppicker.Request{
ID: req.ID,
Method: req.Method,
Params: req.Params,
}
apppicker.HandleRequest(conn, appPickerReq, appPickerManager)
return
}
if strings.HasPrefix(req.Method, "cups.") {
if cupsManager == nil {
models.RespondError(conn, req.ID, "CUPS manager not initialized")

View File

@@ -15,6 +15,7 @@ import (
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
@@ -55,6 +56,7 @@ var loginctlManager *loginctl.Manager
var freedesktopManager *freedesktop.Manager
var waylandManager *wayland.Manager
var bluezManager *bluez.Manager
var appPickerManager *apppicker.Manager
var cupsManager *cups.Manager
var dwlManager *dwl.Manager
var extWorkspaceManager *extworkspace.Manager
@@ -88,6 +90,21 @@ func GetSocketPath() string {
return filepath.Join(getSocketDir(), fmt.Sprintf("danklinux-%d.sock", os.Getpid()))
}
func FindSocket() (string, error) {
dir := getSocketDir()
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".sock") {
return filepath.Join(dir, entry.Name()), nil
}
}
return "", fmt.Errorf("no dms socket found")
}
func cleanupStaleSockets() {
dir := getSocketDir()
entries, err := os.ReadDir(dir)
@@ -201,6 +218,13 @@ func InitializeBluezManager() error {
return nil
}
func InitializeAppPickerManager() error {
manager := apppicker.NewManager()
appPickerManager = manager
log.Info("AppPicker manager initialized")
return nil
}
func InitializeCupsManager() error {
manager, err := cups.NewManager()
if err != nil {
@@ -357,6 +381,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "bluetooth")
}
if appPickerManager != nil {
caps = append(caps, "browser")
}
if cupsManager != nil {
caps = append(caps, "cups")
}
@@ -407,6 +435,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "bluetooth")
}
if appPickerManager != nil {
caps = append(caps, "browser")
}
if cupsManager != nil {
caps = append(caps, "cups")
}
@@ -724,6 +756,31 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}()
}
if shouldSubscribe("browser") && appPickerManager != nil {
wg.Add(1)
appPickerChan := appPickerManager.Subscribe(clientID + "-browser")
go func() {
defer wg.Done()
defer appPickerManager.Unsubscribe(clientID + "-browser")
for {
select {
case event, ok := <-appPickerChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "browser.open_requested", Data: event}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
if shouldSubscribe("cups") {
cupsSubscribers.Store(clientID+"-cups", true)
count := cupsSubscriberCount.Add(1)
@@ -1018,6 +1075,9 @@ func cleanupManagers() {
if bluezManager != nil {
bluezManager.Close()
}
if appPickerManager != nil {
appPickerManager.Close()
}
if cupsManager != nil {
cupsManager.Close()
}
@@ -1256,6 +1316,10 @@ func Start(printDocs bool) error {
}
}()
if err := InitializeAppPickerManager(); err != nil {
log.Debugf("AppPicker manager unavailable: %v", err)
}
if err := InitializeDwlManager(); err != nil {
log.Debugf("DWL manager unavailable: %v", err)
}

View File

@@ -180,6 +180,8 @@ Singleton {
property string appLauncherViewMode: "list"
property string spotlightModalViewMode: "list"
property string browserPickerViewMode: "grid"
property var browserUsageHistory: ({})
property bool sortAppsAlphabetically: false
property int appLauncherGridColumns: 4
property bool spotlightCloseNiriOverview: true

View File

@@ -431,6 +431,74 @@ Item {
}
}
BrowserPickerModal {
id: browserPickerModal
}
AppPickerModal {
id: filePickerModal
title: I18n.tr("Open with...")
function shellEscape(str) {
return "'" + str.replace(/'/g, "'\\''") + "'"
}
onApplicationSelected: (app, filePath) => {
if (!app) return
let cmd = app.exec || ""
const escapedPath = shellEscape(filePath)
const escapedUri = shellEscape("file://" + filePath)
let hasField = false
if (cmd.includes("%f")) { cmd = cmd.replace("%f", escapedPath); hasField = true }
else if (cmd.includes("%F")) { cmd = cmd.replace("%F", escapedPath); hasField = true }
else if (cmd.includes("%u")) { cmd = cmd.replace("%u", escapedUri); hasField = true }
else if (cmd.includes("%U")) { cmd = cmd.replace("%U", escapedUri); hasField = true }
cmd = cmd.replace(/%[ikc]/g, "")
if (!hasField) {
cmd += " " + escapedPath
}
console.log("FilePicker: Launching", cmd)
Quickshell.execDetached({
command: ["sh", "-c", cmd]
})
}
}
Connections {
target: DMSService
function onOpenUrlRequested(url) {
browserPickerModal.url = url
browserPickerModal.open()
}
function onAppPickerRequested(data) {
console.log("DMSShell: App picker requested with data:", JSON.stringify(data))
if (!data || !data.target) {
console.warn("DMSShell: Invalid app picker request data")
return
}
filePickerModal.targetData = data.target
filePickerModal.targetDataLabel = data.requestType || "file"
if (data.categories && data.categories.length > 0) {
filePickerModal.categoryFilter = data.categories
} else {
filePickerModal.categoryFilter = []
}
filePickerModal.usageHistoryKey = "filePickerUsageHistory"
filePickerModal.open()
}
}
DankColorPickerModal {
id: colorPickerModal

View File

@@ -0,0 +1,469 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Modals.Common
import qs.Widgets
import qs.Services
DankModal {
id: root
property string title: I18n.tr("Select Application")
property string targetData: ""
property string targetDataLabel: ""
property string searchQuery: ""
property int selectedIndex: 0
property int gridColumns: SettingsData.appLauncherGridColumns
property bool keyboardNavigationActive: false
property string viewMode: "grid"
property var categoryFilter: []
property var usageHistoryKey: ""
property bool showTargetData: true
signal applicationSelected(var app, string targetData)
shouldBeVisible: false
allowStacking: true
modalWidth: 520
modalHeight: 500
onDialogClosed: {
searchQuery = ""
selectedIndex = 0
keyboardNavigationActive: false
}
onOpened: {
searchQuery = ""
updateApplicationList()
selectedIndex = 0
Qt.callLater(() => {
if (contentLoader.item && contentLoader.item.searchField) {
contentLoader.item.searchField.text = ""
contentLoader.item.searchField.forceActiveFocus()
}
})
}
function updateApplicationList() {
applicationsModel.clear()
const apps = AppSearchService.applications
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {}
let filteredApps = []
for (const app of apps) {
if (!app || !app.categories) continue
let matchesCategory = categoryFilter.length === 0
if (categoryFilter.length > 0) {
try {
for (const cat of app.categories) {
if (categoryFilter.includes(cat)) {
matchesCategory = true
break
}
}
} catch (e) {
console.warn("AppPicker: Error iterating categories for", app.name, ":", e)
continue
}
}
if (matchesCategory) {
const name = app.name || ""
const lowerName = name.toLowerCase()
const lowerQuery = searchQuery.toLowerCase()
if (searchQuery === "" || lowerName.includes(lowerQuery)) {
filteredApps.push({
name: name,
icon: app.icon || "application-x-executable",
exec: app.exec || app.execString || "",
startupClass: app.startupWMClass || "",
appData: app
})
}
}
}
filteredApps.sort((a, b) => {
const aId = a.appData.id || a.appData.execString || a.appData.exec || ""
const bId = b.appData.id || b.appData.execString || b.appData.exec || ""
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0
const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0
if (aUsage !== bUsage) {
return bUsage - aUsage
}
return (a.name || "").localeCompare(b.name || "")
})
filteredApps.forEach(app => {
applicationsModel.append({
name: app.name,
icon: app.icon,
exec: app.exec,
startupClass: app.startupClass,
appId: app.appData.id || app.appData.execString || app.appData.exec || ""
})
})
console.log("AppPicker: Found " + filteredApps.length + " applications")
}
onSearchQueryChanged: updateApplicationList()
ListModel {
id: applicationsModel
}
content: Component {
FocusScope {
id: appContent
property alias searchField: searchField
anchors.fill: parent
focus: true
Keys.onEscapePressed: event => {
root.close()
event.accepted = true
}
Keys.onPressed: event => {
if (applicationsModel.count === 0) return
// Toggle view mode with Tab key
if (event.key === Qt.Key_Tab) {
root.viewMode = root.viewMode === "grid" ? "list" : "grid"
event.accepted = true
return
}
if (root.viewMode === "grid") {
if (event.key === Qt.Key_Left) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
event.accepted = true
} else if (event.key === Qt.Key_Right) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns)
event.accepted = true
}
} else {
if (event.key === Qt.Key_Up) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.max(0, root.selectedIndex - 1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.keyboardNavigationActive = true
root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1)
event.accepted = true
}
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.selectedIndex >= 0 && root.selectedIndex < applicationsModel.count) {
const app = applicationsModel.get(root.selectedIndex)
launchApplication(app)
}
event.accepted = true
}
}
Column {
width: parent.width - Theme.spacingS * 2
height: parent.height - Theme.spacingS * 2
x: Theme.spacingS
y: Theme.spacingS
spacing: Theme.spacingS
Item {
width: parent.width
height: 40
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: root.title
font.pixelSize: Theme.fontSizeLarge + 4
font.weight: Font.Bold
color: Theme.surfaceText
}
Row {
spacing: 4
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
DankActionButton {
buttonSize: 36
circular: false
iconName: "view_list"
iconSize: 20
iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText
backgroundColor: root.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
onClicked: {
root.viewMode = "list"
}
}
DankActionButton {
buttonSize: 36
circular: false
iconName: "grid_view"
iconSize: 20
iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText
backgroundColor: root.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
onClicked: {
root.viewMode = "grid"
}
}
}
}
DankTextField {
id: searchField
width: parent.width - Theme.spacingS * 2
anchors.horizontalCenter: parent.horizontalCenter
height: 52
cornerRadius: Theme.cornerRadius
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
leftIconName: "search"
leftIconSize: Theme.iconSize
leftIconColor: Theme.surfaceVariantText
leftIconFocusedColor: Theme.primary
showClearButton: true
font.pixelSize: Theme.fontSizeLarge
enabled: root.shouldBeVisible
ignoreLeftRightKeys: root.viewMode !== "list"
ignoreTabKeys: true
keyForwardTargets: [appContent]
onTextEdited: {
root.searchQuery = text
}
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Escape) {
root.close()
event.accepted = true
return
}
const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key)
const hasText = text.length > 0
if (isEnterKey && hasText) {
if (root.keyboardNavigationActive && applicationsModel.count > 0) {
const app = applicationsModel.get(root.selectedIndex)
launchApplication(app)
} else if (applicationsModel.count > 0) {
const app = applicationsModel.get(0)
launchApplication(app)
}
event.accepted = true
return
}
const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab]
const isNavigationKey = navigationKeys.includes(event.key)
const isEmptyEnter = isEnterKey && !hasText
event.accepted = !(isNavigationKey || isEmptyEnter)
}
Connections {
function onShouldBeVisibleChanged() {
if (!root.shouldBeVisible) {
searchField.focus = false
}
}
target: root
}
}
Rectangle {
width: parent.width
height: {
let usedHeight = 40 + Theme.spacingS
usedHeight += 52 + Theme.spacingS
if (root.showTargetData) {
usedHeight += 36 + Theme.spacingS
}
return parent.height - usedHeight
}
radius: Theme.cornerRadius
color: "transparent"
DankListView {
id: appList
property int itemHeight: 60
property int itemSpacing: Theme.spacingS
function ensureVisible(index) {
if (index < 0 || index >= count) return
const itemY = index * (itemHeight + itemSpacing)
const itemBottom = itemY + itemHeight
if (itemY < contentY) {
contentY = itemY
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height
}
}
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
visible: root.viewMode === "list"
model: applicationsModel
currentIndex: root.selectedIndex
clip: true
spacing: itemSpacing
onCurrentIndexChanged: {
root.selectedIndex = currentIndex
if (root.keyboardNavigationActive) {
ensureVisible(currentIndex)
}
}
delegate: AppLauncherListDelegate {
listView: appList
itemHeight: 60
iconSize: 40
showDescription: false
isCurrentItem: index === root.selectedIndex
keyboardNavigationActive: root.keyboardNavigationActive
hoverUpdatesSelection: true
onItemClicked: (idx, modelData) => {
launchApplication(modelData)
}
onKeyboardNavigationReset: {
root.keyboardNavigationActive = false
}
}
}
DankGridView {
id: appGrid
function ensureVisible(index) {
if (index < 0 || index >= count) return
const itemY = Math.floor(index / root.gridColumns) * cellHeight
const itemBottom = itemY + cellHeight
if (itemY < contentY) {
contentY = itemY
} else if (itemBottom > contentY + height) {
contentY = itemBottom - height
}
}
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
anchors.bottomMargin: Theme.spacingS
visible: root.viewMode === "grid"
model: applicationsModel
cellWidth: width / root.gridColumns
cellHeight: 120
clip: true
currentIndex: root.selectedIndex
onCurrentIndexChanged: {
root.selectedIndex = currentIndex
if (root.keyboardNavigationActive) {
ensureVisible(currentIndex)
}
}
delegate: AppLauncherGridDelegate {
gridView: appGrid
cellWidth: appGrid.cellWidth
cellHeight: appGrid.cellHeight
currentIndex: root.selectedIndex
keyboardNavigationActive: root.keyboardNavigationActive
hoverUpdatesSelection: true
onItemClicked: (idx, modelData) => {
launchApplication(modelData)
}
onKeyboardNavigationReset: {
root.keyboardNavigationActive = false
}
}
}
}
Rectangle {
width: parent.width
height: 36
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
border.color: Theme.outlineMedium
border.width: 1
visible: root.showTargetData && root.targetData.length > 0
StyledText {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
text: root.targetDataLabel.length > 0 ? root.targetDataLabel + ": " + root.targetData : root.targetData
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
elide: Text.ElideMiddle
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
}
function launchApplication(app) {
if (!app) return
root.applicationSelected(app, root.targetData)
if (usageHistoryKey && app.appId) {
const usageHistory = SettingsData[usageHistoryKey] || {}
const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0
usageHistory[app.appId] = {
count: currentCount + 1,
lastUsed: Date.now(),
name: app.name
}
SettingsData.set(usageHistoryKey, usageHistory)
}
root.close()
}
}
}
}

View File

@@ -0,0 +1,51 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Modals
AppPickerModal {
id: root
property string url: ""
title: I18n.tr("Open with...")
targetData: url
targetDataLabel: ""
categoryFilter: ["WebBrowser", "X-WebBrowser"]
viewMode: SettingsData.browserPickerViewMode || "grid"
usageHistoryKey: "browserUsageHistory"
showTargetData: true
function shellEscape(str) {
return "'" + str.replace(/'/g, "'\\''") + "'"
}
onApplicationSelected: (app, url) => {
if (!app) return
let cmd = app.exec || ""
const escapedUrl = shellEscape(url)
let hasField = false
if (cmd.includes("%u")) { cmd = cmd.replace("%u", escapedUrl); hasField = true }
else if (cmd.includes("%U")) { cmd = cmd.replace("%U", escapedUrl); hasField = true }
else if (cmd.includes("%f")) { cmd = cmd.replace("%f", escapedUrl); hasField = true }
else if (cmd.includes("%F")) { cmd = cmd.replace("%F", escapedUrl); hasField = true }
cmd = cmd.replace(/%[ikc]/g, "")
if (!hasField) {
cmd += " " + escapedUrl
}
console.log("BrowserPicker: Launching", cmd)
Quickshell.execDetached({
command: ["sh", "-c", cmd]
})
}
onViewModeChanged: {
SettingsData.set("browserPickerViewMode", viewMode)
}
}

View File

@@ -49,10 +49,12 @@ Singleton {
signal extWorkspaceStateUpdate(var data)
signal wlrOutputStateUpdate(var data)
signal evdevStateUpdate(var data)
signal openUrlRequested(string url)
signal appPickerRequested(var data)
property bool capsLockState: false
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev"]
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser"]
Component.onCompleted: {
if (socketPath && socketPath.length > 0) {
@@ -265,9 +267,9 @@ Singleton {
function removeSubscription(service) {
if (activeSubscriptions.includes("all")) {
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace"];
const filtered = allServices.filter(s => s !== service);
subscribe(filtered);
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser"]
const filtered = allServices.filter(s => s !== service)
subscribe(filtered)
} else {
const filtered = activeSubscriptions.filter(s => s !== service);
if (filtered.length === 0) {
@@ -287,9 +289,9 @@ Singleton {
excludeServices = [excludeServices];
}
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace"];
const filtered = allServices.filter(s => !excludeServices.includes(s));
subscribe(filtered);
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser"]
const filtered = allServices.filter(s => !excludeServices.includes(s))
subscribe(filtered)
}
function handleSubscriptionEvent(response) {
@@ -353,7 +355,17 @@ Singleton {
if (data.capsLock !== undefined) {
capsLockState = data.capsLock;
}
evdevStateUpdate(data);
evdevStateUpdate(data)
} else if (service === "browser.open_requested") {
if (data.target) {
if (data.requestType === "url" || !data.requestType) {
openUrlRequested(data.target)
} else {
appPickerRequested(data)
}
} else if (data.url) {
openUrlRequested(data.url)
}
}
}