1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-05 21:15:38 -05:00
Files
DankMaterialShell/quickshell/Modals/AppPickerModal.qml
Jon Rogers 1b6d567451 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
2025-11-30 22:41:37 -05:00

470 lines
18 KiB
QML

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()
}
}
}
}