mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 21:42:51 -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:
10
assets/dms-open.desktop
Normal file
10
assets/dms-open.desktop
Normal 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;
|
||||||
224
core/cmd/dms/commands_open.go
Normal file
224
core/cmd/dms/commands_open.go
Normal 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")
|
||||||
|
}
|
||||||
64
core/internal/server/apppicker/handlers.go
Normal file
64
core/internal/server/apppicker/handlers.go
Normal 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")
|
||||||
|
}
|
||||||
48
core/internal/server/apppicker/manager.go
Normal file
48
core/internal/server/apppicker/manager.go
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
8
core/internal/server/apppicker/models.go
Normal file
8
core/internal/server/apppicker/models.go
Normal 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"`
|
||||||
|
}
|
||||||
28
core/internal/server/browser/handlers.go
Normal file
28
core/internal/server/browser/handlers.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
49
core/internal/server/browser/manager.go
Normal file
49
core/internal/server/browser/manager.go
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
5
core/internal/server/browser/models.go
Normal file
5
core/internal/server/browser/models.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
type OpenEvent struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||||
@@ -96,6 +97,20 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
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 strings.HasPrefix(req.Method, "cups.") {
|
||||||
if cupsManager == nil {
|
if cupsManager == nil {
|
||||||
models.RespondError(conn, req.ID, "CUPS manager not initialized")
|
models.RespondError(conn, req.ID, "CUPS manager not initialized")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"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/bluez"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||||
@@ -55,6 +56,7 @@ var loginctlManager *loginctl.Manager
|
|||||||
var freedesktopManager *freedesktop.Manager
|
var freedesktopManager *freedesktop.Manager
|
||||||
var waylandManager *wayland.Manager
|
var waylandManager *wayland.Manager
|
||||||
var bluezManager *bluez.Manager
|
var bluezManager *bluez.Manager
|
||||||
|
var appPickerManager *apppicker.Manager
|
||||||
var cupsManager *cups.Manager
|
var cupsManager *cups.Manager
|
||||||
var dwlManager *dwl.Manager
|
var dwlManager *dwl.Manager
|
||||||
var extWorkspaceManager *extworkspace.Manager
|
var extWorkspaceManager *extworkspace.Manager
|
||||||
@@ -88,6 +90,21 @@ func GetSocketPath() string {
|
|||||||
return filepath.Join(getSocketDir(), fmt.Sprintf("danklinux-%d.sock", os.Getpid()))
|
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() {
|
func cleanupStaleSockets() {
|
||||||
dir := getSocketDir()
|
dir := getSocketDir()
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
@@ -201,6 +218,13 @@ func InitializeBluezManager() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InitializeAppPickerManager() error {
|
||||||
|
manager := apppicker.NewManager()
|
||||||
|
appPickerManager = manager
|
||||||
|
log.Info("AppPicker manager initialized")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func InitializeCupsManager() error {
|
func InitializeCupsManager() error {
|
||||||
manager, err := cups.NewManager()
|
manager, err := cups.NewManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -357,6 +381,10 @@ func getCapabilities() Capabilities {
|
|||||||
caps = append(caps, "bluetooth")
|
caps = append(caps, "bluetooth")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if appPickerManager != nil {
|
||||||
|
caps = append(caps, "browser")
|
||||||
|
}
|
||||||
|
|
||||||
if cupsManager != nil {
|
if cupsManager != nil {
|
||||||
caps = append(caps, "cups")
|
caps = append(caps, "cups")
|
||||||
}
|
}
|
||||||
@@ -407,6 +435,10 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "bluetooth")
|
caps = append(caps, "bluetooth")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if appPickerManager != nil {
|
||||||
|
caps = append(caps, "browser")
|
||||||
|
}
|
||||||
|
|
||||||
if cupsManager != nil {
|
if cupsManager != nil {
|
||||||
caps = append(caps, "cups")
|
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") {
|
if shouldSubscribe("cups") {
|
||||||
cupsSubscribers.Store(clientID+"-cups", true)
|
cupsSubscribers.Store(clientID+"-cups", true)
|
||||||
count := cupsSubscriberCount.Add(1)
|
count := cupsSubscriberCount.Add(1)
|
||||||
@@ -1018,6 +1075,9 @@ func cleanupManagers() {
|
|||||||
if bluezManager != nil {
|
if bluezManager != nil {
|
||||||
bluezManager.Close()
|
bluezManager.Close()
|
||||||
}
|
}
|
||||||
|
if appPickerManager != nil {
|
||||||
|
appPickerManager.Close()
|
||||||
|
}
|
||||||
if cupsManager != nil {
|
if cupsManager != nil {
|
||||||
cupsManager.Close()
|
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 {
|
if err := InitializeDwlManager(); err != nil {
|
||||||
log.Debugf("DWL manager unavailable: %v", err)
|
log.Debugf("DWL manager unavailable: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ Singleton {
|
|||||||
|
|
||||||
property string appLauncherViewMode: "list"
|
property string appLauncherViewMode: "list"
|
||||||
property string spotlightModalViewMode: "list"
|
property string spotlightModalViewMode: "list"
|
||||||
|
property string browserPickerViewMode: "grid"
|
||||||
|
property var browserUsageHistory: ({})
|
||||||
property bool sortAppsAlphabetically: false
|
property bool sortAppsAlphabetically: false
|
||||||
property int appLauncherGridColumns: 4
|
property int appLauncherGridColumns: 4
|
||||||
property bool spotlightCloseNiriOverview: true
|
property bool spotlightCloseNiriOverview: true
|
||||||
|
|||||||
@@ -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 {
|
DankColorPickerModal {
|
||||||
id: colorPickerModal
|
id: colorPickerModal
|
||||||
|
|
||||||
|
|||||||
469
quickshell/Modals/AppPickerModal.qml
Normal file
469
quickshell/Modals/AppPickerModal.qml
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
quickshell/Modals/BrowserPickerModal.qml
Normal file
51
quickshell/Modals/BrowserPickerModal.qml
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,10 +49,12 @@ Singleton {
|
|||||||
signal extWorkspaceStateUpdate(var data)
|
signal extWorkspaceStateUpdate(var data)
|
||||||
signal wlrOutputStateUpdate(var data)
|
signal wlrOutputStateUpdate(var data)
|
||||||
signal evdevStateUpdate(var data)
|
signal evdevStateUpdate(var data)
|
||||||
|
signal openUrlRequested(string url)
|
||||||
|
signal appPickerRequested(var data)
|
||||||
|
|
||||||
property bool capsLockState: false
|
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: {
|
Component.onCompleted: {
|
||||||
if (socketPath && socketPath.length > 0) {
|
if (socketPath && socketPath.length > 0) {
|
||||||
@@ -265,9 +267,9 @@ Singleton {
|
|||||||
|
|
||||||
function removeSubscription(service) {
|
function removeSubscription(service) {
|
||||||
if (activeSubscriptions.includes("all")) {
|
if (activeSubscriptions.includes("all")) {
|
||||||
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace"];
|
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser"]
|
||||||
const filtered = allServices.filter(s => s !== service);
|
const filtered = allServices.filter(s => s !== service)
|
||||||
subscribe(filtered);
|
subscribe(filtered)
|
||||||
} else {
|
} else {
|
||||||
const filtered = activeSubscriptions.filter(s => s !== service);
|
const filtered = activeSubscriptions.filter(s => s !== service);
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
@@ -287,9 +289,9 @@ Singleton {
|
|||||||
excludeServices = [excludeServices];
|
excludeServices = [excludeServices];
|
||||||
}
|
}
|
||||||
|
|
||||||
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace"];
|
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser"]
|
||||||
const filtered = allServices.filter(s => !excludeServices.includes(s));
|
const filtered = allServices.filter(s => !excludeServices.includes(s))
|
||||||
subscribe(filtered);
|
subscribe(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubscriptionEvent(response) {
|
function handleSubscriptionEvent(response) {
|
||||||
@@ -353,7 +355,17 @@ Singleton {
|
|||||||
if (data.capsLock !== undefined) {
|
if (data.capsLock !== undefined) {
|
||||||
capsLockState = data.capsLock;
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user