diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..edf272e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Geometry tests + run: node --test tests/*.test.mjs + - name: Validate manifest + run: node -e 'const m=require("./manifest.json"); for (const k of ["schemaVersion","id","name","version","kinds","entryPoints"]) if (!(k in m)) throw new Error("manifest missing " + k); for (const f of Object.values(m.entryPoints)) require("fs").accessSync(f)' + - name: Lua syntax + run: | + sudo apt-get install -y --no-install-recommends lua5.4 >/dev/null + luac5.4 -p src/lua/mousefirst.lua + - name: QML syntax + run: | + sudo apt-get install -y --no-install-recommends qt6-declarative-dev-tools >/dev/null + out=$(for f in $(git ls-files '*.qml'); do /usr/lib/qt6/bin/qmllint "$f" 2>&1; done | grep '\[syntax\]' || true) + if [ -n "$out" ]; then echo "$out"; exit 1; fi + - name: Shell scripts + run: shellcheck scripts/*.sh diff --git a/BarWidget.qml b/BarWidget.qml index bf09191..b3de7f8 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -1,2606 +1,63 @@ import QtQuick -import QtQuick.Layouts -import Quickshell -import Quickshell.Wayland -import Quickshell.Io -import Quickshell.Hyprland +import "src/ui" +// Bar widget: the row of app icons. Omarchy creates one per monitor; all +// state lives in the Mouse-First service (Service.qml), shared by every copy. Item { id: root + property QtObject bar: null property string moduleName: "" property var settings: ({}) - readonly property bool vertical: bar ? bar.vertical : false readonly property int barSize: bar ? bar.barSize : 26 + readonly property bool vertical: bar ? bar.vertical : false - implicitWidth: (root.shouldShowAppsInBar && root.barAppsList.length > 0) - ? minAppsRow.implicitWidth - : 0 - implicitHeight: root.barSize - width: implicitWidth - height: implicitHeight - - readonly property string homeDir: Quickshell.env("HOME") - readonly property string binDir: - homeDir + "/.config/omarchy/plugins/io.github.mousefirst.controls/bin" - readonly property string cfgFile: - homeDir + "/.config/omarchy/mouse-first.json" - - property string language: "en" - property bool disableTiling: true - property bool alwaysVisible: true - property bool dragFullWidth: true - property bool showDragHandle: false - property bool ignoreDock: false - property bool disableSnapping: false - property bool minimizeToBar: true - - readonly property string detectedSection: { - try { - if (root.parent && root.parent.parent && root.parent.parent.region) { - var r = String(root.parent.parent.region).trim() - if (r === "left" || r === "center" || r === "right") return r - } - } catch(e) {} - return "" - } - - property string barSection: (detectedSection !== "") ? detectedSection : "left" - - onDetectedSectionChanged: { - if (detectedSection !== "") { - barSection = detectedSection - } - } - - property bool isDockPresent: true - readonly property bool shouldShowAppsInBar: root.minimizeToBar || !root.isDockPresent - property var barAppsList: [] - - property bool enableMenuDrag: true - property bool rememberMenuPerWindow: false - property bool rememberMenuGlobal: false - property bool alwaysShowMenuDragZone: false - property real curMenuOffsetX: 0 - property real curMenuOffsetY: 0 - property real globalMenuOffsetX: 0 - property real globalMenuOffsetY: 0 - property var perWindowClassOffsets: ({}) - property var sessionMenuOffsets: ({}) - property string winClass: "" - property var clampedWindows: ({}) - - property bool isMenuDragging: false - property real menuDragStartMouseX: 0 - property real menuDragStartMouseY: 0 - property real menuDragStartOffsetX: 0 - property real menuDragStartOffsetY: 0 - property var buttonOrder: ["float", "minimize", "maximize", "close"] - property var buttonVisible: ({ float: false, minimize: true, maximize: true, close: true }) - property string colorMode: "theme" - property string customBg: "#0e0e14" - property string customFg: "#a9b1d6" - property string customAccent: "#7aa2f7" - property string customRed: "#f7768e" - property var minimizedWindows: [] - property bool showMinimizedPanel: false - - // Cores do tema - property string themeBg: "#1a1b26" - property string themeFg: "#a9b1d6" - property string themeAccent: "#7aa2f7" - property string themeRed: "#f7768e" - property string themeMuted: "#414868" - property string themeMode: "dark" - - readonly property string effBg: - colorMode === "theme" ? Qt.darker(themeBg, 1.4) : customBg - readonly property string effFg: - colorMode === "theme" ? themeFg : customFg - readonly property string effAccent: - colorMode === "theme" ? themeAccent : customAccent - readonly property string effRed: - colorMode === "theme" ? themeRed : customRed - - // Internationalization helper - function t(key) { - var isPt = root.language === "pt" - var d = { - minimize: isPt ? "Minimizar" : "Minimize", - maximize: isPt ? "Maximizar" : "Maximize", - restore: isPt ? "Restaurar" : "Restore", - close: isPt ? "Fechar" : "Close", - float: isPt ? "Flutuar" : "Float", - tile: isPt ? "Tilar" : "Tile", - floatTile: isPt ? "Float / Tile" : "Float / Tile", - maxRestore: isPt ? "Maximizar / Restaurar" : "Maximize / Restore", - untitled: isPt ? "Sem título" : "Untitled", - settingsTitle: "Mouse First", - settingsHint: isPt ? "Clique com botão direito na barra para abrir/fechar" : "Right-click the bar to toggle settings", - systemBehavior: isPt ? "COMPORTAMENTO DO SISTEMA" : "SYSTEM BEHAVIOR", - disableTiling: isPt ? "Desativar tiling (todas as janelas flutuantes)" : "Disable window tiling", - disableTilingSub: isPt ? "Força todas as janelas a abrirem flutuando como no Windows" : "Open all windows as floating by default", - minimizedSectionTitle: isPt ? "JANELAS E APLICATIVOS NA BARRA" : "SYSTEM BAR APPS & WINDOWS", - minimizeToBar: isPt ? "Minimizar janelas para a barra do sistema" : "Minimize windows to system bar", - minimizeToBarSub: isPt ? "Exibe ícones de aplicativos na barra do sistema. Se desativado, usa a dock se disponível." : "Show application icons on the system bar. When disabled, uses the dock if available.", - minimizedAppsLocation: isPt ? "Posição dos aplicativos na barra" : "System bar apps position", - minimizedAppsLocationSub: isPt ? "Escolha onde os ícones dos apps aparecem na barra do sistema" : "Choose where application icons appear on the system bar", - locLeft: isPt ? "Esquerda" : "Left", - locCenter: isPt ? "Centro" : "Center", - locRight: isPt ? "Direita" : "Right", - visibility: isPt ? "VISIBILIDADE" : "VISIBILITY", - alwaysVisible: isPt ? "Barra sempre visível" : "Always show control bar", - alwaysVisibleSub: isPt ? "Desativado: revela apenas ao passar o mouse no canto superior" : "When disabled, reveals on top-right hover", - dragging: isPt ? "ARRASTO E SNAP DE JANELAS" : "WINDOW DRAGGING & SNAPPING", - dragZone: isPt ? "Mostrar zona de arrasto no topo da janela" : "Show top window drag zone", - dragZoneSub: isPt ? "Mostra uma faixa de arrasto ao passar o mouse na borda superior" : "Shows a draggable area when hovering over window top edge", - dragHandle: isPt ? "Mostrar alça de arrasto na barra (⠿)" : "Show drag grip on control bar (⠿)", - dragHandleSub: isPt ? "Ícone de arrasto à esquerda dos botões de controle" : "Draggable grip icon on the left side of the bar", - ignoreDock: isPt ? "Ignorar dock no snap" : "Ignore dock when snapping", - ignoreDockSub: isPt ? "Permite que o snap inferior se estenda sobre a área da dock" : "Allow snapped windows to extend over the dock area", - disableSnapping: isPt ? "Desativar snap de janelas" : "Disable window snapping", - disableSnappingSub: isPt ? "Desativa o snap nas bordas da tela e as sombras de preview" : "Turns off edge snapping and snap preview outlines", - menuDragging: isPt ? "POSIÇÃO DA BARRA DE CONTROLES" : "CONTROL BAR POSITIONING", - enableMenuDrag: isPt ? "Permitir reposicionar a barra de controles" : "Allow repositioning control bar", - enableMenuDragSub: isPt ? "Arraste a borda inferior para mover. Clique duplo para resetar." : "Drag the thin bottom handle to freely move the bar. Double-click to reset.", - rememberMenuPerWindow: isPt ? "Lembrar posição por aplicativo" : "Remember position per application", - rememberMenuPerWindowSub: isPt ? "Mantém a posição personalizada para cada aplicativo mesmo após fechar" : "Saves custom position for each application across sessions", - rememberMenuGlobal: isPt ? "Aplicar mesma posição a todas as janelas" : "Apply position to all windows", - rememberMenuGlobalSub: isPt ? "Aplica a mesma posição personalizada globalmente a todas as janelas" : "Applies the same custom position globally to all windows", - alwaysShowMenuDragZone: isPt ? "Sempre mostrar alça de arrasto da barra" : "Always show control bar drag handle", - alwaysShowMenuDragZoneSub: isPt ? "Mantém a alça de arrasto inferior visível mesmo sem passar o mouse" : "Keep the bottom drag handle visible even when not hovering", - buttonsTitle: isPt ? "BOTÕES — ORDEM E VISIBILIDADE" : "BUTTONS — ORDER & VISIBILITY", - buttonsHint: isPt ? "↑ ↓ para reordenar. Toggle para mostrar/ocultar." : "↑ ↓ to reorder. Toggle to show or hide.", - colorTitle: isPt ? "COR DA BARRA" : "BAR COLOR", - themeFollow: isPt ? "Seguir tema do Omarchy" : "Follow Omarchy theme", - themeFollowSub: isPt ? "Atualiza as cores automaticamente ao trocar de tema" : "Automatically updates colors when theme changes", - closeSettings: isPt ? "Fechar configurações" : "Close settings" - } - return d[key] || key - } - - // Estado da Janela Ativa - property int winX: 0 - property int winY: 0 - property int winW: 800 - property int winH: 600 - property string winAddr: "" - property string winTitle: "" - property bool winFloat: true - property bool winMax: false - property bool hasWindow: winAddr !== "" - property string lastFloatedAddr: "" - property var savedWinSizes: ({}) - property var preSnapSizes: ({}) - property int savedFloatingW: 1000 - property int savedFloatingH: 650 - - // Estado do Arraste Suave e Estável - property bool isDragging: false - property bool dragInitiated: false - property bool wasMaxOnPress: false - property bool wasSnappedOnPress: false - property real pressCursorX: 0 - property real pressCursorY: 0 - property real dragStartWinX: 0 - property real dragStartWinY: 0 - property int dragStartWinW: 800 - property int dragStartWinH: 600 - property bool dragNeedsDispatch: false - - // Zonas de Snapping (Aero Snap / Light Tiling) - property string snapZone: "" - property int snapTargetX: 0 - property int snapTargetY: 0 - property int snapTargetW: 0 - property int snapTargetH: 0 - - // Reservas de tela do monitor (top bar e dock) - property int reservedTop: 26 - property int reservedBottom: 69 - - - function cleanAddr(addr) { - if (!addr) return "" - var s = String(addr).trim() - return /^0x[0-9a-fA-F]+$/.test(s) ? s : "" - } - - function dispatchLua(lua) { - if (!lua) return - Hyprland.dispatch(lua) - } - - function applyWindowGeometry(addr, x, y, w, h) { - var a = cleanAddr(addr) - if (!a) return - Hyprland.dispatch('hl.dsp.window.resize({ x = ' + Math.round(w) + ', y = ' + Math.round(h) + ', window = "address:' + a + '" })') - Hyprland.dispatch('hl.dsp.window.move({ x = ' + Math.round(x) + ', y = ' + Math.round(y) + ', relative = false, window = "address:' + a + '" })') - } - - function unmaximizeAndRestore(addr, x, y, w, h) { - var a = cleanAddr(addr) - if (!a) return - Hyprland.dispatch('hl.dsp.window.fullscreen({ mode = "maximized", window = "address:' + a + '" })') - Hyprland.dispatch('hl.dsp.window.float({ action = "on", window = "address:' + a + '" })') - Hyprland.dispatch('hl.dsp.window.resize({ x = ' + Math.round(w) + ', y = ' + Math.round(h) + ', window = "address:' + a + '" })') - Hyprland.dispatch('hl.dsp.window.move({ x = ' + Math.round(x) + ', y = ' + Math.round(y) + ', relative = false, window = "address:' + a + '" })') - Hyprland.dispatch('hl.dsp.window.bring_to_top({ window = "address:' + a + '" })') - } - - function evalLua(lua) { - if (!lua) return - var re = /hl\.dispatch\((hl\.dsp\.[^;)]+\))\)/g - var m - var found = false - while ((m = re.exec(lua)) !== null) { - found = true - Hyprland.dispatch(m[1]) - } - if (!found) { - Hyprland.dispatch(lua) - } - } - - function clampWinX(x, w) { - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var minX = 6 - var winW = (w > 0 ? w : root.winW) - var maxX = Math.max(minX, sW - (winW > 0 ? winW : 800) - 6) - return Math.round(Math.max(minX, Math.min(maxX, x))) - } - - function clampWinY(y, h) { - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - var minY = root.reservedTop + 6 - var winH = (h > 0 ? h : root.winH) - var effectiveDock = root.ignoreDock ? 0 : root.reservedBottom - var maxY = Math.max(minY, sH - effectiveDock - 6 - (winH > 0 ? winH : 600)) - return Math.round(Math.max(minY, Math.min(maxY, y))) - } - - Process { - id: monitorInfoProc - command: ["hyprctl", "monitors", "-j"] - stdout: StdioCollector { - onStreamFinished: { - try { - var mons = JSON.parse(text) - if (mons && mons.length > 0) { - var m = mons.find(function(x) { return x.focused; }) || mons[0] - if (m && m.reserved) { - root.reservedTop = (m.reserved[1] !== undefined && m.reserved[1] > 0) ? m.reserved[1] : 26 - root.reservedBottom = (m.reserved[3] !== undefined && m.reserved[3] > 0) ? m.reserved[3] : 0 - } - } - } catch(e) {} - } - } - } - - // ── Detecção Universal de Dock ──────────────────────────── - Process { - id: dockCheckProc - command: [root.binDir + "/mf-dock-check"] - stdout: StdioCollector { - onStreamFinished: { - root.isDockPresent = (text.trim() === "1") - if (!root.isDockPresent) { - root.reservedBottom = 0 - } - } - } - } - - // ── Sincronização da Seção na Barra do Sistema ───────────── - Process { - id: checkBarSectionProc - command: ["bash", "-c", "cat ~/.config/omarchy/shell.json 2>/dev/null"] - stdout: StdioCollector { - onStreamFinished: { - if (root.detectedSection !== "") return - try { - var s = JSON.parse(text) - if (s && s.bar && s.bar.layout) { - var l = s.bar.layout - var foundSec = "" - if (l.left && l.left.some(function(x) { return x.id === "io.github.mousefirst.controls"; })) { - foundSec = "left" - } else if (l.center && l.center.some(function(x) { return x.id === "io.github.mousefirst.controls"; })) { - foundSec = "center" - } else if (l.right && l.right.some(function(x) { return x.id === "io.github.mousefirst.controls"; })) { - foundSec = "right" - } - if (foundSec !== "") { - root.barSection = foundSec - } - } - } catch(e) {} - } - } - } - - function setBarSection(sec) { - if (!sec || (sec !== "left" && sec !== "center" && sec !== "right")) return - root.barSection = sec - root.saveConfig() - Quickshell.execDetached(["omarchy-shell", "shell", "moveBarWidget", "io.github.mousefirst.controls", JSON.stringify({ section: sec })]) - } - - Timer { - id: monitorPollTimer - interval: 10000 - repeat: true - running: true - onTriggered: { - if (root.isDragging || root.dragInitiated) return - if (!monitorInfoProc.running) monitorInfoProc.running = true - if (!dockCheckProc.running) dockCheckProc.running = true - if (!checkBarSectionProc.running) checkBarSectionProc.running = true - } - } - - Component.onCompleted: { - if (root.detectedSection !== "") root.barSection = root.detectedSection - cfgReadProc.running = true - appsProc.running = true - themeProc.running = true - activeWinTimer.running = true - themeTimer.running = true - monitorInfoProc.running = true - dockCheckProc.running = true - checkBarSectionProc.running = true - } - - // ── Eventos em Tempo Real do Hyprland ───────────────────── - Connections { - target: Hyprland - function onRawEvent(event) { - if (!event || !event.name) return - if (root.isDragging || root.dragInitiated) return - var n = event.name - if (n === "activewindowv2" || n === "activewindow" || - n === "openwindow" || n === "closewindow" || - n === "movewindow" || n === "changefloatingmode") { - if (!activeWinProc.running) activeWinProc.running = true - } - if (n === "openwindow" || n === "closewindow") { - if (!appsProc.running) appsProc.running = true - } - if (n === "closewindow") { - var d = event.data ? event.data.trim() : "" - var a1 = d.indexOf("0x") === 0 ? d : ("0x" + d) - if (root.sessionMenuOffsets[a1] || root.sessionMenuOffsets[d]) { - var sMap = Object.assign({}, root.sessionMenuOffsets) - delete sMap[a1] - delete sMap[d] - root.sessionMenuOffsets = sMap - } - if (root.preSnapSizes[a1] || root.preSnapSizes[d]) { - var snapMapClose = Object.assign({}, root.preSnapSizes) - delete snapMapClose[a1] - delete snapMapClose[d] - root.preSnapSizes = snapMapClose - } - } - } - } - - // ── Timer de despacho suave ao Hyprland (60fps) ─────────── - Timer { - id: dragMoveTimer - interval: 16 - repeat: true - onTriggered: { - if (!root.isDragging) { - running = false - return - } - var addr = root.cleanAddr(root.winAddr) - if (root.dragNeedsDispatch && addr !== "") { - root.dragNeedsDispatch = false - var cx = dragProxy.x - var cy = dragProxy.y - root.dispatchLua('hl.dsp.window.move({ x = ' + cx + ', y = ' + cy + ', relative = false, window = "address:' + addr + '" })') - } - } - } - - // ── Polling de Janela Ativa para acompanhar Resize e Super-Drag ── - Timer { - id: activeWinTimer - interval: 120 - repeat: true - onTriggered: { - if (!root.isDragging && !root.dragInitiated && !settleTimer.running && !activeWinProc.running) { - activeWinProc.running = true - } - } - } - - // ── Timer de estabilização pós-ação (evita ler coordenadas transitórias) ── - Timer { - id: settleTimer - interval: 300 - repeat: false - onTriggered: { - if (!root.isDragging && !activeWinProc.running) { - activeWinProc.running = true - } - } - } - - Process { - id: recoverFocusProc - command: ["bash", "-c", "AW=$(hyprctl activeworkspace -j 2>/dev/null | python3 -c \"import sys,json\ntry:\n d=json.load(sys.stdin)\n print(d.get('lastwindow',''))\nexcept:\n pass\" 2>/dev/null); if [ -n \"$AW\" ] && [ \"$AW\" != \"0x0\" ] && [ \"$AW\" != \"null\" ]; then hyprctl dispatch \"hl.dsp.focus({ window = 'address:$AW' })\" >/dev/null 2>&1 || true; fi"] - onExited: { - if (!activeWinProc.running) activeWinProc.running = true - } - } - - Process { - id: activeWinProc - command: ["hyprctl", "--batch", "j/activewindow ; cursorpos"] - stdout: StdioCollector { - onStreamFinished: { - try { - var rawText = text - var jsonEnd = rawText.lastIndexOf("}") - if (jsonEnd === -1) return - var jsonStr = rawText.substring(0, jsonEnd + 1) - var w = JSON.parse(jsonStr) - - var cursorMatch = rawText.match(/(\d+),\s*(\d+)\s*$/) - var curMouseX = cursorMatch ? parseInt(cursorMatch[1]) : -1 - var curMouseY = cursorMatch ? parseInt(cursorMatch[2]) : -1 - - var isSpecial = !!(w && w.workspace && w.workspace.name && w.workspace.name.indexOf("special:") === 0) - if (!w || !w.address || w.mapped === false || w.hidden === true || w.visible === false || isSpecial) { - if (!root.isDragging && !root.dragInitiated) { - root.hasWindow = false - root.winAddr = "" - } - if (isSpecial) { - if (!recoverFocusProc.running) recoverFocusProc.running = true - } - return - } - - if (root.isDragging || root.dragInitiated || settleTimer.running) { - return - } - - var rawX = (Array.isArray(w.at) && w.at.length >= 2 && !isNaN(Number(w.at[0]))) ? Number(w.at[0]) : 0 - var rawY = (Array.isArray(w.at) && w.at.length >= 2 && !isNaN(Number(w.at[1]))) ? Number(w.at[1]) : 0 - var rawW = (Array.isArray(w.size) && w.size.length >= 2 && !isNaN(Number(w.size[0]))) ? Number(w.size[0]) : 800 - var rawH = (Array.isArray(w.size) && w.size.length >= 2 && !isNaN(Number(w.size[1]))) ? Number(w.size[1]) : 600 - - if (rawW < 100 || rawH < 100) { - return - } - - var isMax = (w.fullscreen === 1 || w.fullscreen === 2) - var isFloat = (w.floating || false) - - // ── Limpeza de estado de Snap se a janela foi movida ou redimensionada fora do snap ── - if (w.address && !isMax && isFloat && !settleTimer.running) { - var snapInfo = root.preSnapSizes[w.address] - if (snapInfo && snapInfo.snapW > 0) { - var atSnapPos = (Math.abs(rawX - snapInfo.snapX) <= 12 && Math.abs(rawY - snapInfo.snapY) <= 12) - var atSnapSize = (Math.abs(rawW - snapInfo.snapW) <= 20 && Math.abs(rawH - snapInfo.snapH) <= 20) - - if (!snapInfo.settled) { - if (atSnapPos && atSnapSize) { - snapInfo.settled = true - } - } else { - var movedAway = (Math.hypot(rawX - snapInfo.snapX, rawY - snapInfo.snapY) > 25) - var sizeChanged = (!atSnapSize) - if (movedAway || sizeChanged) { - var snapMapClean = Object.assign({}, root.preSnapSizes) - delete snapMapClean[w.address] - root.preSnapSizes = snapMapClean - } - } - } - } - - if (!root.isDragging) { - root.snapZone = "" - } - - root.winAddr = w.address - root.winClass = w.class || w.initialClass || "" - root.winTitle = (w.title || root.winClass || "").substring(0, 60) - root.winFloat = isFloat - root.winMax = isMax - root.winX = Math.round(rawX) - root.winY = Math.round(rawY) - root.winW = Math.round(rawW) - root.winH = Math.round(rawH) - root.hasWindow = true - - // Proteção inteligente contra janelas e webapps que abrem maiores que a tela ou sob a barra do sistema - if (root.winFloat && !root.winMax && !root.preSnapSizes[w.address] && !root.isDragging && !root.dragInitiated && root.winW >= 200 && root.winH >= 200) { - if (!root.clampedWindows[w.address]) { - root.clampedWindows[w.address] = true - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - var topReserve = Math.max(30, root.reservedTop) - var bottomReserve = (root.isDockPresent && root.reservedBottom > 0) ? root.reservedBottom : 0 - var maxUsableW = sW - 24 - var maxUsableH = sH - topReserve - bottomReserve - 16 - - var curW = root.winW - var curH = root.winH - var curX = root.winX - var curY = root.winY - - var needsClamp = false - var targetW = curW - var targetH = curH - var targetX = curX - var targetY = curY - - if (curW > maxUsableW || curH > maxUsableH) { - needsClamp = true - targetW = Math.min(curW, Math.round(sW * 0.88)) - targetH = Math.min(curH, Math.round((sH - topReserve - bottomReserve) * 0.84)) - targetX = Math.round((sW - targetW) / 2) - targetY = Math.round(topReserve + Math.max(8, (sH - topReserve - bottomReserve - targetH) / 2)) - } else if (curY < topReserve) { - needsClamp = true - targetY = topReserve + 8 - if (targetY + curH > sH - bottomReserve) { - targetH = Math.max(300, sH - targetY - bottomReserve - 8) - } - } - - if (needsClamp && targetW >= 200 && targetH >= 200) { - root.winW = targetW - root.winH = targetH - root.winX = targetX - root.winY = targetY - dragProxy.x = targetX - dragProxy.y = targetY - dragProxy.width = targetW - var addr = root.cleanAddr(w.address) - if (addr) { - root.applyWindowGeometry(addr, targetX, targetY, targetW, targetH) - } - } - } - } - - if (!root.isMenuDragging) { - root.updateMenuOffsetForActiveWindow(w.address, root.winClass) - } + property var service: null + readonly property bool shown: !!service && service.showAppsInBar && service.apps.rows.length > 0 - if (root.winFloat && !root.winMax && root.winW > 200 && root.winH > 200) { - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var isLikelySnapped = (root.winW >= Math.floor(sW * 0.48)) - var isWeb = /brave|chrome|chromium/i.test(w.class || w.initialClass || "") - if (!root.preSnapSizes[w.address]) { - if (!isLikelySnapped) { - root.savedFloatingW = root.winW - root.savedFloatingH = root.winH - } - if (!isLikelySnapped || isWeb) { - var sizes = Object.assign({}, root.savedWinSizes) - sizes[w.address] = { w: root.winW, h: root.winH } - root.savedWinSizes = sizes - } - } - } + implicitWidth: shown ? (vertical ? barSize : row.implicitWidth) : 0 + implicitHeight: shown ? (vertical ? row.implicitHeight : barSize) : barSize + visible: shown - // Desativação Global do Tiling - if (root.disableTiling && !root.winFloat && root.lastFloatedAddr !== root.winAddr) { - var addr = root.cleanAddr(root.winAddr) - if (addr) { - root.lastFloatedAddr = root.winAddr - root.dispatchLua('hl.dsp.window.float({ action = "toggle", window = "address:' + addr + '" })') - root.winFloat = true - } - } - } catch(e) { - if (!root.isDragging) { - root.hasWindow = false - root.winAddr = "" - } - } - } - } + // The service may finish loading after the bar; look it up until found. + function bindService() { + if (root.service) return + var host = root.bar && root.bar.shell ? root.bar.shell : null + if (host && typeof host.serviceFor === "function") + root.service = host.serviceFor("io.github.mousefirst.controls") } - - // ── Polling de Aplicativos e Minimizadas ────────────────── Timer { - id: appsTimer - interval: 2000 + interval: 250 repeat: true - running: true - onTriggered: { - if (root.isDragging || root.dragInitiated) return - if (!appsProc.running) appsProc.running = true - if (!listProc.running) listProc.running = true - } - } - - Process { - id: appsProc - command: [root.binDir + "/mf-apps"] - stdout: StdioCollector { - onStreamFinished: { - try { root.barAppsList = JSON.parse(text) || [] } - catch(e) { root.barAppsList = [] } - } - } - } - - Process { - id: listProc - command: [root.binDir + "/mf-list"] - stdout: StdioCollector { - onStreamFinished: { - try { root.minimizedWindows = JSON.parse(text) || [] } - catch(e) { root.minimizedWindows = [] } - } - } - } - - // ── Config ─────────────────────────────────────────────── - Process { - id: cfgReadProc - command: ["cat", root.cfgFile] - stdout: StdioCollector { - onStreamFinished: { - try { - var c = JSON.parse(text) - if (c.language !== undefined) root.language = c.language - if (c.disableTiling !== undefined) root.disableTiling = c.disableTiling - if (c.alwaysVisible !== undefined) root.alwaysVisible = c.alwaysVisible - if (c.dragFullWidth !== undefined) root.dragFullWidth = c.dragFullWidth - if (c.showDragHandle !== undefined) root.showDragHandle = c.showDragHandle - if (c.ignoreDock !== undefined) root.ignoreDock = c.ignoreDock - if (c.disableSnapping !== undefined) root.disableSnapping = c.disableSnapping - if (c.enableMenuDrag !== undefined) root.enableMenuDrag = c.enableMenuDrag - if (c.rememberMenuPerWindow !== undefined) root.rememberMenuPerWindow = c.rememberMenuPerWindow - if (c.rememberMenuGlobal !== undefined) root.rememberMenuGlobal = c.rememberMenuGlobal - if (c.alwaysShowMenuDragZone !== undefined) root.alwaysShowMenuDragZone = c.alwaysShowMenuDragZone - if (c.minimizeToBar !== undefined) root.minimizeToBar = c.minimizeToBar - if (c.barSection !== undefined && root.detectedSection === "") root.barSection = c.barSection - if (c.globalMenuOffsetX !== undefined) root.globalMenuOffsetX = c.globalMenuOffsetX - if (c.globalMenuOffsetY !== undefined) root.globalMenuOffsetY = c.globalMenuOffsetY - if (c.perWindowClassOffsets !== undefined) root.perWindowClassOffsets = c.perWindowClassOffsets - if (c.buttonOrder !== undefined) root.buttonOrder = c.buttonOrder - if (c.buttonVisible !== undefined) root.buttonVisible = c.buttonVisible - if (c.colorMode !== undefined) root.colorMode = c.colorMode - if (c.customBg !== undefined) root.customBg = c.customBg - if (c.customFg !== undefined) root.customFg = c.customFg - if (c.customAccent !== undefined) root.customAccent = c.customAccent - if (c.customRed !== undefined) root.customRed = c.customRed - } catch(e) {} - } - } - } - - function saveConfig() { - var cfg = { - language: root.language, - disableTiling: root.disableTiling, - alwaysVisible: root.alwaysVisible, - dragFullWidth: root.dragFullWidth, - showDragHandle: root.showDragHandle, - ignoreDock: root.ignoreDock, - disableSnapping: root.disableSnapping, - enableMenuDrag: root.enableMenuDrag, - rememberMenuPerWindow: root.rememberMenuPerWindow, - rememberMenuGlobal: root.rememberMenuGlobal, - alwaysShowMenuDragZone: root.alwaysShowMenuDragZone, - minimizeToBar: root.minimizeToBar, - barSection: root.barSection, - globalMenuOffsetX: root.globalMenuOffsetX, - globalMenuOffsetY: root.globalMenuOffsetY, - perWindowClassOffsets: root.perWindowClassOffsets, - buttonOrder: root.buttonOrder, - buttonVisible: root.buttonVisible, - colorMode: root.colorMode, - customBg: root.customBg, - customFg: root.customFg, - customAccent: root.customAccent, - customRed: root.customRed - } - var jsonStr = JSON.stringify(cfg, null, 2) - Quickshell.execDetached(["bash", "-c", "cat << 'CFGEOF' > '" + root.cfgFile + "'\n" + jsonStr + "\nCFGEOF\n"]) - } - - // ── Tema ───────────────────────────────────────────────── - Timer { id: themeTimer; interval: 5000; repeat: true; onTriggered: themeProc.running = true } - - Process { - id: themeProc - command: [root.binDir + "/mf-theme"] - stdout: StdioCollector { - onStreamFinished: { - try { - var t = JSON.parse(text) - root.themeBg = t.background || "#1a1b26" - root.themeFg = t.foreground || "#a9b1d6" - root.themeAccent = t.accent || "#7aa2f7" - root.themeRed = t.red || "#f7768e" - root.themeMuted = t.muted || "#414868" - root.themeMode = t.mode || "dark" - } catch(e) {} - } - } - } - - // ── Processos auxiliares ────────────────────────────────── - Process { - id: minimizeProc - property string addr: "" - command: { - var targetWs = (root.minimizeToBar && root.isDockPresent) ? "special:mf-minimized" : "special:minimized" - return addr !== "" ? [root.binDir + "/mf-minimize", addr, targetWs] : [root.binDir + "/mf-minimize", "", targetWs] - } - onExited: { - if (!activeWinProc.running) activeWinProc.running = true - if (!listProc.running) listProc.running = true - if (!appsProc.running) appsProc.running = true - } - } - Process { - id: restoreProc - property string addr: "" - command: addr !== "" ? [root.binDir + "/mf-restore", addr] : [root.binDir + "/mf-restore"] - onExited: { - if (!activeWinProc.running) activeWinProc.running = true - if (!listProc.running) listProc.running = true - if (!appsProc.running) appsProc.running = true - } - } - - function launchApp(appId) { - if (!appId) return - Quickshell.execDetached(["gtk-launch", appId]) - appsTimer.restart() - } - - function focusWindow(addr) { - var clean = root.cleanAddr(addr) - if (!clean) return - root.dispatchLua('hl.dsp.focus({ window = "address:' + clean + '" })') - root.dispatchLua('hl.dsp.window.bring_to_top({ window = "address:' + clean + '" })') - if (!activeWinProc.running) activeWinProc.running = true - appsTimer.restart() - } - - function minimizeWindow(addr) { - root.hasWindow = false - root.winAddr = "" - minimizeProc.addr = addr || "" - if (!minimizeProc.running) minimizeProc.running = true - } - - function restoreWindow(addr) { - restoreProc.addr = addr || "" - if (!restoreProc.running) restoreProc.running = true - } - - function closeWindow(addr) { - var clean = root.cleanAddr(addr) - if (!clean) return - root.dispatchLua('hl.dsp.window.close({ window = "address:' + clean + '" })') - if (!activeWinProc.running) activeWinProc.running = true - appsTimer.restart() - } - - // ── Helpers ─────────────────────────────────────────────── - function moveButton(i, dir) { - var a = root.buttonOrder.slice() - var t = i + dir - if (t < 0 || t >= a.length) return - var tmp = a[i]; a[i] = a[t]; a[t] = tmp - root.buttonOrder = a - root.saveConfig() - } - - function btnTooltip(id) { - if (id === "float") return root.winFloat ? root.t("tile") : root.t("float") - if (id === "minimize") return root.t("minimize") - if (id === "maximize") return root.winMax ? root.t("restore") : root.t("maximize") - if (id === "close") return root.t("close") - return "" - } - - function btnLabel(id) { - if (id === "float") return root.t("floatTile") - if (id === "minimize") return root.t("minimize") - if (id === "maximize") return root.t("maxRestore") - if (id === "close") return root.t("close") - return id - } - - function updateMenuOffsetForActiveWindow(addr, wClass) { - if (!root.enableMenuDrag) { - root.curMenuOffsetX = 0 - root.curMenuOffsetY = 0 - return - } - if (root.rememberMenuGlobal) { - root.curMenuOffsetX = root.globalMenuOffsetX - root.curMenuOffsetY = root.globalMenuOffsetY - } else if (root.rememberMenuPerWindow) { - if (wClass && root.perWindowClassOffsets[wClass]) { - root.curMenuOffsetX = root.perWindowClassOffsets[wClass].x || 0 - root.curMenuOffsetY = root.perWindowClassOffsets[wClass].y || 0 - } else { - root.curMenuOffsetX = 0 - root.curMenuOffsetY = 0 - } - } else { - if (addr && root.sessionMenuOffsets[addr]) { - root.curMenuOffsetX = root.sessionMenuOffsets[addr].x || 0 - root.curMenuOffsetY = root.sessionMenuOffsets[addr].y || 0 - } else { - root.curMenuOffsetX = 0 - root.curMenuOffsetY = 0 - } - } - } - - function finishMenuDrag() { - if (!root.isMenuDragging) return - root.isMenuDragging = false - - if (root.rememberMenuGlobal) { - root.globalMenuOffsetX = root.curMenuOffsetX - root.globalMenuOffsetY = root.curMenuOffsetY - root.saveConfig() - } else if (root.rememberMenuPerWindow) { - if (root.winClass !== "") { - var map = Object.assign({}, root.perWindowClassOffsets) - map[root.winClass] = { x: root.curMenuOffsetX, y: root.curMenuOffsetY } - root.perWindowClassOffsets = map - root.saveConfig() - } - } else { - if (root.winAddr !== "") { - var sMap = Object.assign({}, root.sessionMenuOffsets) - sMap[root.winAddr] = { x: root.curMenuOffsetX, y: root.curMenuOffsetY } - root.sessionMenuOffsets = sMap - } - } - } - - function resetMenuOffset() { - root.curMenuOffsetX = 0 - root.curMenuOffsetY = 0 - if (root.rememberMenuGlobal) { - root.globalMenuOffsetX = 0 - root.globalMenuOffsetY = 0 - root.saveConfig() - } else if (root.rememberMenuPerWindow) { - if (root.winClass !== "" && root.perWindowClassOffsets[root.winClass]) { - var map = Object.assign({}, root.perWindowClassOffsets) - delete map[root.winClass] - root.perWindowClassOffsets = map - root.saveConfig() - } - } else { - if (root.winAddr !== "" && root.sessionMenuOffsets[root.winAddr]) { - var sMap = Object.assign({}, root.sessionMenuOffsets) - delete sMap[root.winAddr] - root.sessionMenuOffsets = sMap - } - } - } - - function triggerBtn(id) { - if (!root.hasWindow || !root.winAddr) return - var addr = root.cleanAddr(root.winAddr) - if (!addr) return - - if (id === "float") { - root.dispatchLua('hl.dsp.window.float({ action = "toggle", window = "address:' + addr + '" })') - root.winFloat = !root.winFloat - settleTimer.restart() - return - } - if (id === "minimize") { - root.hasWindow = false - root.winAddr = "" - minimizeProc.addr = addr - minimizeProc.running = true - return - } - if (id === "maximize") { - root.dispatchLua('hl.dsp.window.fullscreen({ mode = "maximized", window = "address:' + addr + '" })') - root.winMax = !root.winMax - settleTimer.restart() - return - } - if (id === "close") { - var closingAddr = root.winAddr - root.hasWindow = false - root.winAddr = "" - root.curMenuOffsetX = 0 - root.curMenuOffsetY = 0 - if (closingAddr && root.sessionMenuOffsets[closingAddr]) { - var sMap = Object.assign({}, root.sessionMenuOffsets) - delete sMap[closingAddr] - root.sessionMenuOffsets = sMap - } - root.dispatchLua('hl.dsp.window.close({ window = "address:' + addr + '" })') - settleTimer.restart() - return - } - } - - function updateSnapZone(cx, cy, rawWinX, rawWinY) { - if (root.disableSnapping) { - root.snapZone = "" - return - } - - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - - var topBarH = root.reservedTop - var dockH = root.ignoreDock ? 0 : root.reservedBottom - var G = 6 // gap uniforme em todos os lados - - var usableX = G - var usableY = topBarH + G - var usableW = sW - (G * 2) - var usableH = sH - topBarH - dockH - (G * 2) - - var halfW = Math.round((usableW - G) / 2) - var halfH = Math.round((usableH - G) / 2) - var remH = usableH - halfH - G - - var winW = (root.winW > 100 ? root.winW : 800) - var winH = (root.winH > 100 ? root.winH : 600) - - var minX = usableX - var maxX = Math.max(minX, sW - winW - G) - var minY = usableY - var maxY = Math.max(minY, sH - dockH - G - winH) - - // Se rawWinX / rawWinY não foram passados (ex: fallback), usa as coordenadas do proxy - var rX = (rawWinX !== undefined ? rawWinX : dragProxy.x) - var rY = (rawWinY !== undefined ? rawWinY : dragProxy.y) - - // Detecção de ultrapassagem intencional ("pressionar demais" > 45px além da borda) - // Permite transpor limites da tela (ex: multi-monitores ou esconder parte da janela) - var breakMargin = 45 - var isBreaking = (rX < minX - breakMargin) || (rX > maxX + breakMargin) || - (rY < minY - breakMargin) || (rY > maxY + breakMargin) || - (cx < -10) || (cx > sW + 10) || (cy < -10) || (cy > sH + 10) - - if (isBreaking) { - root.snapZone = "" - return - } - - // Margem de contato para ativação do snap ao encostar a janela na borda - var touchMargin = 6 - var winPushLeft = (rX <= minX + touchMargin) - var winPushRight = (rX >= maxX - touchMargin) - var winPushTop = (rY <= minY + touchMargin) - var winPushBottom = (rY >= maxY - touchMargin) - - // Detecção por cursor arremessado na borda extrema - var cursorPushLeft = (cx <= 16) - var cursorPushRight = (cx >= sW - 16) - var cursorPushTop = (cy <= topBarH + 16) - var cursorPushBottom = (cy >= sH - dockH - 16) - - var isPushLeft = winPushLeft || cursorPushLeft - var isPushRight = winPushRight || cursorPushRight - var isPushTop = winPushTop || cursorPushTop - var isPushBottom = winPushBottom || cursorPushBottom - - if (!isPushLeft && !isPushRight && !isPushTop && !isPushBottom) { - root.snapZone = "" - return - } - - // Quinas extremas para acionamento direto pelo cursor - var cornerSize = 32 - var cursorInTopLeft = (cx <= cornerSize && cy <= topBarH + cornerSize) - var cursorInTopRight = (cx >= sW - cornerSize && cy <= topBarH + cornerSize) - var cursorInBottomLeft = (cx <= cornerSize && cy >= sH - dockH - cornerSize) - var cursorInBottomRight = (cx >= sW - cornerSize && cy >= sH - dockH - cornerSize) - - // 1. Cantos da tela (quartos de tela): - // Aciona somente quando o canto da janela entra em contato físico com o canto da tela - // (contato lateral E superior/inferior simultaneamente), ou o cursor está na quina extrema. - var isTopLeft = cursorInTopLeft || (isPushLeft && isPushTop) - var isTopRight = cursorInTopRight || (isPushRight && isPushTop) - var isBottomLeft = cursorInBottomLeft || (isPushLeft && isPushBottom) - var isBottomRight = cursorInBottomRight || (isPushRight && isPushBottom) - - var zone = "" - var tx = 0, ty = 0, tw = 0, th = 0 - - if (isTopLeft) { - zone = "top-left" - tx = usableX - ty = usableY - tw = halfW - th = halfH - } else if (isTopRight) { - zone = "top-right" - tx = usableX + halfW + G - ty = usableY - tw = halfW - th = halfH - } else if (isBottomLeft) { - zone = "bottom-left" - tx = usableX - ty = usableY + halfH + G - tw = halfW - th = remH - } else if (isBottomRight) { - zone = "bottom-right" - tx = usableX + halfW + G - ty = usableY + halfH + G - tw = halfW - th = remH - } - // 2. Bordas laterais (metades verticais) — ampla área de contato na lateral - else if (isPushLeft) { - zone = "left" - tx = usableX - ty = usableY - tw = halfW - th = usableH - } else if (isPushRight) { - zone = "right" - tx = usableX + halfW + G - ty = usableY - tw = halfW - th = usableH - } - // 3. Bordas superior e inferior (metades horizontais) - else if (isPushTop) { - zone = "top" - tx = usableX - ty = usableY - tw = usableW - th = halfH - } else if (isPushBottom) { - zone = "bottom" - tx = usableX - ty = usableY + halfH + G - tw = usableW - th = remH - } - - root.snapZone = zone - if (zone !== "") { - root.snapTargetX = tx - root.snapTargetY = ty - root.snapTargetW = tw - root.snapTargetH = th - } - } - - function handlePress(cx, cy, isRightBtn) { - if (isRightBtn) { - settingsWin.visible = !settingsWin.visible - return - } - if (!root.hasWindow || !root.winAddr) return - if (!monitorInfoProc.running) monitorInfoProc.running = true - root.pressCursorX = cx - root.pressCursorY = cy - root.dragStartWinX = dragProxy.x - root.dragStartWinY = dragProxy.y - root.dragStartWinW = root.winW - root.dragStartWinH = root.winH - root.wasMaxOnPress = root.winMax - root.wasSnappedOnPress = !!(root.preSnapSizes[root.winAddr]) - root.dragInitiated = true - } - - function handlePositionChanged(cx, cy) { - if (!root.dragInitiated) return - var dist = Math.hypot(cx - root.pressCursorX, cy - root.pressCursorY) - - if (!root.isDragging) { - if (dist < 4) return - root.isDragging = true - - if (root.wasMaxOnPress) { - root.winMax = false - - var sz = root.savedWinSizes[root.winAddr] - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - var isWeb = /brave|chrome|chromium/i.test(root.winClass) - var maxAllowedW = isWeb ? Math.floor(sW * 0.85) : Math.min(1050, Math.floor(sW * 0.55)) - var maxAllowedH = isWeb ? Math.floor(sH * 0.80) : Math.min(700, Math.floor(sH * 0.65)) - var defaultW = isWeb ? Math.floor(sW * 0.85) : Math.min(1000, Math.floor(sW * 0.55)) - var defaultH = isWeb ? Math.floor(sH * 0.80) : Math.min(650, Math.floor(sH * 0.65)) - - var baseW = (sz && sz.w && sz.w <= maxAllowedW && sz.w >= 400) ? sz.w : - ((root.savedFloatingW && root.savedFloatingW <= maxAllowedW && root.savedFloatingW >= 400) ? root.savedFloatingW : defaultW) - var baseH = (sz && sz.h && sz.h <= maxAllowedH && sz.h >= 300) ? sz.h : - ((root.savedFloatingH && root.savedFloatingH <= maxAllowedH && root.savedFloatingH >= 300) ? root.savedFloatingH : defaultH) - - var restoredW = Math.max(500, Math.min(baseW, maxAllowedW)) - var restoredH = Math.max(350, Math.min(baseH, maxAllowedH)) - - var curProxyW = dragProxy.width > 0 ? dragProxy.width : sW - var ratioX = Math.max(0.15, Math.min(0.85, (root.pressCursorX - dragProxy.x) / curProxyW)) - - var rawX = Math.round(cx - restoredW * ratioX) - var rawY = Math.round(cy - 14) - var newWinX = root.clampWinX(rawX, restoredW) - var newWinY = root.clampWinY(rawY, restoredH) - - root.winW = restoredW - root.winH = restoredH - root.winX = newWinX - root.winY = newWinY - - dragProxy.width = restoredW - dragProxy.x = newWinX - dragProxy.y = newWinY - - root.dragStartWinX = newWinX - root.dragStartWinY = newWinY - root.pressCursorX = cx - root.pressCursorY = cy - - var addr = root.cleanAddr(root.winAddr) - if (addr) { - root.unmaximizeAndRestore(addr, newWinX, newWinY, restoredW, restoredH) - } - } else if (root.wasSnappedOnPress) { - var pre = root.preSnapSizes[root.winAddr] || root.savedWinSizes[root.winAddr] - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - var isWeb = /brave|chrome|chromium/i.test(root.winClass) - var maxAllowedW = isWeb ? Math.floor(sW * 0.85) : Math.min(1050, Math.floor(sW * 0.55)) - var maxAllowedH = isWeb ? Math.floor(sH * 0.80) : Math.min(700, Math.floor(sH * 0.65)) - var defaultW = isWeb ? Math.floor(sW * 0.85) : Math.min(1000, Math.floor(sW * 0.55)) - var defaultH = isWeb ? Math.floor(sH * 0.80) : Math.min(650, Math.floor(sH * 0.65)) - - var baseW = (pre && pre.w && pre.w <= maxAllowedW && pre.w >= 400) ? pre.w : - ((root.savedFloatingW && root.savedFloatingW <= maxAllowedW && root.savedFloatingW >= 400) ? root.savedFloatingW : defaultW) - var baseH = (pre && pre.h && pre.h <= maxAllowedH && pre.h >= 300) ? pre.h : - ((root.savedFloatingH && root.savedFloatingH <= maxAllowedH && root.savedFloatingH >= 300) ? root.savedFloatingH : defaultH) - - var restoredW = Math.max(500, Math.min(baseW, maxAllowedW)) - var restoredH = Math.max(350, Math.min(baseH, maxAllowedH)) - - var snapMap = Object.assign({}, root.preSnapSizes) - delete snapMap[root.winAddr] - root.preSnapSizes = snapMap - - var curProxyW = dragProxy.width > 0 ? dragProxy.width : sW - var ratioX = Math.max(0.15, Math.min(0.85, (root.pressCursorX - dragProxy.x) / curProxyW)) - - var rawX = Math.round(cx - restoredW * ratioX) - var rawY = Math.round(cy - 14) - var newWinX = root.clampWinX(rawX, restoredW) - var newWinY = root.clampWinY(rawY, restoredH) - - root.winW = restoredW - root.winH = restoredH - root.winX = newWinX - root.winY = newWinY - - dragProxy.width = restoredW - dragProxy.x = newWinX - dragProxy.y = newWinY - - root.dragStartWinX = newWinX - root.dragStartWinY = newWinY - root.pressCursorX = cx - root.pressCursorY = cy - - var addr = root.cleanAddr(root.winAddr) - if (addr) { - root.applyWindowGeometry(addr, newWinX, newWinY, restoredW, restoredH) - } - } - - var addr = root.cleanAddr(root.winAddr) - if (addr) { - if (!root.winFloat) { - root.dispatchLua('hl.dsp.window.float({ action = "on", window = "address:' + addr + '" })') - root.winFloat = true - } - root.dispatchLua('hl.dsp.window.bring_to_top({ window = "address:' + addr + '" })') - } - } - - var dx = cx - root.pressCursorX - var dy = cy - root.pressCursorY - - var rawWinX = root.dragStartWinX + dx - var rawWinY = root.dragStartWinY + dy - - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - var winW = (root.winW > 100 ? root.winW : 800) - var winH = (root.winH > 100 ? root.winH : 600) - var effectiveDock = root.ignoreDock ? 0 : root.reservedBottom - var minX = 6 - var maxX = Math.max(minX, sW - winW - 6) - var minY = root.reservedTop + 6 - var maxY = Math.max(minY, sH - effectiveDock - 6 - winH) - - var breakMargin = 45 - var isBreaking = (rawWinX < minX - breakMargin) || (rawWinX > maxX + breakMargin) || - (rawWinY < minY - breakMargin) || (rawWinY > maxY + breakMargin) || - (cx < -10) || (cx > sW + 10) || (cy < -10) || (cy > sH + 10) - - var targetX = isBreaking ? rawWinX : root.clampWinX(rawWinX, root.winW) - var targetY = isBreaking ? rawWinY : root.clampWinY(rawWinY, root.winH) - - dragProxy.x = targetX - dragProxy.y = targetY - root.winX = targetX - root.winY = targetY - - root.dragNeedsDispatch = true - if (!dragMoveTimer.running) dragMoveTimer.running = true - - root.updateSnapZone(cx, cy, rawWinX, rawWinY) - } - - function handleRelease() { - root.dragInitiated = false - if (root.isDragging) { - root.isDragging = false - root.finishDrag() - } + running: !root.service + triggeredOnStart: true + onTriggered: root.bindService() } + onBarChanged: bindService() - function finishDrag() { - dragMoveTimer.running = false - root.dragNeedsDispatch = false - - if (root.winAddr !== "") { - if (root.snapZone !== "") { - var origW = (root.preSnapSizes[root.winAddr] && root.preSnapSizes[root.winAddr].w) ? root.preSnapSizes[root.winAddr].w : - (root.savedWinSizes[root.winAddr] ? root.savedWinSizes[root.winAddr].w : root.dragStartWinW) - var origH = (root.preSnapSizes[root.winAddr] && root.preSnapSizes[root.winAddr].h) ? root.preSnapSizes[root.winAddr].h : - (root.savedWinSizes[root.winAddr] ? root.savedWinSizes[root.winAddr].h : root.dragStartWinH) - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - var isWeb = /brave|chrome|chromium/i.test(root.winClass) - var maxFloatingCapW = isWeb ? Math.floor(sW * 0.85) : Math.min(1000, Math.floor(sW * 0.55)) - var maxFloatingCapH = isWeb ? Math.floor(sH * 0.80) : Math.min(650, Math.floor(sH * 0.65)) - if (origW > maxFloatingCapW) origW = maxFloatingCapW - if (origH > maxFloatingCapH) origH = maxFloatingCapH - - var targetX = root.snapTargetX - var targetY = root.snapTargetY - var targetW = root.snapTargetW - var targetH = root.snapTargetH - - var snapMap = Object.assign({}, root.preSnapSizes) - snapMap[root.winAddr] = { - w: origW, - h: origH, - snapX: targetX, - snapY: targetY, - snapW: targetW, - snapH: targetH, - settled: false - } - root.preSnapSizes = snapMap - - root.winX = targetX - root.winY = targetY - root.winW = targetW - root.winH = targetH - - dragProxy.x = targetX - dragProxy.y = targetY - dragProxy.width = targetW - - root.snapZone = "" - - var addr = root.cleanAddr(root.winAddr) - if (addr) { - root.applyWindowGeometry(addr, targetX, targetY, targetW, targetH) - } - } else { - var finalX = root.clampWinX(dragProxy.x, root.winW) - var finalY = root.clampWinY(dragProxy.y, root.winH) - dragProxy.x = finalX - dragProxy.y = finalY - root.winX = finalX - root.winY = finalY - var addr = root.cleanAddr(root.winAddr) - if (addr) { - root.dispatchLua('hl.dsp.window.move({ x = ' + finalX + ', y = ' + finalY + ', relative = false, window = "address:' + addr + '" })') - } - } - } - settleTimer.restart() + Loader { + id: menuLoader + active: !!root.service + sourceComponent: AppMenu { svc: root.service } } - // ── Ícones de aplicativos e janelas na barra do sistema ─── - Row { - id: minAppsRow + Grid { + id: row + anchors.centerIn: parent + columns: root.vertical ? 1 : Math.max(1, repeater.count) spacing: 4 - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - height: root.barSize - visible: root.shouldShowAppsInBar && root.barAppsList.length > 0 Repeater { - model: root.barAppsList - delegate: Rectangle { - id: appTile + id: repeater + model: root.service ? root.service.apps.rows : [] + delegate: AppTile { required property var modelData - required property int index - - implicitWidth: 26 - implicitHeight: 26 - width: 26 - height: 26 - radius: 6 - color: { - if (tileMouse.containsMouse) { - return modelData.state === "active" ? Qt.rgba(1, 1, 1, 0.22) : - modelData.state === "running" ? Qt.rgba(1, 1, 1, 0.16) : - modelData.state === "minimized" ? Qt.rgba(1, 1, 1, 0.14) : - Qt.rgba(1, 1, 1, 0.12) - } - return modelData.state === "active" ? Qt.rgba(1, 1, 1, 0.14) : - modelData.state === "running" ? Qt.rgba(1, 1, 1, 0.06) : - modelData.state === "minimized" ? Qt.rgba(1, 1, 1, 0.04) : - "transparent" - } - border.color: { - if (modelData.state === "active") return Qt.rgba(root.effAccent.r, root.effAccent.g, root.effAccent.b, 0.5) - if (tileMouse.containsMouse) return Qt.rgba(1, 1, 1, 0.22) - return "transparent" - } - border.width: 1 - Behavior on color { ColorAnimation { duration: 80 } } - - Image { - id: appIco - anchors.centerIn: parent - width: 16 - height: 16 - sourceSize.width: 16 - sourceSize.height: 16 - smooth: true - fillMode: Image.PreserveAspectFit - opacity: (modelData.state === "minimized") ? 0.55 : ((modelData.state === "closed") ? 0.8 : 1.0) - source: { - var ico = modelData.icon || "" - if (!ico) return "" - if (ico.indexOf("file://") === 0) return ico - if (ico.charAt(0) === "/") return "file://" + ico - return "" - } - visible: status === Image.Ready - } - - Text { - anchors.centerIn: parent - visible: !appIco.visible - text: (modelData.name || modelData.appId || "?").substring(0, 1).toUpperCase() - color: root.effAccent - font.pixelSize: 11 - font.bold: true - } - - // Indicador de estado (ativo, em execução, minimizado) - Rectangle { - anchors.bottom: parent.bottom - anchors.bottomMargin: 1.5 - anchors.horizontalCenter: parent.horizontalCenter - visible: modelData.state !== "closed" - width: (modelData.state === "active") ? 10 : 4 - height: (modelData.state === "active") ? 2.5 : 2 - radius: 1.25 - color: (modelData.state === "minimized") ? root.themeMuted : root.effAccent - opacity: (modelData.state === "active") ? 1.0 : ((modelData.state === "running") ? 0.85 : 0.65) - } - - MouseArea { - id: tileMouse - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - cursorShape: Qt.PointingHandCursor - - onEntered: { - var title = modelData.title ? (modelData.name + " — " + modelData.title) : (modelData.name || modelData.appId || root.t("untitled")) - try { - if (root.parent && root.parent.bar && root.parent.bar.showTooltip) { - root.parent.bar.showTooltip(appTile, title) - } else if (root.bar && root.bar.showTooltip) { - root.bar.showTooltip(appTile, title) - } - } catch(e) {} - } - onExited: { - try { - if (root.parent && root.parent.bar && root.parent.bar.hideTooltip) { - root.parent.bar.hideTooltip(appTile) - } else if (root.bar && root.bar.hideTooltip) { - root.bar.hideTooltip(appTile) - } - } catch(e) {} - } - - onClicked: mouse => { - try { - if (root.parent && root.parent.bar && root.parent.bar.hideTooltip) { - root.parent.bar.hideTooltip(appTile) - } else if (root.bar && root.bar.hideTooltip) { - root.bar.hideTooltip(appTile) - } - } catch(e) {} - - if (mouse.button === Qt.LeftButton) { - if (modelData.state === "active") { - root.minimizeWindow(modelData.address) - } else if (modelData.state === "running") { - root.focusWindow(modelData.address) - } else if (modelData.state === "minimized") { - root.restoreWindow(modelData.address) - } else if (modelData.state === "closed") { - root.launchApp(modelData.appId) - } - } else if (mouse.button === Qt.RightButton || mouse.button === Qt.MiddleButton) { - if (modelData.state !== "closed" && modelData.address) { - root.closeWindow(modelData.address) - } - } - } - } + row: modelData + svc: root.service + bar: root.bar + menu: menuLoader.item } } } - - // ── PanelWindow — Overlay Flutuante Global ──────────────── - PanelWindow { - id: fw - - WlrLayershell.namespace: "mouse-first-controls" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - exclusionMode: ExclusionMode.Ignore - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" - - // Input mask: somente as áreas interativas recebem cliques (em drag, a tela toda recebe) - mask: Region { - Region { item: (root.isDragging || root.dragInitiated || root.isMenuDragging) ? overlayRoot : null } - Region { item: settingsBackdrop.visible ? settingsBackdrop : null } - Region { item: minDropBackdrop.visible ? minDropBackdrop : null } - Region { item: settingsWin.visible ? settingsWin : null } - Region { item: minDropPanel.visible ? minDropPanel : null } - Region { item: (root.hasWindow && root.dragFullWidth) ? dragHandleZone : null } - Region { item: controlPanel.visible ? controlPanel : null } - } - - Item { - id: overlayRoot - anchors.fill: parent - - // ── Sombra de Preview de Snapping (Aero Snap / Light Tiling) ── - Rectangle { - id: snapPreview - property bool active: root.isDragging && root.snapZone !== "" - visible: opacity > 0.01 - opacity: active ? 1.0 : 0.0 - x: root.snapTargetX - y: root.snapTargetY - width: root.snapTargetW - height: root.snapTargetH - radius: 12 - color: Qt.rgba(0.48, 0.64, 0.97, 0.16) - border.color: root.effAccent - border.width: 2 - z: 2 - - Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } - Behavior on y { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } - Behavior on width { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } - Behavior on height { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } - Behavior on opacity { NumberAnimation { duration: 140 } } - } - - // ── Backdrop para fechar Settings ao clicar fora ─── - MouseArea { - id: settingsBackdrop - anchors.fill: parent - visible: settingsWin.visible - z: 90 - onClicked: settingsWin.visible = false - } - - // ── Backdrop para fechar Dropdown de Minimizadas ──── - MouseArea { - id: minDropBackdrop - anchors.fill: parent - visible: minDropPanel.visible - z: 140 - onClicked: root.showMinimizedPanel = false - } - - // ── Item de Drag Nativo (QtQuick C++ Drag Engine) ── - Item { - id: dragProxy - visible: root.hasWindow - x: root.winX - y: root.winY - width: root.winW - height: 8 - - Binding { - target: dragProxy - property: "x" - value: root.winX - when: !root.isDragging - } - Binding { - target: dragProxy - property: "y" - value: root.winY - when: !root.isDragging - } - Binding { - target: dragProxy - property: "width" - value: root.winW - } - - onXChanged: { - if (root.isDragging) { - root.winX = dragProxy.x - root.dragNeedsDispatch = true - if (!dragMoveTimer.running) dragMoveTimer.running = true - } - } - onYChanged: { - if (root.isDragging) { - root.winY = dragProxy.y - root.dragNeedsDispatch = true - if (!dragMoveTimer.running) dragMoveTimer.running = true - } - } - - // Linha visual elegante centralizada na janela - Rectangle { - id: topDragIndicator - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - width: Math.max(160, Math.min(parent.width - 160, Math.round(parent.width * 0.45))) - height: 3 - radius: 1.5 - color: root.effAccent - visible: root.dragFullWidth - opacity: (dragTopMouse.containsMouse || root.isDragging) ? 0.65 : 0 - Behavior on opacity { NumberAnimation { duration: 180 } } - } - - // Zona de arrasto ampla no cabeçalho (preserva botões na esquerda e controles na direita) - Item { - id: dragHandleZone - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - // Deixa margem para botões de voltar/refresh em webapps quando flutuante normal; - // quando ancorada ou maximizada, cobre desde o canto esquerdo para un-snap imediato - anchors.leftMargin: (root.preSnapSizes[root.winAddr] || root.winMax) ? 6 : 72 - anchors.rightMargin: Math.max(120, bubble.width + 12) - height: 8 - - MouseArea { - id: dragTopMouse - anchors.fill: parent - enabled: root.dragFullWidth - hoverEnabled: root.dragFullWidth - cursorShape: containsMouse ? Qt.SizeAllCursor : Qt.ArrowCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton - - onDoubleClicked: mouse => { - if (mouse.button === Qt.LeftButton) { - root.triggerBtn("maximize") - } - } - - onPressed: mouse => { - var pt = mapToItem(overlayRoot, mouse.x, mouse.y) - root.handlePress(pt.x, pt.y, mouse.button === Qt.RightButton) - } - - onPositionChanged: mouse => { - var pt = mapToItem(overlayRoot, mouse.x, mouse.y) - root.handlePositionChanged(pt.x, pt.y) - } - - onReleased: { - root.handleRelease() - } - - onCanceled: { - root.handleRelease() - } - } - } - } - - // ── Barra Flutuante de Controles ────────────────── - Item { - id: controlPanel - visible: root.hasWindow - z: 50 - - property bool isHovered: false - - Timer { - id: hideTimer - interval: 500 - onTriggered: controlPanel.isHovered = false - } - - // Posicionamento GLUADO diretamente no canto superior direito da janela com offset customizado - x: { - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var curX = (root.isDragging ? dragProxy.x : root.winX) - var curW = (root.isDragging ? dragProxy.width : root.winW) - return Math.max(0, Math.min(sW - bubble.width, curX + curW - bubble.width + root.curMenuOffsetX)) - } - y: { - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - var curY = (root.isDragging ? dragProxy.y : root.winY) - return Math.max(root.reservedTop, Math.min(sH - bubble.height, curY + root.curMenuOffsetY)) - } - width: bubble.width - height: bubble.height - - opacity: { - if (root.alwaysVisible) return 1.0 - return (controlPanel.isHovered || bubbleHover.containsMouse || settingsWin.visible) ? 1.0 : 0.0 - } - Behavior on opacity { NumberAnimation { duration: 150 } } - - // Zona de aproximação do hover - MouseArea { - anchors.centerIn: parent - width: parent.width + 24 - height: parent.height + 16 - hoverEnabled: true - acceptedButtons: Qt.NoButton - onEntered: { - hideTimer.stop() - controlPanel.isHovered = true - } - onExited: { - hideTimer.restart() - } - } - - Rectangle { - id: bubble - height: 34 - radius: 10 - color: root.effBg - border.color: Qt.rgba(1,1,1, root.themeMode === "dark" ? 0.20 : 0.30) - border.width: 1 - - implicitWidth: { - var n = root.buttonOrder.filter(b => root.buttonVisible[b] !== false).length - return 6 + (root.showDragHandle ? 24 : 0) + n * 28 + Math.max(0, n-1) * 4 + 6 - } - width: implicitWidth - - MouseArea { - id: bubbleHover - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.RightButton - onClicked: mouse => { - if (mouse.button === Qt.RightButton) { - settingsWin.visible = !settingsWin.visible - } - } - onEntered: { - hideTimer.stop() - controlPanel.isHovered = true - } - onExited: { - hideTimer.restart() - } - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 6 - anchors.rightMargin: 6 - spacing: 4 - - // Alça de Drag opcional - Item { - visible: root.showDragHandle - implicitWidth: 22 - implicitHeight: 28 - Column { - anchors.centerIn: parent - spacing: 3 - Repeater { - model: 3 - Row { - spacing: 3 - Repeater { - model: 2 - Rectangle { - width: 3 - height: 3 - radius: 1.5 - color: root.effFg - opacity: handleMouse.containsMouse ? 0.75 : 0.28 - Behavior on opacity { NumberAnimation { duration: 120 } } - } - } - } - } - } - MouseArea { - id: handleMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.SizeAllCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton - - onPressed: mouse => { - var pt = mapToItem(overlayRoot, mouse.x, mouse.y) - root.handlePress(pt.x, pt.y, mouse.button === Qt.RightButton) - } - onPositionChanged: mouse => { - var pt = mapToItem(overlayRoot, mouse.x, mouse.y) - root.handlePositionChanged(pt.x, pt.y) - } - onReleased: { - root.handleRelease() - } - onCanceled: { - root.handleRelease() - } - } - } - - // Botões da Janela - Repeater { - model: root.buttonOrder - delegate: Rectangle { - required property string modelData - property string bid: modelData - visible: root.buttonVisible[bid] !== false - implicitWidth: 28 - implicitHeight: 28 - radius: 7 - readonly property bool hov: bma.containsMouse - readonly property bool prs: bma.pressed - readonly property bool isClose: bid === "close" - readonly property bool isAccent: bid === "float" && root.winFloat - readonly property color iconColor: (hov && isClose) ? "white" : (isAccent ? root.effAccent : Qt.rgba(1, 1, 1, 0.85)) - - color: { - if (prs && isClose) return Qt.rgba(0.85,0.15,0.15,0.9) - if (hov && isClose) return Qt.rgba(0.9,0.2,0.2,0.70) - if (prs && isAccent) return Qt.rgba(0.47,0.64,0.97,0.35) - if (hov && isAccent) return Qt.rgba(0.47,0.64,0.97,0.20) - if (prs) return Qt.rgba(1,1,1,0.15) - if (hov) return Qt.rgba(1,1,1,0.09) - return "transparent" - } - Behavior on color { ColorAnimation { duration: 80 } } - - // Desenho consistente e vetorial dos ícones - Item { - anchors.centerIn: parent - width: 14 - height: 14 - - // Minimizar: barra horizontal nítida de 10x2px - Rectangle { - visible: bid === "minimize" - anchors.centerIn: parent - width: 10 - height: 2 - radius: 1 - color: iconColor - Behavior on color { ColorAnimation { duration: 80 } } - } - - // Maximizar (quando não maximizada): quadrado vazado nítido de 10x10px - Rectangle { - visible: bid === "maximize" && !root.winMax - anchors.centerIn: parent - width: 10 - height: 10 - radius: 1.5 - color: "transparent" - border.color: iconColor - border.width: 1.5 - Behavior on border.color { ColorAnimation { duration: 80 } } - } - - // Restaurar (quando maximizada): dois quadrados sobrepostos consistentes - Item { - visible: bid === "maximize" && root.winMax - anchors.centerIn: parent - width: 11 - height: 11 - Rectangle { - x: 2; y: 0; width: 8; height: 8; radius: 1 - color: "transparent" - border.color: iconColor - border.width: 1.2 - Behavior on border.color { ColorAnimation { duration: 80 } } - } - Rectangle { - x: 0; y: 3; width: 8; height: 8; radius: 1 - color: (hov && isClose) ? "white" : root.effBg - border.color: iconColor - border.width: 1.2 - Behavior on border.color { ColorAnimation { duration: 80 } } - } - } - - // Fechar: dois traços vetoriais cruzados nítidos e proporcionais - Item { - visible: bid === "close" - anchors.centerIn: parent - width: 14 - height: 14 - - Rectangle { - anchors.centerIn: parent - width: 13.5 - height: 1.8 - radius: 0.9 - rotation: 45 - color: iconColor - antialiasing: true - Behavior on color { ColorAnimation { duration: 80 } } - } - Rectangle { - anchors.centerIn: parent - width: 13.5 - height: 1.8 - radius: 0.9 - rotation: -45 - color: iconColor - antialiasing: true - Behavior on color { ColorAnimation { duration: 80 } } - } - } - - // Float/Tile - Text { - visible: bid === "float" - anchors.centerIn: parent - text: root.winFloat ? "⧉" : "⊡" - color: iconColor - font.pixelSize: 12 - Behavior on color { ColorAnimation { duration: 80 } } - } - } - - Rectangle { - visible: hov - anchors.bottom: parent.top - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottomMargin: 5 - width: ttxt.implicitWidth + 14 - height: ttxt.implicitHeight + 8 - radius: 5 - color: Qt.rgba(0,0,0,0.85) - border.color: Qt.rgba(1,1,1,0.12) - border.width: 1 - z: 200 - Text { - id: ttxt - anchors.centerIn: parent - text: root.btnTooltip(bid) - color: "white" - font.pixelSize: 11 - } - } - - MouseArea { - id: bma - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: mouse => { - if (mouse.button === Qt.RightButton) { - settingsWin.visible = !settingsWin.visible - } else { - root.triggerBtn(bid) - } - } - } - } - } - } - - // Indicador fino de arrasto na borda inferior do menu (aparece no hover/drag ou sempre se alwaysShowMenuDragZone) - Rectangle { - id: menuDragLine - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottomMargin: 2 - width: Math.min(parent.width - 24, 34) - height: 2 - radius: 1 - color: root.effAccent - visible: root.enableMenuDrag - opacity: (root.alwaysShowMenuDragZone || menuDragArea.containsMouse || root.isMenuDragging) ? 0.85 : 0 - Behavior on opacity { NumberAnimation { duration: 180 } } - } - - MouseArea { - id: menuDragArea - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - width: Math.min(parent.width - 16, 46) - height: 5 - enabled: root.enableMenuDrag - hoverEnabled: root.enableMenuDrag - cursorShape: containsMouse ? Qt.SizeAllCursor : Qt.ArrowCursor - acceptedButtons: Qt.LeftButton - z: 20 - - onEntered: { - hideTimer.stop() - controlPanel.isHovered = true - } - onExited: { - if (!root.isMenuDragging) hideTimer.restart() - } - - onDoubleClicked: mouse => { - if (mouse.button === Qt.LeftButton) { - root.resetMenuOffset() - } - } - - onPressed: mouse => { - if (mouse.button === Qt.LeftButton) { - var pt = mapToItem(overlayRoot, mouse.x, mouse.y) - root.menuDragStartMouseX = pt.x - root.menuDragStartMouseY = pt.y - root.menuDragStartOffsetX = root.curMenuOffsetX - root.menuDragStartOffsetY = root.curMenuOffsetY - root.isMenuDragging = true - } - } - - onPositionChanged: mouse => { - if (!root.isMenuDragging) return - var pt = mapToItem(overlayRoot, mouse.x, mouse.y) - var dx = pt.x - root.menuDragStartMouseX - var dy = pt.y - root.menuDragStartMouseY - - var sW = (overlayRoot.width > 0 ? overlayRoot.width : Screen.width) - var sH = (overlayRoot.height > 0 ? overlayRoot.height : Screen.height) - var baseMenuX = dragProxy.x + dragProxy.width - bubble.width - var baseMenuY = dragProxy.y - - var rawOffX = root.menuDragStartOffsetX + dx - var rawOffY = root.menuDragStartOffsetY + dy - - var minOffX = -baseMenuX - var maxOffX = sW - bubble.width - baseMenuX - var minOffY = root.reservedTop - baseMenuY - var maxOffY = sH - bubble.height - baseMenuY - - root.curMenuOffsetX = Math.round(Math.max(minOffX, Math.min(maxOffX, rawOffX))) - root.curMenuOffsetY = Math.round(Math.max(minOffY, Math.min(maxOffY, rawOffY))) - } - - onReleased: { - root.finishMenuDrag() - } - - onCanceled: { - root.finishMenuDrag() - } - } - } - } - - // ── Painel de Configurações ──────────────────────── - Rectangle { - id: settingsWin - visible: false - onVisibleChanged: { - if (visible) { - if (root.detectedSection !== "") { - root.barSection = root.detectedSection - } else if (!checkBarSectionProc.running) { - checkBarSectionProc.running = true - } - } - } - z: 100 - x: Math.max(8, Math.min((fw.width > 0 ? fw.width : Screen.width) - width - 8, controlPanel.x + controlPanel.width - width)) - y: Math.max(40, Math.min((fw.height > 0 ? fw.height : Screen.height) - height - 8, controlPanel.y + controlPanel.height + 8)) - width: 390 - height: sCol.implicitHeight + 32 - radius: 12 - color: root.effBg - border.color: Qt.rgba(1,1,1,0.12) - border.width: 1 - - MouseArea { anchors.fill: parent } - - Column { - id: sCol - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: 16 - spacing: 12 - - Text { text: root.t("settingsTitle"); color: root.effFg; font.pixelSize: 14; font.bold: true; width: parent.width } - Text { text: root.t("settingsHint"); color: root.themeMuted; font.pixelSize: 10; width: parent.width } - Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.08) } - - // Comportamento Global de Tiling - Text { text: root.t("systemBehavior"); color: root.themeMuted; font.pixelSize: 9; font.bold: true; width: parent.width } - SettingsToggle { - label: root.t("disableTiling") - sublabel: root.t("disableTilingSub") - checked: root.disableTiling - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { - root.disableTiling = !root.disableTiling - root.saveConfig() - } - } - - // Janelas Minimizadas na Barra do Sistema - Text { text: root.t("minimizedSectionTitle"); color: root.themeMuted; font.pixelSize: 9; font.bold: true; width: parent.width } - SettingsToggle { - label: root.t("minimizeToBar") - sublabel: root.t("minimizeToBarSub") - checked: root.minimizeToBar - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { - root.minimizeToBar = !root.minimizeToBar - root.saveConfig() - } - } - - Column { - width: parent.width - 16 - x: 16 - spacing: 6 - opacity: root.minimizeToBar ? 1.0 : 0.35 - enabled: root.minimizeToBar - Behavior on opacity { NumberAnimation { duration: 150 } } - - Text { - text: root.t("minimizedAppsLocation") - color: root.effFg - font.pixelSize: 12 - width: parent.width - wrapMode: Text.Wrap - } - Text { - text: root.t("minimizedAppsLocationSub") - color: root.themeMuted - font.pixelSize: 10 - width: parent.width - wrapMode: Text.Wrap - } - - Row { - spacing: 24 - topPadding: 4 - - Repeater { - model: [ - { id: "left", label: root.t("locLeft") }, - { id: "center", label: root.t("locCenter") }, - { id: "right", label: root.t("locRight") } - ] - delegate: Item { - id: radioItem - required property var modelData - readonly property bool isSelected: root.barSection === (radioItem.modelData ? radioItem.modelData.id : "") - width: radioRow.implicitWidth - height: 24 - - Row { - id: radioRow - spacing: 8 - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - width: 16 - height: 16 - radius: 8 - anchors.verticalCenter: parent.verticalCenter - color: "transparent" - border.color: radioItem.isSelected ? root.effAccent : Qt.rgba(1,1,1,0.25) - border.width: 1.5 - Behavior on border.color { ColorAnimation { duration: 120 } } - - Rectangle { - anchors.centerIn: parent - width: 8 - height: 8 - radius: 4 - color: root.effAccent - visible: radioItem.isSelected - opacity: radioItem.isSelected ? 1.0 : 0 - Behavior on opacity { NumberAnimation { duration: 120 } } - } - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: radioItem.modelData ? radioItem.modelData.label : "" - color: radioItem.isSelected ? root.effFg : root.themeMuted - font.pixelSize: 11 - Behavior on color { ColorAnimation { duration: 120 } } - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - enabled: root.minimizeToBar - onClicked: { - root.barSection = radioItem.modelData.id - root.setBarSection(radioItem.modelData.id) - } - } - } - } - } - } - - // Visibilidade - Text { text: root.t("visibility"); color: root.themeMuted; font.pixelSize: 9; font.bold: true; width: parent.width } - SettingsToggle { - label: root.t("alwaysVisible") - sublabel: root.t("alwaysVisibleSub") - checked: root.alwaysVisible - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { root.alwaysVisible = !root.alwaysVisible; root.saveConfig() } - } - - // Drag - Text { text: root.t("dragging"); color: root.themeMuted; font.pixelSize: 9; font.bold: true; width: parent.width } - SettingsToggle { - label: root.t("dragZone") - sublabel: root.t("dragZoneSub") - checked: root.dragFullWidth - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { root.dragFullWidth = !root.dragFullWidth; root.saveConfig() } - } - SettingsToggle { - label: root.t("dragHandle") - sublabel: root.t("dragHandleSub") - checked: root.showDragHandle - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { root.showDragHandle = !root.showDragHandle; root.saveConfig() } - } - SettingsToggle { - label: root.t("ignoreDock") - sublabel: root.t("ignoreDockSub") - checked: root.ignoreDock - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { root.ignoreDock = !root.ignoreDock; root.saveConfig() } - } - SettingsToggle { - label: root.t("disableSnapping") - sublabel: root.t("disableSnappingSub") - checked: root.disableSnapping - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { root.disableSnapping = !root.disableSnapping; root.saveConfig() } - } - - // Reposicionamento dos Controles Flutuantes - Text { text: root.t("menuDragging"); color: root.themeMuted; font.pixelSize: 9; font.bold: true; width: parent.width } - SettingsToggle { - label: root.t("enableMenuDrag") - sublabel: root.t("enableMenuDragSub") - checked: root.enableMenuDrag - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { - root.enableMenuDrag = !root.enableMenuDrag - if (!root.enableMenuDrag) { - root.curMenuOffsetX = 0 - root.curMenuOffsetY = 0 - } else { - root.updateMenuOffsetForActiveWindow(root.winAddr, root.winClass) - } - root.saveConfig() - } - } - - Column { - width: parent.width - spacing: 8 - opacity: root.enableMenuDrag ? 1.0 : 0.35 - enabled: root.enableMenuDrag - Behavior on opacity { NumberAnimation { duration: 150 } } - - SettingsToggle { - width: parent.width - 16 - x: 16 - label: root.t("rememberMenuPerWindow") - sublabel: root.t("rememberMenuPerWindowSub") - checked: root.rememberMenuPerWindow - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { - root.rememberMenuPerWindow = !root.rememberMenuPerWindow - if (root.rememberMenuPerWindow) { - root.rememberMenuGlobal = false - } - root.updateMenuOffsetForActiveWindow(root.winAddr, root.winClass) - root.saveConfig() - } - } - - SettingsToggle { - width: parent.width - 16 - x: 16 - label: root.t("rememberMenuGlobal") - sublabel: root.t("rememberMenuGlobalSub") - checked: root.rememberMenuGlobal - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { - root.rememberMenuGlobal = !root.rememberMenuGlobal - if (root.rememberMenuGlobal) { - root.rememberMenuPerWindow = false - } - root.updateMenuOffsetForActiveWindow(root.winAddr, root.winClass) - root.saveConfig() - } - } - - SettingsToggle { - width: parent.width - 16 - x: 16 - label: root.t("alwaysShowMenuDragZone") - sublabel: root.t("alwaysShowMenuDragZoneSub") - checked: root.alwaysShowMenuDragZone - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { - root.alwaysShowMenuDragZone = !root.alwaysShowMenuDragZone - root.saveConfig() - } - } - } - - // Botões - Text { text: root.t("buttonsTitle"); color: root.themeMuted; font.pixelSize: 9; font.bold: true; width: parent.width } - Text { text: root.t("buttonsHint"); color: root.themeMuted; font.pixelSize: 10; width: parent.width; wrapMode: Text.Wrap } - - Column { - width: parent.width - spacing: 4 - Repeater { - model: root.buttonOrder - delegate: Rectangle { - required property string modelData - required property int index - property string bid: modelData - width: parent.width - height: 40 - radius: 8 - color: Qt.rgba(1,1,1,0.04) - border.color: Qt.rgba(1,1,1,0.07) - border.width: 1 - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 8 - - Rectangle { - width: 26; height: 26; radius: 6 - color: Qt.rgba(1,1,1,0.07) - Item { - anchors.centerIn: parent - width: 14; height: 14 - Rectangle { - visible: bid === "minimize" - anchors.centerIn: parent - width: 10; height: 2; radius: 1; color: root.effFg - } - Rectangle { - visible: bid === "maximize" - anchors.centerIn: parent - width: 10; height: 10; radius: 1.5; color: "transparent"; border.color: root.effFg; border.width: 1.5 - } - Item { - visible: bid === "close" - anchors.centerIn: parent - width: 12; height: 12 - Rectangle { - anchors.centerIn: parent - width: 12; height: 2; radius: 1 - rotation: 45; color: root.effRed; antialiasing: true - } - Rectangle { - anchors.centerIn: parent - width: 12; height: 2; radius: 1 - rotation: -45; color: root.effRed; antialiasing: true - } - } - Text { - visible: bid === "float" - anchors.centerIn: parent - text: "⧉"; color: root.effFg; font.pixelSize: 12 - } - } - } - - Text { - Layout.fillWidth: true - text: root.btnLabel(bid) - color: root.effFg - font.pixelSize: 12 - elide: Text.ElideRight - } - - Row { - spacing: 2 - Rectangle { - width: 22; height: 22; radius: 5 - color: upMa.containsMouse && index > 0 ? Qt.rgba(1,1,1,0.10) : Qt.rgba(1,1,1,0.04) - Behavior on color { ColorAnimation { duration: 80 } } - Text { - anchors.centerIn: parent - text: "↑" - color: index > 0 ? root.effFg : root.themeMuted - font.pixelSize: 13 - opacity: index > 0 ? 1.0 : 0.25 - } - MouseArea { id: upMa; anchors.fill: parent; hoverEnabled: true; enabled: index > 0; onClicked: root.moveButton(index, -1) } - } - Rectangle { - width: 22; height: 22; radius: 5 - color: dnMa.containsMouse && index < root.buttonOrder.length-1 ? Qt.rgba(1,1,1,0.10) : Qt.rgba(1,1,1,0.04) - Behavior on color { ColorAnimation { duration: 80 } } - Text { - anchors.centerIn: parent - text: "↓" - color: index < root.buttonOrder.length-1 ? root.effFg : root.themeMuted - font.pixelSize: 13 - opacity: index < root.buttonOrder.length-1 ? 1.0 : 0.25 - } - MouseArea { id: dnMa; anchors.fill: parent; hoverEnabled: true; enabled: index < root.buttonOrder.length-1; onClicked: root.moveButton(index, 1) } - } - } - - Rectangle { - id: vp - width: 36; height: 20; radius: 10 - property bool on: root.buttonVisible[bid] !== false - color: on ? root.effAccent : Qt.rgba(1,1,1,0.12) - Behavior on color { ColorAnimation { duration: 150 } } - Rectangle { - x: vp.on ? 18 : 2; y: 2; width: 16; height: 16; radius: 8; color: "white" - Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } - } - MouseArea { - anchors.fill: parent - onClicked: { - var bv = Object.assign({}, root.buttonVisible) - bv[bid] = !vp.on - root.buttonVisible = bv - root.saveConfig() - } - } - } - } - } - } - } - - // Cor - Text { text: root.t("colorTitle"); color: root.themeMuted; font.pixelSize: 9; font.bold: true; width: parent.width } - SettingsToggle { - label: root.t("themeFollow") - sublabel: root.t("themeFollowSub") - checked: root.colorMode === "theme" - accentColor: root.effAccent; fgColor: root.effFg; mutedColor: root.themeMuted - onToggled: { root.colorMode = root.colorMode === "theme" ? "custom" : "theme"; root.saveConfig() } - } - - Rectangle { width: parent.width; height: 1; color: Qt.rgba(1,1,1,0.08) } - Rectangle { - width: parent.width; height: 32; radius: 6 - color: csm.containsMouse ? Qt.rgba(1,1,1,0.08) : "transparent" - Behavior on color { ColorAnimation { duration: 80 } } - Text { anchors.centerIn: parent; text: root.t("closeSettings"); color: root.themeMuted; font.pixelSize: 12 } - MouseArea { id: csm; anchors.fill: parent; hoverEnabled: true; onClicked: settingsWin.visible = false } - } - Item { height: 4 } - } - } - - // ── Dropdown minimizadas (unclipped no overlay) ──────── - Rectangle { - id: minDropPanel - visible: root.showMinimizedPanel && root.minimizedWindows.length > 0 - x: { - try { - var pt = root.mapToItem(null, 0, 0) - return Math.max(10, Math.min((fw.width > 0 ? fw.width : Screen.width) - width - 10, pt.x)) - } catch(e) { - return 10 - } - } - y: 36 - width: 280 - height: minCol.implicitHeight + 16 - radius: 8 - z: 150 - color: Qt.rgba(0.06, 0.07, 0.10, 0.95) - border.color: Qt.rgba(1,1,1,0.10) - border.width: 1 - - Column { - id: minCol - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: 8 - spacing: 2 - - Repeater { - model: root.minimizedWindows - Rectangle { - width: minCol.width - height: 34 - radius: 6 - color: rMouse.containsMouse ? Qt.rgba(1,1,1,0.08) : "transparent" - Behavior on color { ColorAnimation { duration: 80 } } - - Rectangle { - id: ico - anchors.left: parent.left - anchors.leftMargin: 6 - anchors.verticalCenter: parent.verticalCenter - width: 22 - height: 22 - radius: 5 - color: Qt.rgba(0.47, 0.64, 0.97, 0.25) - Text { - anchors.centerIn: parent - text: (modelData.class || "?").substring(0,1).toUpperCase() - color: "#7aa2f7" - font.pixelSize: 12 - font.bold: true - } - } - - Text { - anchors.left: ico.right - anchors.leftMargin: 8 - anchors.right: xBtn.left - anchors.rightMargin: 4 - anchors.verticalCenter: parent.verticalCenter - text: modelData.title || modelData.class || root.t("untitled") - color: "white" - font.pixelSize: 12 - elide: Text.ElideRight - } - - Rectangle { - id: xBtn - anchors.right: parent.right - anchors.rightMargin: 6 - anchors.verticalCenter: parent.verticalCenter - width: 20 - height: 20 - radius: 10 - visible: rMouse.containsMouse || xMouse.containsMouse - color: xMouse.containsMouse ? Qt.rgba(0.9,0.2,0.2,0.8) : Qt.rgba(1,1,1,0.08) - Behavior on color { ColorAnimation { duration: 80 } } - Text { anchors.centerIn: parent; text: "✕"; color: "white"; font.pixelSize: 12; font.bold: true } - MouseArea { - id: xMouse - anchors.fill: parent - hoverEnabled: true - onClicked: { - var clean = root.cleanAddr(modelData.address) - if (clean) root.dispatchLua('hl.dsp.window.close({ window = "address:' + clean + '" })') - listProc.running = true - } - } - } - - MouseArea { - id: rMouse - anchors.fill: parent - hoverEnabled: true - onClicked: { - root.showMinimizedPanel = false - restoreProc.addr = modelData.address - restoreProc.running = true - } - } - } - } - } - } - } - } - - // ── Componente reutilizável SettingsToggle ──────────────── - component SettingsToggle: Item { - id: tog - property string label: "" - property string sublabel: "" - property bool checked: false - property string accentColor: "#7aa2f7" - property string fgColor: "#a9b1d6" - property string mutedColor: "#414868" - signal toggled() - - width: parent ? parent.width : 300 - height: Math.max(36, labelCol.implicitHeight + 10) - - Column { - id: labelCol - anchors.left: parent.left - anchors.leftMargin: 4 - anchors.right: togPill.left - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - Text { - text: tog.label - color: tog.fgColor - font.pixelSize: 12 - width: parent.width - wrapMode: Text.Wrap - } - Text { - visible: tog.sublabel !== "" - text: tog.sublabel - color: tog.mutedColor - font.pixelSize: 10 - width: parent.width - wrapMode: Text.Wrap - } - } - - Rectangle { - id: togPill - anchors.right: parent.right - anchors.rightMargin: 4 - anchors.verticalCenter: parent.verticalCenter - width: 36; height: 20; radius: 10 - color: tog.checked ? tog.accentColor : Qt.rgba(1,1,1,0.12) - Behavior on color { ColorAnimation { duration: 150 } } - Rectangle { - x: tog.checked ? 18 : 2; y: 2; width: 16; height: 16; radius: 8; color: "white" - Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } - } - } - - MouseArea { anchors.fill: parent; hoverEnabled: true; onClicked: tog.toggled() } - } } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..481cdc6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +## 0.2.0 + +A rewrite of the plugin's internals. Every v0.1 feature is still there, and your existing `~/.config/omarchy/mouse-first.json` keeps working. + +### Fixed +- The controls now stay attached to the window during SUPER + drag, border resizes and keyboard moves. +- Pulling a snapped or maximized window out with SUPER + drag no longer detaches it from the cursor. The window returns to its previous size when you drop it. +- Dragging a snapped or maximized window from the top strip restores it immediately, with the grabbed point staying under the cursor. +- Maximize and restore no longer race Hyprland, so the button icon always shows the real state. Windows maximized with Hyprland's own keybind are recognized. +- On multi-monitor setups the controls, snapping and dragging now work on every monitor, and windows can be dragged between monitors. Previously each monitor drew its own copy of the controls, positioned in the wrong coordinates. +- Snapping now respects the bar and dock on every side and uses your Hyprland gaps. Reserved space was previously hard-coded. +- "Disable tiling" now uses a window rule, so windows open floating instead of jumping from tiled to floating. Turning it off takes effect right away. +- Opening a special workspace, such as the Omarchy scratchpad, no longer steals focus. +- Minimized windows now use `special:minimized` and appear in omadock. Windows restored after a shell restart return to the workspace they came from, instead of workspace 1. +- The settings file is written atomically and no longer breaks on special characters. +- Memory used for closed windows is now released. + +### Added +- Snapping, snap preview and immediate un-snapping also work with SUPER + drag and app title bars. The plugin takes over drags that Hyprland starts on floating windows. +- Resizing a snapped window by its inner border resizes its snapped neighbour too. +- Bar icons group the windows of each app, with a window count; clicking cycles through them. A right-click menu lists the windows and offers *New window*, *Pin/Unpin* (shared with omadock) and *Close*. +- New options: + - drag to the top edge to maximize; + - center new windows (on or off); + - built-in snapping shortcuts (SUPER + CTRL + SHIFT + arrows, off by default); + - hide the controls for chosen apps. +- The controls are skipped on picture-in-picture players and windows too narrow for them. +- Spanish translation. +- The icon index is cached on disk and rebuilt only when an icon is missing. + +### Changed +- The plugin is now a shell service plus a small bar widget. It is event-driven: no polling and no helper scripts. The seven `bin/` scripts (Python/Bash) are gone. +- Colors come from Omarchy's theme API instead of parsing `colors.toml` every five seconds. +- Added a language selector (English/Português) to the settings panel. +- Added IPC actions for keyboard shortcuts (`snap`, `toggleMaximize`, `minimize`, `restoreLast`, `settings`, `status`). +- Removed `install.sh`. Install with `omarchy plugin add`. The plugin no longer writes to `~/.config/hypr/looknfeel.lua`. + +## 0.1.0 + +- Initial release. diff --git a/README.md b/README.md index 1658b3a..52679d1 100644 --- a/README.md +++ b/README.md @@ -1,140 +1,156 @@

Mouse-First

- Intuitive, mouse-driven window management designed for Hyprland and Omarchy Quattro. + Mouse-driven window management for Hyprland on Omarchy.

Mouse-First window controls on Omarchy

-Mouse-First brings traditional mouse-friendly desktop convenience to Hyprland's dynamic tiling and floating environment. It gives every window intuitive floating controls, an edge-drag zone for effortless repositioning and un-maximizing, full-screen and quarter-screen edge snapping with dock detection, and a system bar area that neatly hosts minimized applications. - -> Designed for users who love Hyprland's aesthetics and speed, but prefer the comfort, muscle memory, and flexibility of mouse-driven workflows. +Mouse-First adds the desktop conveniences mouse users expect to Hyprland: minimize, maximize and close buttons on the active window, dragging windows by their top edge, edge and corner snapping, and app icons on the system bar. Keyboard workflows keep working as before.

Mouse-First workflow overview

---- - ## Features -- **Floating Window Controls**: Sleek, theme-aware minimize, maximize/restore, and close buttons integrated directly with the active window. -- **Top-Edge Drag Zone**: Grab, move, and un-maximize windows naturally by clicking and dragging anywhere along the top edge. -- **Smart Edge Snapping**: Half-screen edge snapping (left and right) and quarter-screen corner snapping (top-left, top-right, bottom-left, bottom-right). -- **Dock & Monitor Detection**: Intelligently honors dock reservations (like OmaDock) and multi-monitor boundaries when tiling and snapping. -- **System Bar Minimization**: Minimized windows are neatly organized directly into the top bar with their real application icons and titles for 1-click restore. -- **Native Wayland Socket IPC**: Direct UNIX domain socket communication with Hyprland for zero input lag, smooth dragging, and high performance. -- **Customizable**: Configure button visibility, order, bar alignment, snapping behavior, and colors via an intuitive right-click context settings menu. - ---- +- **Window controls.** A small capsule at the top-right corner of the active window with minimize, maximize/restore and close buttons (plus an optional float/tile button). You can reorder the buttons, hide any of them, and drag the capsule to a different spot. +- **Top-edge dragging.** Grab any window by the strip along its top edge and move it, including onto another monitor. Double-click the strip to maximize or restore. +- **Snapping.** Drop a window against a screen edge to fill half of the screen, or into a corner for a quarter. Snapped and maximized windows respect the bar, the dock and your Hyprland gaps. +- **Snapping everywhere.** Snapping and natural un-snapping work the same whether you drag by the top strip, with SUPER + drag, or by an app's own title bar: a snapped or maximized window returns to its previous size the moment you pull it away, staying under the cursor. +- **Split-view resizing.** Resize one of two snapped windows by its inner border and the neighbour follows, so they keep sharing the screen. +- **App icons on the bar.** Pinned and open apps appear on the system bar, one icon per app with a window count. Click to launch, focus, minimize, restore or cycle through an app's windows; right-click for a menu with its windows, *New window*, *Pin/Unpin* (shared with omadock) and *Close*. Minimized windows are shared with omadock. +- **Stays out of the way.** No controls on small windows, picture-in-picture players, or apps you exclude. +- **Floating by default (optional).** Every window opens floating, like a traditional desktop, centered or where the app chooses. +- **Optional snapping shortcuts.** SUPER + CTRL + SHIFT with the arrow keys. +- **English, Português and Español.** +- **Theme-aware.** Follows the Omarchy theme, or uses your own colors. +- **Lightweight.** Event-driven with no polling, and it doesn't spawn processes while you work. ## Requirements -- **Omarchy Quattro** (or any modern Hyprland installation with Quickshell) -- **Quickshell** `0.3.x` -- **Hyprland** (with Wayland socket IPC) -- **Dependencies**: `jq`, `socat` +- Omarchy 4 with its Quickshell-based shell. +- Hyprland **0.55 or newer** with a Lua configuration (the Omarchy default). ---- +No extra packages are needed. ## Installation -### Via Official Omarchy Plugin CLI (Recommended) - -To install directly using the Omarchy plugin manager: - ```bash omarchy plugin add https://github.com/DjaboDev/Mouse-First.git --enable omarchy restart shell ``` -### Via Quick Install Script - -Alternatively, you can clone and run the provided interactive installer: - -```bash -git clone https://github.com/DjaboDev/Mouse-First.git -cd Mouse-First -bash install.sh -``` - -The script will ask where you'd like minimized applications placed in the system bar (`left`, `center`, or `right`) and automatically register the plugin. - ---- +The plugin is also listed in the [Omarchy plugin store](https://plugins.omarchy.org/plugin.html?id=io.github.mousefirst.controls). ## Usage -- **Drag to Move**: Click and hold the top edge of any floating or tiled window to drag it across workspaces or monitors. -- **Un-maximize**: Dragging a maximized window away from the top edge smoothly un-maximizes it, keeping the window attached to your cursor. -- **Edge Snapping**: Drag a window into the left or right screen border to snap it to half screen. Touch the screen corners to snap into quarters. Pulling the window away cleanly restores its original floating size. -- **Minimize to Bar**: Click the minimize button (`—`) to send the window to the system bar. Click the app icon in the bar to bring it back into focus. -- **Settings & Preferences**: Right-click the floating control capsule on the active window to open the settings panel (toggle buttons, color theme, and drag behaviors). +| Action | How | +| --- | --- | +| Move a window | Drag the strip along its top edge (or the optional ⠿ grip) | +| Maximize / restore | Maximize button, or double-click the top strip | +| Snap | Drag the window (top strip, SUPER + drag or title bar) against an edge for half the screen, or into a corner for a quarter. Optionally, the top edge maximizes | +| Un-snap / un-maximize | Drag the window away from its snapped position | +| Resize two snapped windows together | Drag the border they share | +| Minimize | Minimize button, or click the focused app's icon on the bar | +| Restore | Click the app's icon on the bar (or in omadock) | +| Cycle an app's windows | Click its bar icon repeatedly | +| App menu (windows, new window, pin, close) | Right-click its bar icon | +| Close a window from the bar | Middle-click its icon | +| Move the control capsule | Drag the thin handle under the buttons; double-click it to reset | +| Settings | Right-click the control capsule | + +### Keyboard shortcuts (optional) + +Turn on **Enable snapping shortcuts** in the settings for built-in bindings: + +| Keys | Action | +| --- | --- | +| SUPER + CTRL + SHIFT + ← / → | Snap to the left / right half | +| SUPER + CTRL + SHIFT + ↑ | Maximize or restore | +| SUPER + CTRL + SHIFT + ↓ | Restore a snapped/maximized window, otherwise minimize | + +For other keys, every action is also available over Quickshell IPC, so you can bind it yourself in `~/.config/hypr/bindings.lua`: + +```lua +local mf = "qs -p /usr/share/omarchy/shell ipc call mousefirst " +o.bind("SUPER + ALT + LEFT", "Snap left", mf .. "snap left") +o.bind("SUPER + ALT + RIGHT", "Snap right", mf .. "snap right") +o.bind("SUPER + ALT + UP", "Maximize / restore", mf .. "toggleMaximize") +o.bind("SUPER + ALT + DOWN", "Minimize", mf .. "minimize") +``` ---- +| Function | Description | +| --- | --- | +| `snap ` | `left`, `right`, `top`, `bottom`, `top-left`, `top-right`, `bottom-left`, `bottom-right`, `maximize` | +| `toggleMaximize` | Maximize or restore the active window | +| `restore` | Restore the active window from a snap or maximize | +| `minimize` / `restoreLast` | Minimize the active window / restore the most recently minimized one | +| `settings` | Open or close the settings panel | +| `status` | Print diagnostic state as JSON (useful for bug reports) | ## Configuration -Mouse-First saves your preferences to: +Settings are changed from the settings panel and saved to `~/.config/omarchy/mouse-first.json`. You can also edit the file by hand; changes apply immediately. + +| Key | Default | Description | +| --- | --- | --- | +| `language` | from `$LANG` | `en`, `pt` or `es` | +| `disableTiling` | `true` | Open every window floating | +| `centerNewWindows` | `true` | Center new floating windows (with `disableTiling`) | +| `alwaysVisible` | `true` | Keep the control capsule visible (otherwise it appears on hover) | +| `dragFullWidth` | `true` | Enable the top-edge drag strip | +| `showDragHandle` | `false` | Show a ⠿ drag grip in the capsule | +| `disableSnapping` | `false` | Turn edge snapping off | +| `topEdgeMaximizes` | `false` | Dropping on the top edge maximizes instead of snapping to the top half | +| `enableShortcuts` | `false` | Register the built-in snapping shortcuts | +| `excludedClasses` | `[]` | Window classes that never get controls | +| `minControlsWidth` | `240` | Windows narrower than this (px) get no controls | +| `ignoreDock` | `false` | Let snapped and maximized windows cover the dock area | +| `minimizeToBar` | `true` | Show app icons on the bar (when off, they appear only if no dock is running) | +| `barSection` | `left` | Bar section for the icons (`left`, `center`, `right`) | +| `enableMenuDrag` | `true` | Allow moving the control capsule | +| `rememberMenuPerWindow` / `rememberMenuGlobal` | `false` | Keep the capsule position per application, or use one position for all windows | +| `alwaysShowMenuDragZone` | `false` | Always show the capsule's move handle | +| `buttonOrder` / `buttonVisible` | | Order and visibility of `float`, `minimize`, `maximize` and `close` | +| `colorMode` | `theme` | `theme` follows Omarchy; `custom` uses `customBg`, `customFg`, `customAccent` and `customRed` | + +## How it works + +- **`Service.qml`** runs once per shell. It owns the state and draws one transparent overlay per monitor. The overlay only takes input on the controls and the drag strip. +- **`src/lua/mousefirst.lua`** is loaded into Hyprland with `hyprctl eval`. It watches the active window and the monitor layout from inside the compositor and reports changes as custom IPC events, so the shell never polls. It is reloaded automatically after `hyprctl reload`. +- **Native drags are handed over.** Two non-consuming binds track the left mouse button (Hyprland's Lua API cannot read it). When Hyprland starts moving a floating window, the module ends Hyprland's drag and the shell's drag controller continues it, so every drag shares the same snapping logic. +- A note for Lua config authors: in Hyprland 0.56.2, calling a method on an `HL.Keybind` whose bind no longer exists crashes the compositor, and `:remove()` drops every bind with the same key and modifiers. The module therefore never calls methods on keybind handles. +- **`src/js/geometry.js`** holds the snapping and placement math as pure functions, covered by the tests in `tests/` (which also check that every translation is complete). +- **`BarWidget.qml`** only renders the app icons. Omarchy creates one per monitor, and all of them share the service. + +Minimized windows live on `special:minimized`, the same workspace omadock uses, and each one's original workspace is remembered in `~/.local/state/mouse-first/`. + +## Development -```text -~/.config/omarchy/mouse-first.json -``` - -A typical configuration file: - -```json -{ - "language": "en", - "disableTiling": true, - "alwaysVisible": true, - "dragFullWidth": true, - "showDragHandle": false, - "ignoreDock": false, - "disableSnapping": false, - "enableMenuDrag": true, - "minimizeToBar": true, - "barSection": "left", - "buttonOrder": ["float", "minimize", "maximize", "close"], - "buttonVisible": { - "float": false, - "minimize": true, - "maximize": true, - "close": true - }, - "colorMode": "theme" -} +```bash +git clone https://github.com/DjaboDev/Mouse-First.git +cd Mouse-First +scripts/dev-link.sh # symlink the checkout over the installed plugin +omarchy restart shell # services reload only on a shell restart +node --test tests/*.test.mjs # geometry tests ``` ---- - -## Updating & Removal - -### Update +## Updating & removal ```bash omarchy plugin update io.github.mousefirst.controls omarchy restart shell ``` -### Removal - -To remove the plugin from your system: - ```bash omarchy plugin remove io.github.mousefirst.controls --yes omarchy restart shell +rm -rf ~/.config/omarchy/mouse-first.json ~/.local/state/mouse-first ``` -To clean up configuration files after removal: - -```bash -rm -f ~/.config/omarchy/mouse-first.json -``` - ---- - ## License -This project is licensed under the [MIT License](LICENSE). +[MIT](LICENSE) diff --git a/Service.qml b/Service.qml new file mode 100644 index 0000000..9546aca --- /dev/null +++ b/Service.qml @@ -0,0 +1,442 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import qs.Commons +import "src" +import "src/ui" +import "src/js/geometry.js" as G +import "src/js/hypr.js" as Hypr +import "src/js/i18n.js" as I18n + +// The Mouse-First service: created once by the Omarchy shell. +// +// Omarchy builds one bar (and one bar widget) per monitor, so everything +// stateful lives here and the bar widget only renders `appsModule.rows`. This +// object owns the Hyprland connection, the window state machine, the drag, +// minimize/restore, the settings, and one overlay per screen. +Item { + id: root + width: 0 + height: 0 + visible: false + + // Injected by the shell. + property var shell: null + property var manifest: null + + readonly property string pluginId: "io.github.mousefirst.controls" + readonly property var cfg: config.data + + function tr(key) { return I18n.tr(root.cfg.language, key) } + + // ── Theme ──────────────────────────────────────────────── + readonly property bool themed: root.cfg.colorMode !== "custom" + readonly property color bg: themed ? Qt.darker(Color.background, 1.4) : root.cfg.customBg + readonly property color fg: themed ? Color.foreground : root.cfg.customFg + readonly property color accent: themed ? Color.accent : root.cfg.customAccent + readonly property color red: themed ? Color.urgent : root.cfg.customRed + readonly property color muted: Color.muted + readonly property string fontFamily: Style.font.family + + // ── Bar ────────────────────────────────────────────────── + readonly property var barConfig: root.shell && root.shell.barConfig ? root.shell.barConfig : ({}) + readonly property string barPosition: String(root.barConfig.position || "top") + readonly property string barSection: { + var layout = root.barConfig.layout || {} + var sections = ["left", "center", "right"] + for (var i = 0; i < sections.length; i++) { + var list = layout[sections[i]] || [] + for (var j = 0; j < list.length; j++) + if (list[j] && list[j].id === root.pluginId) return sections[i] + } + return root.cfg.barSection + } + + function setBarSection(section) { + if (["left", "center", "right"].indexOf(section) < 0 || section === root.barSection) return + config.set("barSection", section) + Quickshell.execDetached(["omarchy-shell", "shell", "moveBarWidget", root.pluginId, + JSON.stringify({ section: section })]) + } + + // ── Dock detection ─────────────────────────────────────── + property bool dockPresent: false + readonly property bool showAppsInBar: root.cfg.minimizeToBar || !root.dockPresent + + // ── Active window ──────────────────────────────────────── + readonly property var win: trackerModule.active + readonly property bool hasWindow: !!root.win && root.win.fullscreen !== 2 + && root.win.workspace !== minimizerModule.workspaceName + // Whether the capsule and drag strip are drawn for the active window: not + // for windows narrower than the capsule's useful size, picture-in-picture + // players, or classes the user excluded. + readonly property bool controlsVisible: { + if (!root.hasWindow) return false + var w = root.win + if (w.w < root.cfg.minControlsWidth || w.h < 120) return false + if (/picture.in.picture/i.test(w.title)) return false + var cls = String(w.cls || "").toLowerCase() + var excluded = root.cfg.excludedClasses || [] + for (var i = 0; i < excluded.length; i++) + if (String(excluded[i]).toLowerCase() === cls) return false + return true + } + readonly property bool winMaximized: root.hasWindow && statesModule.isMaximized(root.win) + // Rect the controls follow: the drag's prediction while dragging, so they + // never trail the pointer, otherwise the compositor's report. + readonly property var winRect: { + if (!root.hasWindow) return null + if (dragModule.active && dragModule.rect && dragModule.address === root.win.address) return dragModule.rect + return G.rect(root.win.x, root.win.y, root.win.w, root.win.h) + } + readonly property string winMonitor: { + if (!root.hasWindow) return "" + if (dragModule.active && dragModule.rect) { + var m = G.monitorAt(trackerModule.monitors, dragModule.rect.x + dragModule.rect.w / 2, dragModule.rect.y + 8) + if (m) return m.name + } + return root.win.monitor + } + + // ── Window drag (from the top-edge zone or the grip) ───── + // The screen whose overlay holds the pointer grab for the current drag. + property string dragScreen: "" + + function pressWindow(screenName, point) { + if (!root.hasWindow) return + root.dragScreen = screenName + dragModule.press(point.x, point.y) + } + function moveWindow(point) { dragModule.move(point.x, point.y) } + function releaseWindow() { + dragModule.release() + root.dragScreen = "" + } + + // ── Settings panel ─────────────────────────────────────── + property bool settingsOpen: false + property string settingsMonitor: "" + function toggleSettings() { + root.settingsMonitor = root.winMonitor || (trackerModule.monitors[0] ? trackerModule.monitors[0].name : "") + root.settingsOpen = !root.settingsOpen + } + + // ── Control bar offset (the draggable position of the buttons) ── + property var sessionOffsets: ({}) + property bool menuDragging: false + property var liveMenuOffset: null + readonly property var menuOffset: { + if (root.liveMenuOffset) return root.liveMenuOffset + if (!root.cfg.enableMenuDrag || !root.hasWindow) return { x: 0, y: 0 } + if (root.cfg.rememberMenuGlobal) return { x: root.cfg.globalMenuOffsetX, y: root.cfg.globalMenuOffsetY } + if (root.cfg.rememberMenuPerWindow) { + var o = root.cfg.perWindowClassOffsets[root.win.cls] + return o ? { x: o.x || 0, y: o.y || 0 } : { x: 0, y: 0 } + } + return root.sessionOffsets[root.win.address] || { x: 0, y: 0 } + } + + function setMenuOffset(offset) { + root.liveMenuOffset = { x: Math.round(offset.x), y: Math.round(offset.y) } + } + + function commitMenuOffset() { + var o = root.liveMenuOffset + root.liveMenuOffset = null + if (!o || !root.hasWindow) return + root._storeMenuOffset(o) + } + + function resetMenuOffset() { + if (!root.hasWindow) return + root._storeMenuOffset(null) + } + + function _storeMenuOffset(o) { + if (root.cfg.rememberMenuGlobal) { + config.set("globalMenuOffsetX", o ? o.x : 0) + config.set("globalMenuOffsetY", o ? o.y : 0) + } else if (root.cfg.rememberMenuPerWindow) { + if (!root.win.cls) return + var map = Object.assign({}, root.cfg.perWindowClassOffsets) + if (o) map[root.win.cls] = o + else delete map[root.win.cls] + config.set("perWindowClassOffsets", map) + } else { + var s = Object.assign({}, root.sessionOffsets) + if (o) s[root.win.address] = o + else delete s[root.win.address] + root.sessionOffsets = s + } + } + + // ── Actions ────────────────────────────────────────────── + + function trigger(id) { + if (!root.hasWindow) return + var w = root.win + if (id === "float") trackerModule.dispatch(Hypr.toggleFloating(w.address)) + else if (id === "minimize") root.minimize(w.address) + else if (id === "maximize") statesModule.toggleMaximize(w) + else if (id === "close") trackerModule.dispatch(Hypr.close(w.address)) + } + + function minimize(address) { + var origin = "" + if (root.win && root.win.address === Hypr.normalizeAddress(address)) origin = root.win.workspace + else origin = root.workspaceOf(address) + minimizerModule.minimize(address, origin) + } + + function workspaceOf(address) { + var a = Hypr.normalizeAddress(address) + var ts = Hyprland.toplevels.values + for (var i = 0; i < ts.length; i++) + if (Hypr.normalizeAddress(ts[i].address) === a) + return ts[i].workspace ? String(ts[i].workspace.name || "") : "" + return "" + } + + function restore(address) { minimizerModule.restore(address) } + + function focus(address) { + trackerModule.dispatch([Hypr.focus(address), Hypr.bringToTop(address)]) + } + + function close(address) { trackerModule.dispatch(Hypr.close(address)) } + + function launch(appId) { appsModule.launch(appId) } + + // Bar tile click, like a taskbar: launch when nothing is open; with one + // window, toggle it (minimize when focused, bring back otherwise); with + // several, cycle through them, starting from the most recently used. + function activateRow(row) { + var wins = row.windows || [] + if (wins.length === 0) { root.launch(row.appId); return } + var active = -1 + for (var i = 0; i < wins.length; i++) if (wins[i].state === "active") active = i + var target + if (active >= 0) { + if (wins.length === 1) { root.minimize(wins[0].address); return } + target = wins[(active + 1) % wins.length] + } else { + target = wins.slice().sort((a, b) => { + var ra = a.state === "minimized" ? 1 : 0, rb = b.state === "minimized" ? 1 : 0 + return ra !== rb ? ra - rb : a.focus - b.focus + })[0] + } + root.activateWindow(target) + } + + function activateWindow(w) { + if (w.state === "minimized") root.restore(w.address) + else root.focus(w.address) + } + + function closeRow(row) { + var wins = row.windows || [] + for (var i = 0; i < wins.length; i++) root.close(wins[i].address) + } + + function setPinned(appId, pinned) { appsModule.setPinned(appId, pinned) } + + function moveButton(index, delta) { + var order = root.cfg.buttonOrder.slice() + var to = index + delta + if (to < 0 || to >= order.length) return + var tmp = order[index]; order[index] = order[to]; order[to] = tmp + config.set("buttonOrder", order) + } + + function setExcluded(cls, excluded) { + if (!cls) return + var list = (root.cfg.excludedClasses || []).filter(c => c !== cls) + if (excluded) list.push(cls) + config.set("excludedClasses", list) + if (excluded) root.settingsOpen = false // its capsule is about to disappear + } + + function setButtonVisible(id, visible) { + var v = Object.assign({}, root.cfg.buttonVisible) + v[id] = visible + config.set("buttonVisible", v) + } + + // Settings that live inside Hyprland (window rule, shortcuts) are pushed + // on load, on change, and again after a Hyprland config reload. + function applyCompositorSettings() { + if (!config.ready) return + trackerModule.evalLua(Hypr.evalFloatRule(root.cfg.disableTiling, root.cfg.centerNewWindows)) + trackerModule.evalLua(Hypr.evalShortcuts(root.cfg.enableShortcuts)) + } + + function runShortcut(action) { + if (!root.hasWindow) return + var w = root.win + if (action === "left" || action === "right") statesModule.snap(w, action, statesModule.restoreGeometryFor(w)) + else if (action === "up") statesModule.toggleMaximize(w) + else if (action === "down") { + if (statesModule.isManaged(w)) statesModule.restore(w) + else root.minimize(w.address) + } + } + + // ── Modules ────────────────────────────────────────────── + + Config { id: config; onReadyChanged: root.applyCompositorSettings() } + + WindowTracker { + id: trackerModule + onConfigReloaded: root.applyCompositorSettings() + } + + WindowStates { + id: statesModule + tracker: trackerModule + gap: Math.max(0, Style.gapsOut) + ignoreDock: root.cfg.ignoreDock + dockPresent: root.dockPresent + barPosition: root.barPosition + barThickness: Style.bar.sizeHorizontal + } + + DragController { + id: dragModule + tracker: trackerModule + states: statesModule + snappingEnabled: !root.cfg.disableSnapping + topMaximizes: root.cfg.topEdgeMaximizes + } + + Minimizer { id: minimizerModule; tracker: trackerModule } + + AppsModel { + id: appsModule + minimizedWorkspace: minimizerModule.workspaceName + onMinimizedChanged: addresses => minimizerModule.prune(addresses) + } + + readonly property alias cfgFile: config + readonly property alias tracker: trackerModule + readonly property alias states: statesModule + readonly property alias drag: dragModule + readonly property alias apps: appsModule + + Connections { + target: root.cfg + function onDisableTilingChanged() { root.applyCompositorSettings() } + function onCenterNewWindowsChanged() { root.applyCompositorSettings() } + function onEnableShortcutsChanged() { root.applyCompositorSettings() } + } + + // Native moves handed over by the Lua module (see DragController). + Connections { + target: trackerModule + function onNativeDragStarted(address, x, y, rect) { + if (dragModule.pressed) return // our own drag is moving it + var w = trackerModule.active + var win = w && w.address === address + ? Object.assign({}, w, rect) + : Object.assign({ address: address, floating: true, fullscreen: 0, monitor: "", workspace: "" }, rect) + dragModule.takeOver(win, x, y) + } + function onShortcut(action) { root.runShortcut(action) } + function onCursorMoved(x, y) { + if (dragModule.handedOver) dragModule.move(x, y) + } + function onButtonReleased(x, y) { + if (!dragModule.handedOver) return + dragModule.move(x, y) + dragModule.release() + } + } + + Connections { + target: trackerModule + function onWindowClosed(address) { + if (root.sessionOffsets[address]) { + var s = Object.assign({}, root.sessionOffsets) + delete s[address] + root.sessionOffsets = s + } + } + } + + // Dock detection: one layer query at start, then re-checked whenever a + // layer surface opens or closes. + Process { + id: layersProc + command: ["hyprctl", "layers", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { + var found = false + var outputs = JSON.parse(text) + for (var o in outputs) + for (var lvl in outputs[o].levels) + for (var i = 0; i < outputs[o].levels[lvl].length; i++) + if (/dock|plank|wbar/i.test(outputs[o].levels[lvl][i].namespace || "")) found = true + root.dockPresent = found + } catch (e) {} + } + } + } + Timer { + id: layersDebounce + interval: 300 + onTriggered: if (!layersProc.running) layersProc.running = true + } + Connections { + target: Hyprland + function onRawEvent(event) { + if (event.name === "openlayer" || event.name === "closelayer") layersDebounce.restart() + } + } + + // One overlay per screen, recreated automatically on hotplug. + Variants { + model: Quickshell.screens + delegate: Overlay { + required property var modelData + screen: modelData + service: root + } + } + + // `qs -p /usr/share/omarchy/shell ipc call mousefirst ` + // e.g. o.bind("SUPER + ALT + LEFT", "Snap left", "qs -p /usr/share/omarchy/shell ipc call mousefirst snap left") + IpcHandler { + target: "mousefirst" + + function snap(zone: string): void { + if (root.hasWindow) statesModule.snap(root.win, zone, statesModule.restoreGeometryFor(root.win)) + } + function toggleMaximize(): void { if (root.hasWindow) statesModule.toggleMaximize(root.win) } + function restore(): void { if (root.hasWindow) statesModule.restore(root.win) } + function minimize(): void { if (root.hasWindow) root.minimize(root.win.address) } + function restoreLast(): void { + var rows = appsModule.rows + for (var i = rows.length - 1; i >= 0; i--) + if (rows[i].state === "minimized") { root.restore(rows[i].address); return } + } + function settings(): void { root.toggleSettings() } + // Diagnostics for bug reports. + function status(): string { + return JSON.stringify({ + version: root.manifest ? root.manifest.version : "", + active: trackerModule.active, + monitors: trackerModule.monitors, + buttonKnown: trackerModule.buttonKnown, + dockPresent: root.dockPresent, + states: statesModule.entries + }) + } + } + + Component.onCompleted: { + trackerModule.install() + root.applyCompositorSettings() + layersProc.running = true + } +} diff --git a/assets/01.png b/assets/01.png deleted file mode 100644 index 1660a0b..0000000 Binary files a/assets/01.png and /dev/null differ diff --git a/bin/mf-apps b/bin/mf-apps deleted file mode 100755 index 9588df0..0000000 --- a/bin/mf-apps +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env python3 -# mf-apps: retorna lista consolidada de aplicativos e janelas para a barra do sistema -import sys, json, os, glob, subprocess - -# Constrói índice de ícones do sistema com persistência em disco para inicialização instantânea -cache_dir = os.path.expanduser("~/.cache/omarchy") -cache_file = os.path.join(cache_dir, "mf-icon-cache.json") -icon_cache = {} - -if os.path.exists(cache_file): - try: - # Revalida se o cache tem menos de 24h - if os.path.getmtime(cache_file) > os.path.getmtime("/usr/share/applications"): - with open(cache_file, "r", encoding="utf-8") as f: - icon_cache = json.load(f) - except Exception: - icon_cache = {} - -if not icon_cache: - icon_dirs = [ - os.path.expanduser("~/.local/share/icons"), - os.path.expanduser("~/.icons"), - "/usr/local/share/icons", - "/usr/share/icons/hicolor", - "/usr/share/icons", - "/usr/share/pixmaps" - ] - - for d in icon_dirs: - if not os.path.isdir(d): - continue - for root_dir, _, files in os.walk(d): - for f in files: - if f.endswith(".png") or f.endswith(".svg"): - stem = os.path.splitext(f)[0] - if stem not in icon_cache or f.endswith(".svg"): - path = os.path.join(root_dir, f) - icon_cache[stem] = path - icon_cache[stem.lower()] = path - - try: - os.makedirs(cache_dir, exist_ok=True) - with open(cache_file, "w", encoding="utf-8") as f: - json.dump(icon_cache, f) - except Exception: - pass - -def resolve_icon(icon_name): - if not icon_name: - return "" - if icon_name.startswith("file://"): - return icon_name - if icon_name.startswith("/"): - return f"file://{icon_name}" - if icon_name in icon_cache: - return f"file://{icon_cache[icon_name]}" - if icon_name.lower() in icon_cache: - return f"file://{icon_cache[icon_name.lower()]}" - for ext in ["svg", "png", "xpm"]: - pix = f"/usr/share/pixmaps/{icon_name}.{ext}" - if os.path.exists(pix): - return f"file://{pix}" - fallback = icon_cache.get("application-x-executable") or icon_cache.get("application-default-icon") - return f"file://{fallback}" if fallback else "" - -dock_cfg = os.path.expanduser("~/.config/omarchy/dock.json") -pinned_ids = [] -if os.path.exists(dock_cfg): - try: - with open(dock_cfg) as f: - pinned_ids = json.load(f).get("pinned", []) - except Exception: - pass - -# Cache de aplicativos desktop -desktop_apps = {} -desktop_dirs = [ - os.path.expanduser("~/.local/share/applications"), - "/usr/local/share/applications", - "/usr/share/applications" -] - -for d in desktop_dirs: - if not os.path.exists(d): - continue - for p in glob.glob(os.path.join(d, "*.desktop")): - base = os.path.basename(p) - app_key = base[:-8] if base.endswith(".desktop") else base - if app_key in desktop_apps: - continue - try: - name = app_key - icon = app_key - wmclass = "" - with open(p, "r", encoding="utf-8", errors="ignore") as f: - for line in f: - line = line.strip() - if line.startswith("Name=") and name == app_key: - name = line[5:] - elif line.startswith("Icon=") and icon == app_key: - icon = line[5:] - elif line.startswith("StartupWMClass=") and not wmclass: - wmclass = line[16:] - info = {"name": name, "icon": resolve_icon(icon), "appId": app_key} - desktop_apps[app_key] = info - desktop_apps[app_key.lower()] = info - if wmclass: - desktop_apps[wmclass] = info - desktop_apps[wmclass.lower()] = info - except Exception: - pass - -clients = [] -try: - clients = json.loads(subprocess.check_output(["hyprctl", "clients", "-j"], stderr=subprocess.DEVNULL)) -except Exception: - pass - -result = [] -matched_addrs = set() - -# 1. Processa aplicativos fixados -for pid in pinned_ids: - info = desktop_apps.get(pid) or desktop_apps.get(pid.lower()) or { - "name": pid, - "icon": resolve_icon(pid), - "appId": pid - } - - # Busca janelas correspondentes a este app fixado - app_clients = [] - for c in clients: - c_cls = c.get("class", "") - c_init = c.get("initialClass", "") - c_title = c.get("title", "") - if (pid == c_cls or pid.lower() == c_cls.lower() or - pid == c_init or pid.lower() == c_init.lower() or - (pid.lower() in c_title.lower() and c_cls.lower() not in ("foot", "org.omarchy.screensaver"))): - app_clients.append(c) - matched_addrs.add(c.get("address")) - - if not app_clients: - result.append({ - "appId": pid, - "name": info["name"], - "icon": info["icon"], - "title": "", - "state": "closed", - "address": "", - "pinned": True - }) - else: - # Prioridade de seleção: ativa > em execução > minimizada - active_c = next((c for c in app_clients if c.get("focusHistoryID") == 0), None) - running_c = next((c for c in app_clients if not c.get("workspace", {}).get("name", "").startswith("special:")), None) - min_c = next((c for c in app_clients if c.get("workspace", {}).get("name", "").startswith("special:")), None) - - c_chosen = active_c or running_c or min_c or app_clients[0] - ws_name = c_chosen.get("workspace", {}).get("name", "") - - if ws_name.startswith("special:"): - state = "minimized" - elif c_chosen.get("focusHistoryID") == 0: - state = "active" - else: - state = "running" - - result.append({ - "appId": pid, - "name": info["name"], - "icon": info["icon"], - "title": c_chosen.get("title", ""), - "state": state, - "address": c_chosen.get("address", ""), - "pinned": True - }) - -# 2. Processa janelas abertas que não estão na lista de fixados -for c in clients: - addr = c.get("address") - if addr in matched_addrs: - continue - c_cls = c.get("class", "") - c_init = c.get("initialClass", "") - info = (desktop_apps.get(c_cls) or desktop_apps.get(c_cls.lower()) or - desktop_apps.get(c_init) or desktop_apps.get(c_init.lower()) or - {"name": c_cls or "App", "icon": resolve_icon(c_cls or "application-x-executable"), "appId": c_cls}) - - ws_name = c.get("workspace", {}).get("name", "") - if ws_name.startswith("special:"): - state = "minimized" - elif c.get("focusHistoryID") == 0: - state = "active" - else: - state = "running" - - result.append({ - "appId": info.get("appId", c_cls), - "name": info["name"], - "icon": info["icon"], - "title": c.get("title", ""), - "state": state, - "address": addr, - "pinned": False - }) - -print(json.dumps(result)) diff --git a/bin/mf-dock-check b/bin/mf-dock-check deleted file mode 100755 index 7e11c87..0000000 --- a/bin/mf-dock-check +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# mf-dock-check: detecta genericamente a presença de qualquer dock no sistema -# Retorna 1 se uma dock estiver ativa/presente, 0 caso contrário. - -python3 -c " -import sys, json, os, subprocess - -# 1. Checa margem inferior reservada nos monitores (Hyprland reserved[3] > 0) -try: - mons = json.loads(subprocess.check_output(['hyprctl', 'monitors', '-j'], stderr=subprocess.DEVNULL)) - if any(m.get('reserved', [0, 0, 0, 0])[3] > 0 for m in mons): - print(1) - sys.exit(0) -except Exception: - pass - -# 2. Checa camadas Wayland (Layer Shell) com namespace de dock -try: - layers = json.loads(subprocess.check_output(['hyprctl', 'layers', '-j'], stderr=subprocess.DEVNULL)) - for scr, data in layers.items(): - for lvl, surfs in data.get('levels', {}).items(): - for s in surfs: - ns = s.get('namespace', '').lower() - if 'dock' in ns or 'wbar' in ns or 'plank' in ns: - print(1) - sys.exit(0) -except Exception: - pass - -# 3. Checa processos de dock conhecidos em execução -try: - ret = subprocess.run( - ['pgrep', '-f', '(omadock|nwg-dock|plank|latte-dock|wbar|dockx|tint2)'], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - if ret.returncode == 0: - print(1) - sys.exit(0) -except Exception: - pass - -print(0) -" 2>/dev/null || echo "0" diff --git a/bin/mf-list b/bin/mf-list deleted file mode 100755 index 017faa1..0000000 --- a/bin/mf-list +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -# mf-list: retorna JSON com janelas minimizadas -set -euo pipefail - -STATE="${XDG_CACHE_HOME:-$HOME/.cache}/mf-minimized" - -hyprctl clients -j 2>/dev/null | python3 -c " -import sys, json, os - -state_file = os.path.expanduser('${STATE}') -order = [] -try: - with open(state_file) as f: - for line in f: - parts = line.strip().split() - if parts: - order.append(parts[0]) -except FileNotFoundError: - pass - -clients = json.load(sys.stdin) -minimized = [ - c for c in clients - if c.get('workspace', {}).get('name', '') in ('special:minimized', 'special:mf-minimized') -] - -addr_index = {addr: i for i, addr in enumerate(order)} -minimized.sort(key=lambda c: addr_index.get(c['address'], 9999)) - -result = [ - { - 'address': c['address'], - 'title': c.get('title', '')[:60], - 'class': c.get('class', ''), - 'workspace': c.get('workspace', {}).get('id', 1) - } - for c in minimized -] -print(json.dumps(result)) -" 2>/dev/null || echo "[]" diff --git a/bin/mf-maximize b/bin/mf-maximize deleted file mode 100755 index 90c8e99..0000000 --- a/bin/mf-maximize +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# mf-maximize: alterna maximizado (área útil) / tamanho anterior -set -euo pipefail - -MAX_STATE="${XDG_CACHE_HOME:-$HOME/.cache}/mf-maximized" -SIZE_STATE="${XDG_CACHE_HOME:-$HOME/.cache}/mf-prev-size" -LOCK="${MAX_STATE}.lock" - -FOCUSED=$(hyprctl activewindow -j 2>/dev/null) || exit 0 -eval "$(printf '%s' "$FOCUSED" | python3 -c " -import sys,json -d=json.load(sys.stdin) -addr=d.get('address','') -is_float=1 if d.get('floating') else 0 -at=d.get('at',[0,0]) -sz=d.get('size',[1200,750]) -print(f'ADDR={addr}') -print(f'IS_FLOAT={is_float}') -print(f'WIN_X={at[0]}') -print(f'WIN_Y={at[1]}') -print(f'WIN_W={sz[0]}') -print(f'WIN_H={sz[1]}') -" 2>/dev/null)" || exit 0 - -[[ -z "$ADDR" || "$ADDR" == "null" ]] && exit 0 - -( - flock -w 2 200 || exit 1 - - IS_MAXED="" - [[ -f "$MAX_STATE" ]] && IS_MAXED=$(grep "^$ADDR$" "$MAX_STATE" 2>/dev/null || true) - - if [[ -n "$IS_MAXED" ]]; then - # Restaura tamanho anterior - [[ "$IS_FLOAT" != "1" ]] && hyprctl dispatch togglefloating "address:$ADDR" 2>/dev/null || true - sleep 0.05 - - PREV=$(grep "^$ADDR " "$SIZE_STATE" 2>/dev/null | head -1) || PREV="" - if [[ -n "$PREV" ]]; then - read -r _ PX PY PW PH <<< "$PREV" - hyprctl dispatch movewindowpixel "exact $PX $PY,address:$ADDR" 2>/dev/null || true - sleep 0.03 - hyprctl dispatch resizewindowpixel "exact $PW $PH,address:$ADDR" 2>/dev/null || true - grep -v "^$ADDR " "$SIZE_STATE" > "${SIZE_STATE}.tmp" 2>/dev/null || true - mv "${SIZE_STATE}.tmp" "$SIZE_STATE" - else - hyprctl dispatch resizewindowpixel "exact 1200 750,address:$ADDR" 2>/dev/null || true - sleep 0.03 - hyprctl dispatch centerwindow "address:$ADDR" 2>/dev/null || true - fi - - grep -v "^$ADDR$" "$MAX_STATE" > "${MAX_STATE}.tmp" 2>/dev/null || true - mv "${MAX_STATE}.tmp" "$MAX_STATE" - else - # Salva posição atual e maximiza - { - grep -v "^$ADDR " "$SIZE_STATE" 2>/dev/null || true - echo "$ADDR $WIN_X $WIN_Y $WIN_W $WIN_H" - } > "${SIZE_STATE}.tmp" - mv "${SIZE_STATE}.tmp" "$SIZE_STATE" - - [[ "$IS_FLOAT" != "1" ]] && hyprctl dispatch togglefloating "address:$ADDR" 2>/dev/null || true - sleep 0.05 - - # Descobre área útil do monitor (excluindo a barra) - MON_JSON=$(hyprctl monitors -j 2>/dev/null) || MON_JSON="[]" - eval "$(printf '%s' "$MON_JSON" | python3 -c " -import sys,json -ms=json.load(sys.stdin) -m=next((x for x in ms if x.get('focused')),ms[0] if ms else {}) -res=m.get('reserved',[0,0,0,0]) -x=int(m.get('x',0)+res[3]) -y=int(m.get('y',0)+res[0]) -w=int(m.get('width',1920)-res[3]-res[1]) -h=int(m.get('height',1080)-res[0]-res[2]) -print(f'MON_X={x} MON_Y={y} MON_W={w} MON_H={h}') -" 2>/dev/null)" || { MON_X=0; MON_Y=0; MON_W=1920; MON_H=1080; } - - hyprctl dispatch movewindowpixel "exact $MON_X $MON_Y,address:$ADDR" 2>/dev/null || true - sleep 0.03 - hyprctl dispatch resizewindowpixel "exact $MON_W $MON_H,address:$ADDR" 2>/dev/null || true - echo "$ADDR" >> "$MAX_STATE" - fi -) 200>"$LOCK" diff --git a/bin/mf-minimize b/bin/mf-minimize deleted file mode 100755 index 873e4cf..0000000 --- a/bin/mf-minimize +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# mf-minimize [address] [target_ws]: envia janela para special:minimized ou special:mf-minimized -set -euo pipefail - -STATE="${XDG_CACHE_HOME:-$HOME/.cache}/mf-minimized" -LOCK="${STATE}.lock" -TARGET="${1:-}" -TARGET_WS="${2:-special:minimized}" - -if [[ -n "$TARGET" ]]; then - eval "$(hyprctl clients -j 2>/dev/null | python3 -c " -import sys,json -cs = json.load(sys.stdin) -target = '$TARGET'.lower() -found = False -for c in cs: - if c.get('address','').lower() == target: - print(f'ADDR={c.get(\"address\")}') - print(f'WS={c.get(\"workspace\",{}).get(\"id\",1)}') - found = True - break -if not found: - print(f'ADDR={target}') - print('WS=1') -" 2>/dev/null)" || exit 0 -else - FOCUSED=$(hyprctl activewindow -j 2>/dev/null) || exit 0 - eval "$(printf '%s' "$FOCUSED" | python3 -c " -import sys,json -d=json.load(sys.stdin) -addr=d.get('address','') -ws=d.get('workspace',{}).get('id',1) -print(f'ADDR={addr}') -print(f'WS={ws}') -" 2>/dev/null)" || exit 0 -fi - -[[ -z "$ADDR" || "$ADDR" == "null" ]] && exit 0 - -( - flock -w 2 200 || exit 1 - TMP=$(mktemp) - echo "$ADDR $WS" > "$TMP" - [[ -f "$STATE" ]] && grep -v "^$ADDR " "$STATE" >> "$TMP" || true - head -50 "$TMP" > "$STATE" - rm -f "$TMP" -) 200>"$LOCK" - -hyprctl dispatch "hl.dsp.window.move({ workspace = '$TARGET_WS', window = 'address:$ADDR', follow = false })" >/dev/null 2>&1 || hyprctl dispatch -- movetoworkspacesilent "$TARGET_WS,address:$ADDR" >/dev/null 2>&1 || true - -# Refocus topmost remaining window on active workspace so focus is not orphaned on special workspace -TOP=$(hyprctl clients -j 2>/dev/null | python3 -c " -import sys, json -try: - cs = json.load(sys.stdin) - vis = [c for c in cs if c.get('workspace',{}).get('id') == $WS and c.get('mapped') and not c.get('hidden') and c.get('address') != '$ADDR'] - if vis: - vis.sort(key=lambda x: x.get('focusHistoryID', 999)) - print(vis[0].get('address', '')) -except Exception: - pass -" 2>/dev/null) || TOP="" - -if [[ -n "$TOP" ]]; then - hyprctl dispatch "hl.dsp.focus({ window = 'address:$TOP' })" >/dev/null 2>&1 || true -fi diff --git a/bin/mf-restore b/bin/mf-restore deleted file mode 100755 index 1242f38..0000000 --- a/bin/mf-restore +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# mf-restore [address]: restaura janela do special workspace -set -euo pipefail - -STATE="${XDG_CACHE_HOME:-$HOME/.cache}/mf-minimized" -LOCK="${STATE}.lock" -TARGET="${1:-}" - -( - flock -w 2 200 || exit 1 - - REAL=$(hyprctl clients -j 2>/dev/null | python3 -c " -import sys,json -cs=json.load(sys.stdin) -print('\n'.join(c['address'] for c in cs - if c.get('workspace',{}).get('name','') in ('special:minimized', 'special:mf-minimized'))) -" 2>/dev/null) || REAL="" - - if [[ -z "$TARGET" ]]; then - if [[ -f "$STATE" ]]; then - while IFS=' ' read -r addr ws; do - if printf '%s\n' "$REAL" | grep -qF "$addr"; then - TARGET="$addr"; TWS="$ws"; break - fi - done < "$STATE" - fi - if [[ -z "$TARGET" ]]; then - TARGET=$(echo "$REAL" | head -1) - TWS="1" - fi - else - TWS=$(grep "^$TARGET " "$STATE" 2>/dev/null | awk '{print $2}' | head -1) - TWS="${TWS:-1}" - fi - - [[ -z "$TARGET" ]] && exit 0 - - if [[ -f "$STATE" ]]; then - grep -v "^$TARGET " "$STATE" > "${STATE}.tmp" 2>/dev/null || true - mv "${STATE}.tmp" "$STATE" - fi - - hyprctl dispatch "hl.dsp.window.move({ workspace = '$TWS', window = 'address:$TARGET', follow = true })" >/dev/null 2>&1 || hyprctl dispatch -- movetoworkspace "$TWS,address:$TARGET" >/dev/null 2>&1 || true - sleep 0.05 - hyprctl dispatch "hl.dsp.window.bring_to_top({ window = 'address:$TARGET' })" >/dev/null 2>&1 || true - hyprctl dispatch "hl.dsp.focus({ window = 'address:$TARGET' })" >/dev/null 2>&1 || hyprctl dispatch -- focuswindow "address:$TARGET" >/dev/null 2>&1 || true -) 200>"$LOCK" diff --git a/bin/mf-theme b/bin/mf-theme deleted file mode 100755 index 48bcc68..0000000 --- a/bin/mf-theme +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -# mf-theme: lê colors.toml do tema ativo e retorna JSON com as cores relevantes -# Saída: {"background":"#1a1b26","foreground":"#a9b1d6","accent":"#7aa2f7", -# "muted":"#414868","mode":"dark"} - -set -euo pipefail - -THEME_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/current/theme" -COLORS_FILE="$THEME_DIR/colors.toml" - -if [[ ! -f "$COLORS_FILE" ]]; then - # Fallback: retorna cores escuras padrão se o tema não for encontrado - echo '{"background":"#1a1b26","foreground":"#a9b1d6","accent":"#7aa2f7","muted":"#414868","darker_background":"#0e0e14","lighter_background":"#24283b","mode":"dark"}' - exit 0 -fi - -python3 - "$COLORS_FILE" << 'PYEOF' -import sys, json, re - -path = sys.argv[1] -keys = {} -with open(path) as f: - for line in f: - line = line.strip() - if not line or line.startswith('#'): - continue - m = re.match(r'^(\w+)\s*=\s*"([^"]*)"', line) - if not m: - m = re.match(r'^(\w+)\s*=\s*([^\s#]+)', line) - if m: - keys[m.group(1)] = m.group(2).strip('"') - -result = { - 'background': keys.get('background', keys.get('bg', '#1a1b26')), - 'darker_background': keys.get('darker_background', keys.get('darker_bg', '#0e0e14')), - 'lighter_background':keys.get('lighter_background',keys.get('lighter_bg', '#24283b')), - 'foreground': keys.get('foreground', keys.get('fg', '#a9b1d6')), - 'bright_foreground': keys.get('bright_foreground', keys.get('bright_fg', '#c0caf5')), - 'accent': keys.get('accent', keys.get('color4', '#7aa2f7')), - 'muted': keys.get('muted', keys.get('color8', '#414868')), - 'red': keys.get('red', keys.get('color1', '#f7768e')), - 'mode': keys.get('mode', 'dark'), -} -print(json.dumps(result)) -PYEOF diff --git a/install.sh b/install.sh deleted file mode 100755 index c7dd288..0000000 --- a/install.sh +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env bash -# install.sh — Mouse First para Omarchy Quattro -# Cole e execute: bash install.sh - -set -euo pipefail - -PLUGIN_ID="io.github.mousefirst.controls" -PLUGIN_DIR="$HOME/.config/omarchy/plugins/$PLUGIN_ID" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -echo "==> Instalando Mouse First para Omarchy Quattro..." - -# 1. Copia arquivos -mkdir -p "$PLUGIN_DIR/bin" -cp "$SCRIPT_DIR/manifest.json" "$PLUGIN_DIR/" -cp "$SCRIPT_DIR/BarWidget.qml" "$PLUGIN_DIR/" -cp "$SCRIPT_DIR/bin/"* "$PLUGIN_DIR/bin/" -chmod +x "$PLUGIN_DIR/bin/"* -echo " Arquivos copiados para $PLUGIN_DIR" - -# 2. Posição na barra superior (interativo se terminal interativo) -BAR_SECTION="left" -if [[ -t 0 ]]; then - options=("Left (padrão)" "Center" "Right") - sections=("left" "center" "right") - selected=0 - - # Oculta o cursor durante a seleção interativa - tput civis 2>/dev/null || true - trap 'tput cnorm 2>/dev/null || true' EXIT INT TERM - - echo "" - echo "==> Onde você deseja posicionar os ícones de aplicativos na barra do sistema?" - echo " (Use as setas ↑ / ↓ para navegar e Enter para confirmar)" - echo "" - - render_menu() { - for i in "${!options[@]}"; do - if [[ $i -eq $selected ]]; then - printf " \e[1;36m> [●] %s\e[0m\n" "${options[$i]}" - else - printf " [ ] %s\n" "${options[$i]}" - fi - done - } - - render_menu - - while true; do - IFS= read -rsn1 key - if [[ "$key" == $'\x1b' ]]; then - read -rsn2 -t 1 key2 || true - case "$key2" in - '[A') # Seta para cima - selected=$(( (selected - 1 + 3) % 3 )) - ;; - '[B') # Seta para baixo - selected=$(( (selected + 1) % 3 )) - ;; - esac - printf "\e[3A" - render_menu - elif [[ "$key" == "" ]]; then - # Enter - break - elif [[ "$key" == "1" ]]; then - selected=0 - break - elif [[ "$key" == "2" ]]; then - selected=1 - break - elif [[ "$key" == "3" ]]; then - selected=2 - break - fi - done - - tput cnorm 2>/dev/null || true - trap - EXIT INT TERM - BAR_SECTION="${sections[$selected]}" - echo "" -fi -echo " Posição selecionada na barra: $BAR_SECTION" - -# 3. Configuração padrão (preto, segue tema) -CFG="$HOME/.config/omarchy/mouse-first.json" -if [[ ! -f "$CFG" ]]; then - cat > "$CFG" << CFGEOF -{ - "language": "en", - "disableTiling": true, - "alwaysVisible": true, - "dragFullWidth": true, - "showDragHandle": false, - "ignoreDock": false, - "disableSnapping": false, - "enableMenuDrag": true, - "rememberMenuPerWindow": false, - "rememberMenuGlobal": false, - "alwaysShowMenuDragZone": false, - "minimizeToBar": true, - "barSection": "$BAR_SECTION", - "globalMenuOffsetX": 0, - "globalMenuOffsetY": 0, - "perWindowClassOffsets": {}, - "buttonOrder": ["float", "minimize", "maximize", "close"], - "buttonVisible": { - "float": false, - "minimize": true, - "maximize": true, - "close": true - }, - "colorMode": "theme", - "customBg": "#0e0e14", - "customFg": "#a9b1d6", - "customAccent": "#7aa2f7", - "customRed": "#f7768e" -} -CFGEOF - echo " Configuração padrão criada em $CFG" -else - # Atualiza barSection se a configuração já existir - if command -v jq &>/dev/null; then - tmp_cfg="\$(mktemp)" - jq --arg sec "$BAR_SECTION" '.barSection = \$sec' "$CFG" > "\$tmp_cfg" && mv "\$tmp_cfg" "$CFG" - fi - echo " Configuração existente mantida em $CFG (posição: $BAR_SECTION)" -fi - -# 4. looknfeel.lua — janelas flutuantes por padrão e redimensionamento por borda -LOOKNFEEL="$HOME/.config/hypr/looknfeel.lua" -if [[ ! -f "$LOOKNFEEL" ]]; then - cat > "$LOOKNFEEL" << 'LUAEOF' --- looknfeel.lua — gerado pelo Mouse First -hl.config({ - general = { - resize_on_border = true, - extend_border_grab_area = 15, - hover_icon_on_border = true, - }, -}) - --- Janelas flutuantes e centralizadas por padrão -o.window(".*", { float = true, center = true }) -o.window({ tag = "chromium-based-browser" }, { float = true, center = true, size = { "(monitor_w*85/100)", "(monitor_h*80/100)" } }) -o.window("^(brave-.+|chrome-.+|chromium-.+|google-chrome-.+)$", { float = true, center = true, size = { "(monitor_w*85/100)", "(monitor_h*80/100)" } }) -LUAEOF - echo " looknfeel.lua criado (janelas flutuantes por padrão)" -else - echo " looknfeel.lua já existe — não modificado" -fi - -# 5. Habilita o plugin no shell -if command -v omarchy &>/dev/null; then - echo "==> Habilitando plugin na seção $BAR_SECTION..." - omarchy plugin enable "$PLUGIN_ID" --section "$BAR_SECTION" 2>/dev/null || true -fi - -# 6. Recarrega -echo "==> Recarregando..." -hyprctl reload 2>/dev/null && echo " hyprctl reload OK" || echo " rode 'hyprctl reload' manualmente" -omarchy restart shell 2>/dev/null && echo " shell reiniciado OK" || \ - echo " rode 'omarchy restart shell' manualmente" - -echo "" -echo "✓ Mouse First instalado!" -echo "" -echo " A barra flutuante aparece no canto superior direito de cada janela." -echo " Botões: Minimizar | Maximizar | Fechar" -echo " Clique direito na barra → abre configurações." -echo " Drag: passe o mouse pelo topo da janela e arraste." -echo "" -echo " Para remover:" -echo " omarchy plugin remove $PLUGIN_ID --yes" diff --git a/manifest.json b/manifest.json index b7beab8..5aaeb59 100644 --- a/manifest.json +++ b/manifest.json @@ -2,18 +2,19 @@ "schemaVersion": 1, "id": "io.github.mousefirst.controls", "name": "Mouse-First", - "description": "Brings intuitive mouse-driven window management to Hyprland. Features floating window controls (minimize, maximize, close), easy top-edge dragging, smart edge snapping with multi-monitor and full dock support, and seamless app minimization directly to the top system bar.", - "version": "0.1.0", + "description": "Mouse-driven window management for Hyprland: minimize, maximize and close buttons on every window, drag windows by their top edge, edge and corner snapping with multi-monitor support, and app icons on the system bar.", + "version": "0.2.0", "author": "Djabo", + "homepage": "https://github.com/DjaboDev/Mouse-First", "license": "MIT", - "kinds": ["bar-widget"], - "keepLoaded": true, + "kinds": ["service", "bar-widget"], "entryPoints": { + "service": "Service.qml", "barWidget": "BarWidget.qml" }, "barWidget": { "displayName": "Mouse-First", - "description": "Floating window controls with window dragging for mouse users", + "description": "App icons for pinned, open and minimized windows", "category": "System", "defaultSection": "left", "allowMultiple": false diff --git a/scripts/dev-link.sh b/scripts/dev-link.sh new file mode 100755 index 0000000..06b6901 --- /dev/null +++ b/scripts/dev-link.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Symlink this checkout over the installed Mouse-First plugin for development. +# The previous install is moved aside, never deleted. Nothing else is touched. +set -euo pipefail + +id="io.github.mousefirst.controls" +repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +target="$HOME/.config/omarchy/plugins/$id" + +if [[ -L "$target" && "$(readlink -f "$target")" == "$repo" ]]; then + echo "Already linked: $target -> $repo" + exit 0 +fi + +mkdir -p "$(dirname "$target")" +if [[ -e "$target" || -L "$target" ]]; then + backup="$HOME/.local/state/omarchy/backups/$id.$(date +%Y%m%d%H%M%S)" + mkdir -p "$(dirname "$backup")" + mv "$target" "$backup" + echo "Moved the existing install to $backup" +fi + +ln -s "$repo" "$target" +echo "Linked $target -> $repo" +echo "Run 'omarchy restart shell' to load it." diff --git a/src/AppsModel.qml b/src/AppsModel.qml new file mode 100644 index 0000000..c8f498d --- /dev/null +++ b/src/AppsModel.qml @@ -0,0 +1,284 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import "js/hypr.js" as Hypr + +// Apps shown on the system bar: pinned apps from omadock's dock.json first, +// then every other running app. Windows of the same app share one tile. +// Built from Quickshell's live toplevel list and desktop entries, and +// rebuilt only when Hyprland reports a change. +// +// rows: [{ key, appId, pinId, name, icon, state, title, address, pinned, +// windows: [{ address, title, state, focus }] }] +// state: "active" | "running" | "minimized" | "closed" +Item { + id: root + visible: false + + required property string minimizedWorkspace + property var rows: [] + property var pinned: [] + property var iconIndex: ({}) + property var _pendingIcons: ({}) + + signal minimizedChanged(var addresses) + + function appIdOf(toplevel) { + var appId = toplevel.wayland ? String(toplevel.wayland.appId || "") : "" + if (appId === "" && toplevel.lastIpcObject) + appId = String(toplevel.lastIpcObject["class"] || toplevel.lastIpcObject["initialClass"] || "") + return appId + } + + function entryFor(appId) { + if (!appId) return null + return DesktopEntries.byId(appId) || DesktopEntries.heuristicLookup(appId) + } + + function iconSource(icon) { + var value = String(icon || "") + if (value === "") return "" + if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value + if (value.charAt(0) === "/") return "file://" + value + var found = root.iconIndex[value] || root.iconIndex[value.toLowerCase()] + if (found) return "file://" + found + var themed = "" + try { themed = Quickshell.iconPath(value, true) } catch (e) {} + return themed || "" + } + + function _groupKey(appId, entry) { + return entry && entry.id ? String(entry.id).toLowerCase() : String(appId || "").toLowerCase() + } + + readonly property var _rank: ({ active: 0, running: 1, minimized: 2, closed: 3 }) + + function rebuild() { + var toplevels = Hyprland.toplevels.values + var groups = {} + var order = [] + var minimized = [] + for (var i = 0; i < toplevels.length; i++) { + var t = toplevels[i] + var address = Hypr.normalizeAddress(t.address) + if (!address) continue + var appId = root.appIdOf(t) + var ws = t.workspace ? String(t.workspace.name || "") : "" + var isMin = ws === root.minimizedWorkspace + if (isMin) minimized.push(address) + var entry = root.entryFor(appId) + var key = root._groupKey(appId, entry) + if (!groups[key]) { + groups[key] = { appId: appId, entry: entry, windows: [] } + order.push(key) + } + var ipc = t.lastIpcObject || {} + groups[key].windows.push({ + address: address, + title: String(t.title || ""), + state: isMin ? "minimized" : (t.activated ? "active" : "running"), + focus: ipc.focusHistoryID !== undefined ? Number(ipc.focusHistoryID) : 999 + }) + } + + var out = [] + var used = {} + for (var p = 0; p < root.pinned.length; p++) { + var pin = String(root.pinned[p] || "") + if (pin === "") continue + var pinEntry = root.entryFor(pin) + var pinKey = root._groupKey(pin, pinEntry) + var g = groups[pinKey] || groups[pin.toLowerCase()] + if (g) used[root._groupKey(g.appId, g.entry)] = true + out.push(root._row(g, pinEntry || (g ? g.entry : null), pin, pin)) + } + for (var k = 0; k < order.length; k++) { + if (used[order[k]]) continue + var group = groups[order[k]] + out.push(root._row(group, group.entry, group.appId, "")) + } + root.rows = out + root.minimizedChanged(minimized) + } + + function _row(group, entry, appId, pinId) { + var windows = group ? group.windows : [] + var best = null + for (var i = 0; i < windows.length; i++) + if (!best || root._rank[windows[i].state] < root._rank[best.state] + || (windows[i].state === best.state && windows[i].focus < best.focus)) + best = windows[i] + var name = entry && entry.name ? String(entry.name) : (appId || "App") + var icon = root.iconSource(entry ? entry.icon : appId) || root.iconSource(appId) + if (!icon && !root._rescanned) root._requestRescan() + return { + key: pinId !== "" ? "pin:" + pinId : "app:" + root._groupKey(appId, entry), + appId: entry && entry.id ? String(entry.id) : appId, + pinId: pinId, + name: name, + icon: icon, + title: best ? best.title : "", + state: best ? best.state : "closed", + address: best ? best.address : "", + pinned: pinId !== "", + windows: windows + } + } + + // ── Pinning (shared with omadock through dock.json) ────── + + function setPinned(appId, pinned) { + if (!appId) return + var data = {} + try { data = JSON.parse(dockFile.text() || "{}") } catch (e) { data = {} } + var list = Array.isArray(data.pinned) ? data.pinned.slice() : [] + var lower = String(appId).toLowerCase() + list = list.filter(p => String(p).toLowerCase() !== lower) + if (pinned) list.push(appId) + data.pinned = list + root.pinned = list + dockFile.setText(JSON.stringify(data, null, 2) + "\n") + root.scheduleRebuild() + } + + function launch(appId) { + var entry = root.entryFor(appId) + if (entry) entry.execute() + else if (appId) Quickshell.execDetached(["gtk-launch", appId]) + } + + // ── Change sources ─────────────────────────────────────── + + Timer { + id: rebuildTimer + interval: 60 + onTriggered: root.rebuild() + } + function scheduleRebuild() { rebuildTimer.restart() } + + Timer { + id: refreshTimer + interval: 120 + onTriggered: { + Hyprland.refreshToplevels() + root.scheduleRebuild() + } + } + + Connections { + target: Hyprland + function onRawEvent(event) { + switch (event.name) { + case "openwindow": + case "closewindow": + case "movewindowv2": + case "windowtitlev2": + case "activewindowv2": + case "changefloatingmode": + refreshTimer.restart() + } + } + } + Connections { + target: Hyprland.toplevels + function onValuesChanged() { root.scheduleRebuild() } + } + Connections { + target: DesktopEntries.applications + function onValuesChanged() { + // A newly installed app may bring an icon the index lacks. + root._rescanned = false + root.scheduleRebuild() + } + } + + FileView { + id: dockFile + path: Quickshell.env("HOME") + "/.config/omarchy/dock.json" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { + try { + var d = JSON.parse(text()) + root.pinned = Array.isArray(d.pinned) ? d.pinned : [] + } catch (e) { + root.pinned = [] + } + root.scheduleRebuild() + } + onLoadFailed: { root.pinned = []; root.scheduleRebuild() } + } + + // Absolute-path icon index, the same approach as Omarchy's AppLibrary: + // Qt's themed lookup returns nothing when the configured icon theme is + // not installed, which would leave every tile blank. The index is cached + // on disk and only rebuilt (at low priority) when an icon is missing. + readonly property string cacheDir: (Quickshell.env("XDG_CACHE_HOME") || (Quickshell.env("HOME") + "/.cache")) + "/mouse-first" + property bool _cacheReady: false + property bool _rescanned: false + + function _requestRescan() { + if (!root._cacheReady || root._rescanned) return + root._rescanned = true + iconScanDebounce.restart() + } + + FileView { + id: iconCache + path: root.cacheDir + "/icons.json" + atomicWrites: true + printErrors: false + onLoaded: { + try { root.iconIndex = JSON.parse(text()) || {} } catch (e) { root.iconIndex = {} } + root._cacheReady = true + root.scheduleRebuild() + } + onLoadFailed: { + root._cacheReady = true + root._requestRescan() + } + } + + Timer { + id: iconScanDebounce + interval: 2000 + onTriggered: if (!iconScan.running) iconScan.running = true + } + Process { + id: iconScan + command: ["nice", "-n", "19", "bash", "-c", [ + 'dirs="$HOME/.icons $HOME/.local/share/icons";', + 'IFS=":"; for d in ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do dirs="$dirs $d/icons"; done; unset IFS;', + 'for ext in svg png; do', + ' for base in $dirs; do [[ -d $base ]] && find "$base" -path "*/apps/*" -name "*.$ext" 2>/dev/null; done;', + ' find /usr/share/pixmaps -maxdepth 1 -name "*.$ext" 2>/dev/null;', + 'done' + ].join(" ")] + stdout: SplitParser { + onRead: line => { + var path = String(line).trim() + var file = path.slice(path.lastIndexOf("/") + 1) + var dot = file.lastIndexOf(".") + var name = dot > 0 ? file.slice(0, dot) : file + if (name && root._pendingIcons[name] === undefined) { + root._pendingIcons[name] = path + if (root._pendingIcons[name.toLowerCase()] === undefined) root._pendingIcons[name.toLowerCase()] = path + } + } + } + onStarted: root._pendingIcons = ({}) + onExited: { + root.iconIndex = root._pendingIcons + iconCache.setText(JSON.stringify(root.iconIndex)) + root.scheduleRebuild() + } + } + + Component.onCompleted: { + Quickshell.execDetached(["mkdir", "-p", root.cacheDir]) + Hyprland.refreshToplevels() + root.scheduleRebuild() + } +} diff --git a/src/Config.qml b/src/Config.qml new file mode 100644 index 0000000..e91661e --- /dev/null +++ b/src/Config.qml @@ -0,0 +1,76 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +// User preferences in ~/.config/omarchy/mouse-first.json (same keys as v0.1). +// Edits made in the file by hand are picked up live; changes made from the +// settings panel are written back atomically. +FileView { + id: file + + readonly property alias data: adapter + property bool ready: false + + path: Quickshell.env("HOME") + "/.config/omarchy/mouse-first.json" + watchChanges: true + atomicWrites: true + printErrors: false + + onFileChanged: reload() + onLoaded: ready = true + onLoadFailed: error => { + // First run: write the defaults so the file documents every option. + if (error === FileViewError.FileNotFound) writeAdapter() + ready = true + } + onAdapterUpdated: writeAdapter() + + // Assign a property and persist it. Objects and arrays must be replaced, + // not mutated, for the change to be seen. + function set(key, value) { + adapter[key] = value + } + + function toggle(key) { + adapter[key] = !adapter[key] + } + + JsonAdapter { + id: adapter + + property string language: { + var lang = String(Quickshell.env("LANG") || "") + return lang.indexOf("pt") === 0 ? "pt" : (lang.indexOf("es") === 0 ? "es" : "en") + } + property bool disableTiling: true + property bool alwaysVisible: true + property bool dragFullWidth: true + property bool showDragHandle: false + property bool ignoreDock: false + property bool disableSnapping: false + property bool topEdgeMaximizes: false + property bool centerNewWindows: true + property bool enableShortcuts: false + property var excludedClasses: [] + property int minControlsWidth: 240 + property bool minimizeToBar: true + property string barSection: "left" + + property bool enableMenuDrag: true + property bool rememberMenuPerWindow: false + property bool rememberMenuGlobal: false + property bool alwaysShowMenuDragZone: false + property real globalMenuOffsetX: 0 + property real globalMenuOffsetY: 0 + property var perWindowClassOffsets: ({}) + + property var buttonOrder: ["float", "minimize", "maximize", "close"] + property var buttonVisible: ({ float: false, minimize: true, maximize: true, close: true }) + + property string colorMode: "theme" + property string customBg: "#0e0e14" + property string customFg: "#a9b1d6" + property string customAccent: "#7aa2f7" + property string customRed: "#f7768e" + } +} diff --git a/src/DragController.qml b/src/DragController.qml new file mode 100644 index 0000000..5e01f17 --- /dev/null +++ b/src/DragController.qml @@ -0,0 +1,173 @@ +import QtQuick +import "js/geometry.js" as G +import "js/hypr.js" as Hypr + +// Moves the active window while the user drags the top-edge zone or the +// grip. All coordinates are global. The overlay keeps the pointer grab for +// the whole drag, so motion keeps arriving even across monitors. +// +// press ─(moved ≥ threshold)→ active ─(release)→ snap or final move +// +// A maximized or snapped window is restored the moment the drag starts and +// placed so the point the user grabbed stays under the cursor. +// +// Native moves (SUPER + drag, app title bars) are handed over by the Lua +// module through takeOver(); the cursor then arrives as tracker reports +// instead of overlay mouse events, and everything else is shared. +Item { + id: root + visible: false + + required property var tracker + required property var states + property bool snappingEnabled: true + property bool topMaximizes: false // top edge maximizes instead of the top half + + readonly property bool pressed: _win !== null + property bool active: false + property bool handedOver: false + property string address: "" + // Where the window is being drawn right now (updated on every motion, + // ahead of the compositor), and the snap target if one is armed. + property var rect: null + property string zone: "" + property var zoneRect: null + + property var _win: null + property var _press: null + property var _start: null + property var _restore: null + property bool _dirty: false + + function press(gx, gy) { + var win = root.tracker.active + if (!win || root._win) return + root._win = win + root._press = { x: gx, y: gy } + } + + // Continue a drag Hyprland started. The threshold has already been passed, + // so the drag begins immediately. + function takeOver(win, gx, gy) { + if (root._win || !win) return + root._win = win + root._press = { x: gx, y: gy } + root.handedOver = true + root._begin(gx, gy) + } + + function move(gx, gy) { + if (!root._win) return + if (!root.active) { + if (Math.hypot(gx - root._press.x, gy - root._press.y) < G.DRAG_THRESHOLD) return + root._begin(gx, gy) + } + + var cursor = { x: gx, y: gy } + var monitor = root.states.monitorAt(gx, gy) + if (!monitor) return + var area = root.states.areaFor(monitor) + var raw = G.rect(root._start.x + gx - root._press.x, root._start.y + gy - root._press.y, + root._start.w, root._start.h) + + root.rect = G.dragTarget(raw, area, monitor, cursor) + var z = root.snappingEnabled ? G.snapZone(raw, area, monitor, cursor) : "" + if (z === "top" && root.topMaximizes) z = "maximize" + root.zoneRect = z ? G.zoneRect(z, area, root.states.gap) : null + root.zone = z + root._dirty = true + if (!flushTimer.running) flushTimer.start() + } + + function release() { + if (root.active) root._finish() + root._reset() + } + + function cancel() { + root.release() + } + + function _begin(gx, gy) { + var win = root._win + var a = win.address + root.address = a + root.active = true + root.states.dragging = true + restoreAnimTimer.stop() + + // Remember what a later "restore" should return to before touching + // anything: the pre-snap geometry, or the window as it is now. + root._restore = root.states.restoreGeometryFor(win) + + var cmds = [Hypr.setNoAnim(a, true), Hypr.bringToTop(a)] + if (win.fullscreen === 1) cmds.push(Hypr.setMaximizedState(a, false)) + if (!win.floating) cmds.push(Hypr.setFloating(a, true)) + root.tracker.dispatch(cmds) + + if (root.states.isManaged(win)) { + var cursor = { x: gx, y: gy } + var area = root.states.areaFor(root.states.monitorAt(gx, gy)) + var size = G.restoreSize(root._restore, area) + root._start = G.restoreUnderCursor(size, cursor, + (root._press.x - win.x) / Math.max(1, win.w), root._press.y - win.y, area) + root.states.clear(a) + root.tracker.dispatch(Hypr.geometry(a, root._start)) + } else { + root._start = G.rect(win.x, win.y, win.w, win.h) + } + root._press = { x: gx, y: gy } + root.rect = root._start + } + + function _flush() { + if (!root._dirty || !root.rect) return + root._dirty = false + root.tracker.dispatch(Hypr.move(root.address, root.rect.x, root.rect.y)) + } + + function _finish() { + flushTimer.stop() + var win = Object.assign({}, root._win, { floating: true, fullscreen: 0 }) + if (root.zone !== "" && root.zoneRect) { + root.states.snapTo(win, root.zone, root.zoneRect, root._restore) + root.rect = root.zoneRect + } else if (root.rect) { + root.tracker.dispatch(Hypr.move(root.address, root.rect.x, root.rect.y)) + } + restoreAnimTimer.pendingAddress = root.address + restoreAnimTimer.restart() + } + + function _reset() { + root.states.dragging = false + root.active = false + root.handedOver = false + root._win = null + root._press = null + root._start = null + root.zone = "" + root.zoneRect = null + root.rect = null + root.address = "" + } + + // Coalesces motion into at most one move dispatch per frame. + Timer { + id: flushTimer + interval: 16 + repeat: true + onTriggered: { + if (!root.active) { stop(); return } + root._flush() + } + } + + // Animations come back once the final geometry has been applied. + Timer { + id: restoreAnimTimer + property string pendingAddress: "" + interval: 200 + onTriggered: root.tracker.dispatch(Hypr.setNoAnim(pendingAddress, false)) + } +} diff --git a/src/Minimizer.qml b/src/Minimizer.qml new file mode 100644 index 0000000..b8bdcf1 --- /dev/null +++ b/src/Minimizer.qml @@ -0,0 +1,79 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "js/hypr.js" as Hypr + +// Minimize and restore. Windows are parked on special:minimized, the same +// workspace omadock uses, so both show and restore them. The workspace each +// window came from is remembered on disk so a shell restart does not send +// restored windows to the wrong place. +Item { + id: root + visible: false + + required property var tracker + + readonly property string workspaceName: "special:minimized" + readonly property string stateDir: (Quickshell.env("XDG_STATE_HOME") || (Quickshell.env("HOME") + "/.local/state")) + "/mouse-first" + + function minimize(address, originWorkspace) { + var a = Hypr.normalizeAddress(address) + if (!a) return + if (originWorkspace && originWorkspace.indexOf("special:") !== 0) { + var next = Object.assign({}, store.adapter.origins) + next[a] = originWorkspace + store.adapter.origins = next + } + root.tracker.evalLua(Hypr.evalMinimize(a)) + } + + function restore(address) { + var a = Hypr.normalizeAddress(address) + if (!a) return + var origin = store.adapter.origins[a] || "" + root.forget(a) + root.tracker.evalLua(Hypr.evalRestore(a, origin)) + } + + function forget(address) { + if (store.adapter.origins[address] === undefined) return + var next = Object.assign({}, store.adapter.origins) + delete next[address] + store.adapter.origins = next + } + + // Drop entries for windows that no longer exist or were restored by + // something else (omadock, a keybind). + function prune(minimizedAddresses) { + var keep = {} + for (var i = 0; i < minimizedAddresses.length; i++) keep[minimizedAddresses[i]] = true + var changed = false + var next = {} + for (var a in store.adapter.origins) { + if (keep[a]) next[a] = store.adapter.origins[a] + else changed = true + } + if (changed) store.adapter.origins = next + } + + FileView { + id: store + path: root.stateDir + "/minimized.json" + atomicWrites: true + printErrors: false + onAdapterUpdated: writeAdapter() + + JsonAdapter { + property var origins: ({}) + } + } + + Connections { + target: root.tracker + function onWindowClosed(address) { root.forget(address) } + } + + Component.onCompleted: { + Quickshell.execDetached(["mkdir", "-p", root.stateDir]) + } +} diff --git a/src/WindowStates.qml b/src/WindowStates.qml new file mode 100644 index 0000000..f24d03a --- /dev/null +++ b/src/WindowStates.qml @@ -0,0 +1,264 @@ +import QtQuick +import Quickshell.Hyprland +import "js/geometry.js" as G +import "js/hypr.js" as Hypr + +// Per-window placement state: free, snapped to a zone, or maximized, plus the +// geometry to restore to. This replaces v0.1's pile of size caches and +// browser-specific magic numbers with one explicit model. +// +// Maximize is done with geometry (float + usable area), like snapping, so a +// maximized window and a snapped one behave the same when dragged away. +// Windows maximized by Hyprland itself (fullscreen state 1, e.g. via a +// keybind) are recognised and handed back to Hyprland to un-maximize. +Item { + id: root + visible: false + + required property var tracker + property bool dragging: false // set by DragController + property int gap: 6 + property bool ignoreDock: false + property bool dockPresent: false + property string barPosition: "top" + property int barThickness: 26 + + // address -> { mode: "snapped"|"maximized", zone, target, restore, monitor, + // workspace, since, confirmed } + property var entries: ({}) + // address -> last geometry seen while the window was free and floating + property var lastFree: ({}) + // address -> true once oversized-window protection has run + property var fitted: ({}) + + // Border resize of a snapped window in progress: its rect when the + // resize started and the snapped neighbours that follow its edges. + property var resizeSession: null + + readonly property int settleMs: 700 + + // ── Queries ────────────────────────────────────────────── + + function entry(address) { + return root.entries[address] || null + } + + function isMaximized(win) { + if (!win) return false + var e = root.entry(win.address) + return win.fullscreen === 1 || !!(e && e.mode === "maximized") + } + + function isManaged(win) { + return !!win && (win.fullscreen === 1 || !!root.entry(win.address)) + } + + // Monitor object for a window (by name) or for a point. + function monitorOf(win) { + return (win && root.tracker.monitorByName(win.monitor)) || root.tracker.monitors[0] || null + } + + function monitorAt(x, y) { + return G.monitorAt(root.tracker.monitors, x, y) + } + + function areaFor(monitor, gapOverride) { + if (!monitor) return G.rect(0, 0, 1920, 1080) + var r = monitor.reserved + if (root.ignoreDock && root.dockPresent) { + // Keep the bar's own reservation, drop the dock's. + r = { + left: r.left, top: r.top, right: r.right, + bottom: root.barPosition === "bottom" ? root.barThickness : 0 + } + } + return G.usableArea(monitor, r, gapOverride === undefined ? root.gap : gapOverride) + } + + // What "restore" should return this window to. + function restoreGeometryFor(win) { + var e = root.entry(win.address) + if (e && e.restore) return e.restore + if (win.floating && win.fullscreen === 0) return G.rect(win.x, win.y, win.w, win.h) + return root.lastFree[win.address] || null + } + + // ── Mutations ──────────────────────────────────────────── + + function _set(address, value) { + var next = Object.assign({}, root.entries) + if (value) next[address] = value + else delete next[address] + root.entries = next + } + + function clear(address) { + if (root.entries[address]) root._set(address, null) + } + + function forget(address) { + root.clear(address) + delete root.lastFree[address] + delete root.fitted[address] + } + + function apply(address, r) { + root.tracker.dispatch(Hypr.geometry(address, r)) + } + + function _place(win, mode, zone, target, restore) { + if (!win.floating) root.tracker.dispatch(Hypr.setFloating(win.address, true)) + root._set(win.address, { + mode: mode, zone: zone, target: target, restore: restore, + monitor: win.monitor, workspace: win.workspace, + since: Date.now(), confirmed: false + }) + root.apply(win.address, target) + } + + function snap(win, zone, restore) { + var area = root.areaFor(root.monitorOf(win)) + var target = G.zoneRect(zone, area, root.gap) + if (!target) return + root._place(win, zone === "maximize" ? "maximized" : "snapped", zone, target, restore) + } + + // Snap into a zone of the monitor the cursor is on (used by the drag). + function snapTo(win, zone, target, restore) { + root._place(win, zone === "maximize" ? "maximized" : "snapped", zone, target, restore) + } + + function maximize(win) { + if (!win || win.fullscreen === 1) return + root.snap(win, "maximize", root.restoreGeometryFor(win)) + } + + function restore(win) { + if (!win) return + if (win.fullscreen === 1) { + root.tracker.dispatch(Hypr.setMaximizedState(win.address, false)) + root.clear(win.address) + return + } + var e = root.entry(win.address) + if (!e) return + var area = root.areaFor(root.monitorOf(win)) + var size = G.restoreSize(e.restore, area) + var r = e.restore && e.restore.w > 0 + ? G.clampInto(G.rect(e.restore.x, e.restore.y, size.w, size.h), area) + : G.centered(size, area) + root.clear(win.address) + root.apply(win.address, r) + } + + function toggleMaximize(win) { + if (root.isMaximized(win)) root.restore(win) + else root.maximize(win) + } + + // ── Observation ────────────────────────────────────────── + // Called for every geometry report of the active window. + + function observe(win) { + if (!win || root.dragging) return + var e = root.entry(win.address) + + if (e) { + if (G.rectsMatch(win, e.target)) { + if (!e.confirmed) e.confirmed = true + } else if (root.tracker.buttonDown) { + // A drag is handed to the drag controller, which restores the + // window itself. A border resize drags the neighbours along. + if (e.mode === "snapped" && e.confirmed && !G.sizesMatch(win, e.target)) + root._resizeNeighbours(win, e) + } else if (root.resizeSession && root.resizeSession.address === win.address) { + root._finishResize(win) + } else if (e.confirmed || Date.now() - e.since > root.settleMs) { + // Moved or resized by something else (a border resize, a + // keybind): the window is free again. + root.clear(win.address) + } + } else if (win.floating && win.fullscreen === 0) { + root.lastFree[win.address] = G.rect(win.x, win.y, win.w, win.h) + root._fitOnce(win) + } + } + + // ── Resizing snapped neighbours together ───────────────── + + function _workspaceOf(address) { + var ts = Hyprland.toplevels.values + for (var i = 0; i < ts.length; i++) + if (Hypr.normalizeAddress(ts[i].address) === address) + return ts[i].workspace ? String(ts[i].workspace.name || "") : "" + return "" + } + + function _resizeNeighbours(win, e) { + var session = root.resizeSession + if (!session || session.address !== win.address) { + var neighbours = [] + for (var a in root.entries) { + var n = root.entries[a] + if (a === win.address || n.mode !== "snapped" || n.monitor !== e.monitor) continue + if (n.workspace !== e.workspace || root._workspaceOf(a) !== e.workspace) continue + neighbours.push({ address: a, start: n.target }) + } + session = { address: win.address, start: e.target, neighbours: neighbours, latest: {} } + root.resizeSession = session + } + var now = G.rect(win.x, win.y, win.w, win.h) + for (var i = 0; i < session.neighbours.length; i++) { + var nb = session.neighbours[i] + var r = G.followEdge(session.start, now, nb.start, root.gap) + if (r) session.latest[nb.address] = r + } + if (!neighbourTimer.running) neighbourTimer.start() + } + + // The resize ended: the window stays snapped at its new size, and so do + // the neighbours that followed it. + function _finishResize(win) { + neighbourTimer.stop() + root._flushNeighbours() + var session = root.resizeSession + root.resizeSession = null + var next = Object.assign({}, root.entries) + var own = next[win.address] + if (own) next[win.address] = Object.assign({}, own, { target: G.rect(win.x, win.y, win.w, win.h), since: Date.now() }) + for (var a in session.latest) + if (next[a]) next[a] = Object.assign({}, next[a], { target: session.latest[a], since: Date.now(), confirmed: true }) + root.entries = next + } + + function _flushNeighbours() { + var session = root.resizeSession + if (!session) return + for (var a in session.latest) root.apply(a, session.latest[a]) + } + + // Neighbour geometry is sent at most ~30 times a second. + Timer { + id: neighbourTimer + interval: 33 + onTriggered: root._flushNeighbours() + } + + // Windows that open larger than the usable area or under the bar are + // pulled into view once. + function _fitOnce(win) { + if (root.fitted[win.address]) return + root.fitted[win.address] = true + // Measured without gaps: a window flush against the bar is fine. + var r = G.fitInside(win, root.areaFor(root.monitorOf(win), 0)) + if (r) root.apply(win.address, r) + } + + Connections { + target: root.tracker + function onWindowUpdated(win) { root.observe(win) } + function onWindowClosed(address) { root.forget(address) } + // The last geometry report may come before the release; look again. + function onButtonReleased(x, y) { Qt.callLater(() => root.observe(root.tracker.active)) } + } +} diff --git a/src/WindowTracker.qml b/src/WindowTracker.qml new file mode 100644 index 0000000..6c64ea8 --- /dev/null +++ b/src/WindowTracker.qml @@ -0,0 +1,198 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import "js/hypr.js" as Hypr + +// Live view of the active window and the monitor layout. +// +// src/lua/mousefirst.lua runs inside Hyprland and reports changes as custom +// IPC events, so this object never polls. It also owns the channel for +// commands: plain dispatches go through Quickshell's Hyprland socket, and +// calls into the Lua module go through a small `hyprctl eval` queue. +Item { + id: root + visible: false + + // Active window, or null. { address, x, y, w, h, floating, fullscreen, + // pinned, monitor, workspace, cls, title } + property var active: null + // [{ name, x, y, w, h, reserved: { left, top, right, bottom } }] + property var monitors: [] + // Left button state, reported by the module's non-consuming binds; + // `buttonKnown` stays false until the first report arrives. + property bool buttonDown: false + property bool buttonKnown: false + property bool installed: false + + signal windowUpdated(var win) + signal windowClosed(string address) + signal windowOpened(string address) + signal buttonReleased(real x, real y) + // Hyprland started moving `address` (SUPER + drag or a title bar) and + // handed the drag over; cursor reports follow until the button is released. + signal nativeDragStarted(string address, real x, real y, var rect) + signal cursorMoved(real x, real y) + signal shortcut(string action) + signal configReloaded() + + readonly property string moduleSource: luaFile.text() + + function dispatch(commands) { + var list = Array.isArray(commands) ? commands : [commands] + for (var i = 0; i < list.length; i++) + if (list[i]) Hyprland.dispatch(list[i]) + } + + property var _evalQueue: [] + function evalLua(code) { + if (!code) return + root._evalQueue = root._evalQueue.concat([code]) + root._pumpEval() + } + function _pumpEval() { + if (evalProc.running || root._evalQueue.length === 0) return + var code = root._evalQueue[0] + root._evalQueue = root._evalQueue.slice(1) + // A leading newline keeps hyprctl from reading a `--` comment as a flag. + evalProc.command = ["hyprctl", "eval", "\n" + code] + evalProc.running = true + } + + // Identifies this shell instance's copy of the module, so a plugin reload + // that tears down the old service cannot stop the new one's timer. + readonly property string generation: Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36) + + function install() { + if (root.moduleSource === "") return + root.evalLua('MOUSE_FIRST_GENERATION = "' + root.generation + '"\n' + root.moduleSource) + root.installed = true + resendTimer.restart() + } + + // Events emitted while the shell is still connecting to Hyprland's event + // socket are lost; ask the module to report its current state again. + Timer { + id: resendTimer + interval: 600 + onTriggered: root.evalLua("if MOUSE_FIRST then MOUSE_FIRST.resend() end") + } + + function uninstall() { + // The object is going away; the process has to outlive it. + Quickshell.execDetached(["hyprctl", "eval", + 'if MOUSE_FIRST then MOUSE_FIRST.stop("' + root.generation + '") end']) + } + + function monitorByName(name) { + for (var i = 0; i < root.monitors.length; i++) + if (root.monitors[i].name === name) return root.monitors[i] + return null + } + + function _parseWindow(payload) { + if (payload === "none") return null + var f = payload.split("\u001f") + if (f.length < 12) return null + return { + address: Hypr.normalizeAddress(f[0]), + x: Number(f[1]), y: Number(f[2]), w: Number(f[3]), h: Number(f[4]), + floating: f[5] === "1", + fullscreen: Number(f[6]), + pinned: f[7] === "1", + monitor: f[8], + workspace: f[9], + cls: f[10], + title: f.slice(11).join(" ") + } + } + + function _parseMonitors(payload) { + var out = [] + var parts = payload.split(";") + for (var i = 0; i < parts.length; i++) { + var f = parts[i].split(",") + if (f.length < 9) continue + out.push({ + name: f[0], + x: Number(f[1]), y: Number(f[2]), w: Number(f[3]), h: Number(f[4]), + reserved: { left: Number(f[5]), top: Number(f[6]), right: Number(f[7]), bottom: Number(f[8]) } + }) + } + return out + } + + function _handleCustom(data) { + var prefix = "mousefirst>>" + if (data.indexOf(prefix) !== 0) return + var rest = data.slice(prefix.length) + var sep = rest.indexOf(">>") + if (sep < 0) return + var kind = rest.slice(0, sep) + var payload = rest.slice(sep + 2) + + if (kind === "window") { + root.active = root._parseWindow(payload) + root.windowUpdated(root.active) + } else if (kind === "monitors") { + root.monitors = root._parseMonitors(payload) + } else if (kind === "button") { + var b = payload.split(",") + var down = b[0] === "1" + root.buttonKnown = true + var wasDown = root.buttonDown + root.buttonDown = down + if (wasDown && !down) root.buttonReleased(Number(b[1]), Number(b[2])) + } else if (kind === "cursor") { + var c = payload.split(",") + root.cursorMoved(Number(c[0]), Number(c[1])) + } else if (kind === "shortcut") { + root.shortcut(payload) + } else if (kind === "native") { + var n = payload.split(",") + if (n.length < 7) return + root.nativeDragStarted(Hypr.normalizeAddress(n[0]), Number(n[1]), Number(n[2]), + { x: Number(n[3]), y: Number(n[4]), w: Number(n[5]), h: Number(n[6]) }) + } + } + + Connections { + target: Hyprland + function onRawEvent(event) { + var name = event.name + if (name === "custom") { + root._handleCustom(String(event.data || "")) + } else if (name === "closewindow") { + root.windowClosed(Hypr.normalizeAddress(event.data)) + } else if (name === "openwindow") { + root.windowOpened(Hypr.normalizeAddress(String(event.data || "").split(",")[0])) + } else if (name === "configreloaded") { + // A config reload rebuilds Hyprland's Lua state, dropping the + // module, its timer and the float rule. + root.install() + root.configReloaded() + } + } + } + + FileView { + id: luaFile + path: decodeURIComponent(Qt.resolvedUrl("lua/mousefirst.lua").toString().replace(/^file:\/\//, "")) + blockLoading: true + printErrors: true + } + + Process { + id: evalProc + stderr: StdioCollector { onStreamFinished: if (text.trim() !== "") console.warn("mouse-first: hyprctl eval:", text.trim()) } + stdout: StdioCollector { + onStreamFinished: { + var out = text.trim() + if (out !== "" && out !== "ok") console.warn("mouse-first: hyprctl eval:", out) + } + } + onExited: root._pumpEval() + } + + Component.onDestruction: uninstall() +} diff --git a/src/js/geometry.js b/src/js/geometry.js new file mode 100644 index 0000000..8a2b26b --- /dev/null +++ b/src/js/geometry.js @@ -0,0 +1,261 @@ +.pragma library + +// Pure geometry helpers. Every rect is { x, y, w, h } in global layout +// (logical) coordinates, the same space Hyprland reports `at`/`size` in. +// Nothing here touches QML or Hyprland, so tests/geometry.test.mjs can run +// these functions under node. + +var DRAG_THRESHOLD = 4 // px of pointer travel before a press becomes a drag +var BREAK_MARGIN = 45 // px pushed past an edge to escape clamping/snapping +var EDGE_TOUCH = 6 // px from the usable edge that counts as touching it +var CURSOR_EDGE = 16 // px from the screen edge that arms an edge snap +var CURSOR_CORNER = 32 // px square in each screen corner that arms a quarter +var MATCH_TOLERANCE = 4 // px of slack when comparing requested vs actual rects +var RESTORE_W = 0.6 // default restore size, as a fraction of the usable area +var RESTORE_H = 0.65 +var MIN_W = 320 +var MIN_H = 240 + +function rect(x, y, w, h) { + return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) } +} + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)) +} + +function contains(r, px, py) { + return px >= r.x && px < r.x + r.w && py >= r.y && py < r.y + r.h +} + +function rectsMatch(a, b, tol) { + var t = tol === undefined ? MATCH_TOLERANCE : tol + return !!a && !!b && + Math.abs(a.x - b.x) <= t && Math.abs(a.y - b.y) <= t && + Math.abs(a.w - b.w) <= t && Math.abs(a.h - b.h) <= t +} + +function sizesMatch(a, b, tol) { + var t = tol === undefined ? MATCH_TOLERANCE : tol + return !!a && !!b && Math.abs(a.w - b.w) <= t && Math.abs(a.h - b.h) <= t +} + +// Monitor under a point; falls back to the nearest one so a cursor that is +// momentarily between outputs still resolves. +function monitorAt(monitors, px, py) { + if (!monitors || monitors.length === 0) return null + var best = null, bestDist = Infinity + for (var i = 0; i < monitors.length; i++) { + var m = monitors[i] + if (contains(m, px, py)) return m + var dx = Math.max(m.x - px, 0, px - (m.x + m.w)) + var dy = Math.max(m.y - py, 0, py - (m.y + m.h)) + var d = dx * dx + dy * dy + if (d < bestDist) { bestDist = d; best = m } + } + return best +} + +// Area windows may occupy on a monitor: minus reserved space (bar, dock), +// inset by `gap`. `reserved` is { left, top, right, bottom }. +function usableArea(monitor, reserved, gap) { + var r = reserved || { left: 0, top: 0, right: 0, bottom: 0 } + var g = gap || 0 + return rect( + monitor.x + r.left + g, + monitor.y + r.top + g, + Math.max(0, monitor.w - r.left - r.right - 2 * g), + Math.max(0, monitor.h - r.top - r.bottom - 2 * g)) +} + +// Target rect of a snap zone inside a usable area. `gap` separates tiles. +function zoneRect(zone, area, gap) { + var g = gap || 0 + var halfW = Math.round((area.w - g) / 2) + var halfH = Math.round((area.h - g) / 2) + var right = area.x + halfW + g + var lower = area.y + halfH + g + var restW = area.w - halfW - g + var restH = area.h - halfH - g + switch (zone) { + case "maximize": return rect(area.x, area.y, area.w, area.h) + case "left": return rect(area.x, area.y, halfW, area.h) + case "right": return rect(right, area.y, restW, area.h) + case "top": return rect(area.x, area.y, area.w, halfH) + case "bottom": return rect(area.x, lower, area.w, restH) + case "top-left": return rect(area.x, area.y, halfW, halfH) + case "top-right": return rect(right, area.y, restW, halfH) + case "bottom-left": return rect(area.x, lower, halfW, restH) + case "bottom-right": return rect(right, lower, restW, restH) + } + return null +} + +// Placement bounds for a window of size w x h inside `area`. +function bounds(area, w, h) { + return { + minX: area.x, + minY: area.y, + maxX: Math.max(area.x, area.x + area.w - w), + maxY: Math.max(area.y, area.y + area.h - h) + } +} + +// Move a rect (keeping its size) so it lies inside `area` where possible. +function clampInto(r, area) { + var b = bounds(area, r.w, r.h) + return rect(clamp(r.x, b.minX, b.maxX), clamp(r.y, b.minY, b.maxY), r.w, r.h) +} + +// True when the user pushes the window (or cursor) well past the usable +// edges: they want to leave the monitor or tuck the window partly off-screen, +// so clamping and snapping step aside. +function isBreaking(raw, area, monitor, cursor) { + var b = bounds(area, raw.w, raw.h) + return raw.x < b.minX - BREAK_MARGIN || raw.x > b.maxX + BREAK_MARGIN || + raw.y < b.minY - BREAK_MARGIN || raw.y > b.maxY + BREAK_MARGIN || + cursor.x < monitor.x - 10 || cursor.x > monitor.x + monitor.w + 10 || + cursor.y < monitor.y - 10 || cursor.y > monitor.y + monitor.h + 10 +} + +// Where a dragged window should be drawn: clamped inside the usable area, +// unless the user is deliberately breaking out of it. +function dragTarget(raw, area, monitor, cursor) { + if (isBreaking(raw, area, monitor, cursor)) return rect(raw.x, raw.y, raw.w, raw.h) + return clampInto(raw, area) +} + +// Snap zone for a drag in progress, or "" for none. A zone arms when the +// window touches a usable edge or the cursor is thrown against a screen edge; +// touching two edges at once (or the cursor sitting in a corner) picks a +// quarter. +function snapZone(raw, area, monitor, cursor) { + if (isBreaking(raw, area, monitor, cursor)) return "" + var b = bounds(area, raw.w, raw.h) + var usableBottom = area.y + area.h + + var left = raw.x <= b.minX + EDGE_TOUCH || cursor.x <= monitor.x + CURSOR_EDGE + var right = raw.x >= b.maxX - EDGE_TOUCH || cursor.x >= monitor.x + monitor.w - CURSOR_EDGE + var top = raw.y <= b.minY + EDGE_TOUCH || cursor.y <= area.y + CURSOR_EDGE + var bottom = raw.y >= b.maxY - EDGE_TOUCH || cursor.y >= usableBottom - CURSOR_EDGE + + // A window as wide (tall) as the area touches both sides at once; only + // the side the cursor is nearer to counts. + if (left && right) { + var mid = area.x + area.w / 2 + left = cursor.x < mid + right = !left + } + if (top && bottom) { + var midY = area.y + area.h / 2 + top = cursor.y < midY + bottom = !top + } + + var cLeft = cursor.x <= monitor.x + CURSOR_CORNER + var cRight = cursor.x >= monitor.x + monitor.w - CURSOR_CORNER + var cTop = cursor.y <= area.y + CURSOR_CORNER + var cBottom = cursor.y >= usableBottom - CURSOR_CORNER + + if ((cLeft && cTop) || (left && top)) return "top-left" + if ((cRight && cTop) || (right && top)) return "top-right" + if ((cLeft && cBottom) || (left && bottom)) return "bottom-left" + if ((cRight && cBottom) || (right && bottom)) return "bottom-right" + if (left) return "left" + if (right) return "right" + if (top) return "top" + if (bottom) return "bottom" + return "" +} + +function defaultRestoreSize(area) { + return { w: Math.round(area.w * RESTORE_W), h: Math.round(area.h * RESTORE_H) } +} + +// Size to restore a snapped/maximized window to: the remembered one, shrunk +// to fit the area it is going to, or the default when nothing is remembered. +function restoreSize(remembered, area) { + var s = (remembered && remembered.w > 0 && remembered.h > 0) ? remembered : defaultRestoreSize(area) + return { + w: Math.round(clamp(s.w, Math.min(MIN_W, area.w), area.w)), + h: Math.round(clamp(s.h, Math.min(MIN_H, area.h), area.h)) + } +} + +// Place a restored window so the point the user grabbed stays under the +// cursor: same horizontal ratio, same vertical offset from the top edge. +function restoreUnderCursor(size, cursor, grabRatioX, grabOffsetY, area) { + var ratio = clamp(grabRatioX, 0.1, 0.9) + var offY = clamp(grabOffsetY, 0, Math.max(0, size.h - 1)) + var b = bounds(area, size.w, size.h) + return rect( + clamp(cursor.x - size.w * ratio, b.minX, b.maxX), + clamp(cursor.y - offY, b.minY, b.maxY), + size.w, size.h) +} + +// Center a rect of the given size in the area. +function centered(size, area) { + return rect(area.x + (area.w - size.w) / 2, area.y + (area.h - size.h) / 2, size.w, size.h) +} + +// Shrink/move a window that opened larger than the usable area or under the +// bar. Returns null when the window already fits. +function fitInside(win, area) { + var tooBig = win.w > area.w || win.h > area.h + var underTop = win.y < area.y + if (!tooBig && !underTop) return null + if (tooBig) { + var size = { w: Math.min(win.w, Math.round(area.w * 0.9)), h: Math.min(win.h, Math.round(area.h * 0.9)) } + return centered(size, area) + } + return rect(win.x, area.y, win.w, Math.min(win.h, area.h)) +} + +// A snapped window was resized from `before` to `after`. If `other` shares +// the edge that moved (they sat side by side, `gap` apart), return the rect +// that keeps `other` against it; otherwise null. Used to resize snapped +// neighbours together, like a split view. +function followEdge(before, after, other, gap) { + var g = gap || 0 + var t = MATCH_TOLERANCE * 2 + var r = { x: other.x, y: other.y, w: other.w, h: other.h } + var changed = false + var overlapY = other.y < before.y + before.h && other.y + other.h > before.y + var overlapX = other.x < before.x + before.w && other.x + other.w > before.x + + if (overlapY && Math.abs(other.x - (before.x + before.w + g)) <= t && after.x + after.w !== before.x + before.w) { + var right = other.x + other.w + r.x = after.x + after.w + g + r.w = right - r.x + changed = true + } + if (overlapY && Math.abs(other.x + other.w + g - before.x) <= t && after.x !== before.x) { + r.w = after.x - g - other.x + changed = true + } + if (overlapX && Math.abs(other.y - (before.y + before.h + g)) <= t && after.y + after.h !== before.y + before.h) { + var bottom = other.y + other.h + r.y = after.y + after.h + g + r.h = bottom - r.y + changed = true + } + if (overlapX && Math.abs(other.y + other.h + g - before.y) <= t && after.y !== before.y) { + r.h = after.y - g - other.y + changed = true + } + if (!changed || r.w < MIN_W / 2 || r.h < MIN_H / 2) return null + return rect(r.x, r.y, r.w, r.h) +} + +// Exported for node tests; ignored by the QML engine. +if (typeof module !== "undefined") { + module.exports = { + DRAG_THRESHOLD: DRAG_THRESHOLD, rect: rect, clamp: clamp, contains: contains, + rectsMatch: rectsMatch, sizesMatch: sizesMatch, monitorAt: monitorAt, + usableArea: usableArea, zoneRect: zoneRect, clampInto: clampInto, isBreaking: isBreaking, + dragTarget: dragTarget, snapZone: snapZone, defaultRestoreSize: defaultRestoreSize, + restoreSize: restoreSize, restoreUnderCursor: restoreUnderCursor, + centered: centered, fitInside: fitInside, followEdge: followEdge + } +} diff --git a/src/js/hypr.js b/src/js/hypr.js new file mode 100644 index 0000000..15cf3f2 --- /dev/null +++ b/src/js/hypr.js @@ -0,0 +1,95 @@ +.pragma library + +// Builders for Hyprland Lua dispatch strings. Every window-targeting command +// goes through `target()`, which accepts only a real hex address, so nothing +// read from a window title or config can end up inside the Lua expression. + +function normalizeAddress(address) { + var s = String(address || "").trim() + if (s.indexOf("0x") === 0 || s.indexOf("0X") === 0) s = s.slice(2) + return /^[0-9a-fA-F]+$/.test(s) ? "0x" + s.toLowerCase() : "" +} + +function target(address) { + var a = normalizeAddress(address) + return a ? 'window = "address:' + a + '"' : "" +} + +function int(n) { + return Math.round(Number(n) || 0) +} + +function move(address, x, y) { + var t = target(address) + return t ? "hl.dsp.window.move({ x = " + int(x) + ", y = " + int(y) + ", relative = false, " + t + " })" : "" +} + +function resize(address, w, h) { + var t = target(address) + return t ? "hl.dsp.window.resize({ x = " + int(w) + ", y = " + int(h) + ", " + t + " })" : "" +} + +// Hyprland resizes around the window's center, so the move must come after. +function geometry(address, r) { + return [resize(address, r.w, r.h), move(address, r.x, r.y)] +} + +function setFloating(address, on) { + var t = target(address) + return t ? 'hl.dsp.window.float({ action = "' + (on ? "on" : "off") + '", ' + t + " })" : "" +} + +function toggleFloating(address) { + var t = target(address) + return t ? 'hl.dsp.window.float({ action = "toggle", ' + t + " })" : "" +} + +function setMaximizedState(address, on) { + var t = target(address) + return t ? 'hl.dsp.window.fullscreen({ mode = "maximized", action = "' + (on ? "set" : "unset") + '", ' + t + " })" : "" +} + +// Window animations turn a stream of move dispatches into a rubber band; +// they are paused on the dragged window and restored afterwards. +function setNoAnim(address, on) { + var t = target(address) + return t ? 'hl.dsp.window.set_prop({ prop = "no_anim", value = "' + (on ? "1" : "unset") + '", ' + t + " })" : "" +} + +function bringToTop(address) { + var t = target(address) + return t ? "hl.dsp.window.bring_to_top({ " + t + " })" : "" +} + +function focus(address) { + var t = target(address) + return t ? "hl.dsp.focus({ " + t + " })" : "" +} + +function close(address) { + var t = target(address) + return t ? "hl.dsp.window.close({ " + t + " })" : "" +} + +// Lua snippets for `hyprctl eval`, calling into src/lua/mousefirst.lua. +function luaString(s) { + return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, " ") + '"' +} + +function evalMinimize(address) { + var a = normalizeAddress(address) + return a ? "MOUSE_FIRST.minimize(" + luaString(a) + ")" : "" +} + +function evalRestore(address, workspace) { + var a = normalizeAddress(address) + return a ? "MOUSE_FIRST.restore(" + luaString(a) + ", " + luaString(workspace || "") + ")" : "" +} + +function evalFloatRule(enabled, center) { + return "MOUSE_FIRST.set_float_rule(" + (enabled ? "true" : "false") + ", " + (center ? "true" : "false") + ")" +} + +function evalShortcuts(enabled) { + return "MOUSE_FIRST.set_shortcuts(" + (enabled ? "true" : "false") + ")" +} diff --git a/src/js/i18n.js b/src/js/i18n.js new file mode 100644 index 0000000..22aa66d --- /dev/null +++ b/src/js/i18n.js @@ -0,0 +1,219 @@ +.pragma library + +var STRINGS = { + en: { + minimize: "Minimize", + maximize: "Maximize", + restore: "Restore", + close: "Close", + float: "Float", + tile: "Tile", + floatTile: "Float / Tile", + maxRestore: "Maximize / Restore", + untitled: "Untitled", + settingsTitle: "Mouse-First", + settingsHint: "Right-click the control bar to open or close settings", + language: "Language", + systemBehavior: "SYSTEM BEHAVIOR", + disableTiling: "Disable window tiling", + disableTilingSub: "Open every window floating, like a traditional desktop", + minimizedSectionTitle: "SYSTEM BAR APPS & WINDOWS", + minimizeToBar: "Show apps on the system bar", + minimizeToBarSub: "Pinned and open apps appear on the bar. When off, the bar is only used if no dock is running.", + minimizedAppsLocation: "Position on the bar", + minimizedAppsLocationSub: "Where the app icons appear on the system bar", + locLeft: "Left", + locCenter: "Center", + locRight: "Right", + visibility: "VISIBILITY", + alwaysVisible: "Always show the control bar", + alwaysVisibleSub: "When off, the bar appears when you hover over it", + dragging: "WINDOW DRAGGING & SNAPPING", + dragZone: "Top-edge drag zone", + dragZoneSub: "Drag windows by the strip along their top edge", + dragHandle: "Drag grip on the control bar (⠿)", + dragHandleSub: "A grip on the left of the buttons that also moves the window", + ignoreDock: "Ignore the dock when snapping", + ignoreDockSub: "Snapped and maximized windows extend over the dock area", + disableSnapping: "Disable window snapping", + disableSnappingSub: "Turns off edge snapping and its preview", + menuDragging: "CONTROL BAR POSITION", + enableMenuDrag: "Allow moving the control bar", + enableMenuDragSub: "Drag the thin handle under the buttons. Double-click it to reset.", + rememberMenuPerWindow: "Remember the position per application", + rememberMenuPerWindowSub: "Each application keeps its own position, also after restarting", + rememberMenuGlobal: "Use one position for every window", + rememberMenuGlobalSub: "The same position is applied to all windows", + alwaysShowMenuDragZone: "Always show the control bar handle", + alwaysShowMenuDragZoneSub: "Keep the handle visible without hovering", + buttonsTitle: "BUTTONS: ORDER & VISIBILITY", + buttonsHint: "Use ↑ ↓ to reorder and the switch to show or hide.", + colorTitle: "COLORS", + themeFollow: "Follow the Omarchy theme", + themeFollowSub: "Colors update automatically when the theme changes", + closeSettings: "Close settings", + centerNewWindows: "Center new windows", + centerNewWindowsSub: "Off: apps open where they choose, or where Hyprland puts them", + topEdgeMaximizes: "Drag to the top edge to maximize", + topEdgeMaximizesSub: "Off: the top edge snaps to the top half of the screen", + shortcutsTitle: "KEYBOARD SHORTCUTS", + enableShortcuts: "Enable snapping shortcuts", + enableShortcutsSub: "Super+Ctrl+Shift with ← → snap to a half, ↑ maximizes or restores, ↓ restores or minimizes", + excludedTitle: "HIDE CONTROLS", + excludedSub: "Apps where the control bar and drag strip are not shown. Small windows and picture-in-picture are always skipped.", + hideForApp: "Hide for %1", + noExcluded: "No apps hidden", + newWindow: "New window", + pin: "Pin to dock", + unpin: "Unpin from dock", + closeWindow: "Close window", + closeAllWindows: "Close all windows" + }, + pt: { + minimize: "Minimizar", + maximize: "Maximizar", + restore: "Restaurar", + close: "Fechar", + float: "Flutuar", + tile: "Tilar", + floatTile: "Flutuar / Tilar", + maxRestore: "Maximizar / Restaurar", + untitled: "Sem título", + settingsTitle: "Mouse-First", + settingsHint: "Clique com o botão direito na barra de controles para abrir ou fechar", + language: "Idioma", + systemBehavior: "COMPORTAMENTO DO SISTEMA", + disableTiling: "Desativar tiling", + disableTilingSub: "Abre todas as janelas flutuando, como num desktop tradicional", + minimizedSectionTitle: "APLICATIVOS E JANELAS NA BARRA", + minimizeToBar: "Mostrar aplicativos na barra do sistema", + minimizeToBarSub: "Apps fixados e abertos aparecem na barra. Desligado, a barra só é usada se não houver dock.", + minimizedAppsLocation: "Posição na barra", + minimizedAppsLocationSub: "Onde os ícones dos apps aparecem na barra do sistema", + locLeft: "Esquerda", + locCenter: "Centro", + locRight: "Direita", + visibility: "VISIBILIDADE", + alwaysVisible: "Barra de controles sempre visível", + alwaysVisibleSub: "Desligado, a barra aparece ao passar o mouse sobre ela", + dragging: "ARRASTO E SNAP DE JANELAS", + dragZone: "Zona de arrasto no topo da janela", + dragZoneSub: "Arraste as janelas pela faixa ao longo da borda superior", + dragHandle: "Alça de arrasto na barra de controles (⠿)", + dragHandleSub: "Uma alça à esquerda dos botões que também move a janela", + ignoreDock: "Ignorar a dock no snap", + ignoreDockSub: "Janelas com snap ou maximizadas ocupam também a área da dock", + disableSnapping: "Desativar snap de janelas", + disableSnappingSub: "Desliga o snap nas bordas e a pré-visualização", + menuDragging: "POSIÇÃO DA BARRA DE CONTROLES", + enableMenuDrag: "Permitir mover a barra de controles", + enableMenuDragSub: "Arraste a alça fina abaixo dos botões. Clique duplo para resetar.", + rememberMenuPerWindow: "Lembrar posição por aplicativo", + rememberMenuPerWindowSub: "Cada aplicativo mantém sua posição, também após reiniciar", + rememberMenuGlobal: "Usar a mesma posição em todas as janelas", + rememberMenuGlobalSub: "A mesma posição é aplicada a todas as janelas", + alwaysShowMenuDragZone: "Sempre mostrar a alça da barra de controles", + alwaysShowMenuDragZoneSub: "Mantém a alça visível sem passar o mouse", + buttonsTitle: "BOTÕES: ORDEM E VISIBILIDADE", + buttonsHint: "Use ↑ ↓ para reordenar e o botão para mostrar ou ocultar.", + colorTitle: "CORES", + themeFollow: "Seguir o tema do Omarchy", + themeFollowSub: "As cores mudam automaticamente ao trocar de tema", + closeSettings: "Fechar configurações", + centerNewWindows: "Centralizar janelas novas", + centerNewWindowsSub: "Desligado: os apps abrem onde escolherem, ou onde o Hyprland colocar", + topEdgeMaximizes: "Arrastar até o topo maximiza", + topEdgeMaximizesSub: "Desligado: o topo faz snap para a metade de cima da tela", + shortcutsTitle: "ATALHOS DE TECLADO", + enableShortcuts: "Ativar atalhos de snap", + enableShortcutsSub: "Super+Ctrl+Shift com ← → faz snap numa metade, ↑ maximiza ou restaura, ↓ restaura ou minimiza", + excludedTitle: "OCULTAR CONTROLES", + excludedSub: "Apps onde a barra de controles e a zona de arrasto não aparecem. Janelas pequenas e picture-in-picture são sempre ignoradas.", + hideForApp: "Ocultar em %1", + noExcluded: "Nenhum app oculto", + newWindow: "Nova janela", + pin: "Fixar na dock", + unpin: "Desafixar da dock", + closeWindow: "Fechar janela", + closeAllWindows: "Fechar todas as janelas" + }, + es: { + minimize: "Minimizar", + maximize: "Maximizar", + restore: "Restaurar", + close: "Cerrar", + float: "Flotar", + tile: "Mosaico", + floatTile: "Flotar / Mosaico", + maxRestore: "Maximizar / Restaurar", + untitled: "Sin título", + settingsTitle: "Mouse-First", + settingsHint: "Haz clic derecho en la barra de controles para abrir o cerrar", + language: "Idioma", + systemBehavior: "COMPORTAMIENTO DEL SISTEMA", + disableTiling: "Desactivar el mosaico", + disableTilingSub: "Abre todas las ventanas flotando, como en un escritorio tradicional", + minimizedSectionTitle: "APLICACIONES Y VENTANAS EN LA BARRA", + minimizeToBar: "Mostrar aplicaciones en la barra del sistema", + minimizeToBarSub: "Las apps fijadas y abiertas aparecen en la barra. Desactivado, la barra solo se usa si no hay dock.", + minimizedAppsLocation: "Posición en la barra", + minimizedAppsLocationSub: "Dónde aparecen los iconos de las apps en la barra del sistema", + locLeft: "Izquierda", + locCenter: "Centro", + locRight: "Derecha", + visibility: "VISIBILIDAD", + alwaysVisible: "Barra de controles siempre visible", + alwaysVisibleSub: "Desactivado, la barra aparece al pasar el ratón sobre ella", + dragging: "ARRASTRE Y AJUSTE DE VENTANAS", + dragZone: "Zona de arrastre en el borde superior", + dragZoneSub: "Arrastra las ventanas por la franja de su borde superior", + dragHandle: "Asa de arrastre en la barra de controles (⠿)", + dragHandleSub: "Un asa a la izquierda de los botones que también mueve la ventana", + ignoreDock: "Ignorar el dock al ajustar", + ignoreDockSub: "Las ventanas ajustadas o maximizadas ocupan también el área del dock", + disableSnapping: "Desactivar el ajuste de ventanas", + disableSnappingSub: "Desactiva el ajuste a los bordes y su vista previa", + menuDragging: "POSICIÓN DE LA BARRA DE CONTROLES", + enableMenuDrag: "Permitir mover la barra de controles", + enableMenuDragSub: "Arrastra el asa fina bajo los botones. Doble clic para restablecer.", + rememberMenuPerWindow: "Recordar la posición por aplicación", + rememberMenuPerWindowSub: "Cada aplicación conserva su posición, también tras reiniciar", + rememberMenuGlobal: "Usar la misma posición en todas las ventanas", + rememberMenuGlobalSub: "La misma posición se aplica a todas las ventanas", + alwaysShowMenuDragZone: "Mostrar siempre el asa de la barra de controles", + alwaysShowMenuDragZoneSub: "Mantiene el asa visible sin pasar el ratón", + buttonsTitle: "BOTONES: ORDEN Y VISIBILIDAD", + buttonsHint: "Usa ↑ ↓ para reordenar y el interruptor para mostrar u ocultar.", + colorTitle: "COLORES", + themeFollow: "Seguir el tema de Omarchy", + themeFollowSub: "Los colores cambian automáticamente al cambiar de tema", + closeSettings: "Cerrar ajustes", + centerNewWindows: "Centrar las ventanas nuevas", + centerNewWindowsSub: "Desactivado: las apps se abren donde elijan, o donde las ponga Hyprland", + topEdgeMaximizes: "Arrastrar al borde superior maximiza", + topEdgeMaximizesSub: "Desactivado: el borde superior ajusta a la mitad de arriba", + shortcutsTitle: "ATAJOS DE TECLADO", + enableShortcuts: "Activar atajos de ajuste", + enableShortcutsSub: "Super+Ctrl+Shift con ← → ajusta a una mitad, ↑ maximiza o restaura, ↓ restaura o minimiza", + excludedTitle: "OCULTAR CONTROLES", + excludedSub: "Apps donde no se muestran la barra de controles ni la zona de arrastre. Las ventanas pequeñas y el picture-in-picture siempre se omiten.", + hideForApp: "Ocultar en %1", + noExcluded: "Ninguna app oculta", + newWindow: "Nueva ventana", + pin: "Fijar en el dock", + unpin: "Quitar del dock", + closeWindow: "Cerrar ventana", + closeAllWindows: "Cerrar todas las ventanas" + } +} + +var LANGUAGES = [ + { id: "en", label: "English" }, + { id: "pt", label: "Português" }, + { id: "es", label: "Español" } +] + +function tr(language, key) { + var table = STRINGS[language] || STRINGS.en + return table[key] || STRINGS.en[key] || key +} diff --git a/src/lua/mousefirst.lua b/src/lua/mousefirst.lua new file mode 100644 index 0000000..855e603 --- /dev/null +++ b/src/lua/mousefirst.lua @@ -0,0 +1,281 @@ +-- Mouse-First compositor module. +-- +-- Loaded into Hyprland's Lua state with `hyprctl eval` by WindowTracker.qml. +-- A 16 ms timer compares the active window and the monitor layout with the +-- last values it reported, and only when something changed it emits a +-- custom IPC event (`custom>>mousefirst>>...` on socket2). The shell listens +-- to those events instead of polling hyprctl, so the controls follow a window +-- during native drags and resizes without spawning a single process. +-- +-- Everything lives in the MOUSE_FIRST global so the module can be re-evaluated +-- safely: a reload disables the previous timer before starting a new one. + +if MOUSE_FIRST and MOUSE_FIRST.timer then + MOUSE_FIRST.timer:set_enabled(false) +end + +MOUSE_FIRST = MOUSE_FIRST or {} +local M = MOUSE_FIRST +M.generation = MOUSE_FIRST_GENERATION + +-- Earlier builds kept a single rule here; retire it on reload. +if M.float_rule then + M.float_rule:set_enabled(false) + M.float_rule = nil +end + +local SEP = "\31" +local MINIMIZED = "special:minimized" + +local function clean(value) + return (tostring(value or ""):gsub("[\r\n\31]", " ")) +end + +local function emit(kind, payload) + hl.dispatch(hl.dsp.event("mousefirst>>" .. kind .. ">>" .. payload)) +end + +local function round(n) + return math.floor((tonumber(n) or 0) + 0.5) +end + +local function window_state() + local w = hl.get_active_window() + if not w or not w.mapped then return "none" end + return table.concat({ + w.address, + round(w.at.x), round(w.at.y), round(w.size.x), round(w.size.y), + w.floating and 1 or 0, + tonumber(w.fullscreen) or 0, + w.pinned and 1 or 0, + w.monitor and w.monitor.name or "", + clean(w.workspace and w.workspace.name or ""), + clean(w.class), + clean(w.title), + }, SEP) +end + +-- Monitor geometry in logical (layout) coordinates plus reserved space. +local function monitor_state() + local parts = {} + for _, m in ipairs(hl.get_monitors()) do + local scale = (m.scale and m.scale > 0) and m.scale or 1 + local w, h = m.width / scale, m.height / scale + if (tonumber(m.transform) or 0) % 2 == 1 then w, h = h, w end + local r = m.reserved or {} + parts[#parts + 1] = table.concat({ + m.name, round(m.x), round(m.y), round(w), round(h), + round(r.left), round(r.top), round(r.right), round(r.bottom), + }, ",") + end + return table.concat(parts, ";") +end + +-- Left-button tracking. Hyprland's Lua API cannot read mouse buttons, so two +-- non-consuming binds (press and release, any modifiers) record them without +-- taking the click away from apps or from Hyprland's own binds. +local function cursor() + local c = hl.get_cursor_pos() or { x = 0, y = 0 } + return round(c.x), round(c.y) +end + +-- Hyprland 0.56 crashes when any method is called on an HL.Keybind whose +-- bind no longer exists (it null-checks an optional instead of the pointer), +-- and `remove()` drops every bind with the same key and modifiers, so the +-- second of our two mouse:272 handles would be dead. The module therefore +-- never calls methods on keybind handles: the button binds are registered +-- once per Lua state (a config reload starts a fresh state) and are turned +-- off through `M.enabled`; shortcuts are removed by key name. +local function on_press() + if not MOUSE_FIRST.enabled then return end + MOUSE_FIRST.button_down = true + MOUSE_FIRST.down_rect = nil + MOUSE_FIRST.taken = false + local x, y = cursor() + emit("button", "1," .. x .. "," .. y) +end + +local function on_release() + if not MOUSE_FIRST.enabled then return end + MOUSE_FIRST.button_down = false + MOUSE_FIRST.taken = false + local x, y = cursor() + emit("button", "0," .. x .. "," .. y) +end + +local function install_binds() + -- Handles from builds before this fix: the binds still exist and their + -- callbacks are compatible; forget the handles without touching them. + if M.binds then + M.binds = nil + M.binds_installed = true + end + if M.binds_installed then return end + M.binds_installed = true + local opts = { non_consuming = true, transparent = true, ignore_mods = true, description = "Mouse-First: track left button" } + pcall(hl.bind, "mouse:272", function() on_press() end, opts) + pcall(hl.bind, "mouse:272", function() on_release() end, + { non_consuming = true, transparent = true, ignore_mods = true, release = true, description = "Mouse-First: track left button" }) +end + +-- Native moves (SUPER + drag, or an app's own title bar). Hyprland places the +-- window at "start position + pointer delta" for the whole drag, so a window +-- restored from a snap mid-drag would drift away from the cursor, and the +-- shell cannot preview or apply snaps. As soon as a floating window starts +-- moving under a held button, the drag is handed to the shell: Hyprland's +-- drag is ended (the drag dispatcher ends the current drag when called +-- outside a button press) and the shell moves the window from then on, +-- following the cursor reports below until the button is released. +local function watch_native_move() + if not M.button_down then return end + local w = hl.get_active_window() + if not w or not w.floating then return end + local r = { a = w.address, x = round(w.at.x), y = round(w.at.y), w = round(w.size.x), h = round(w.size.y), fs = tonumber(w.fullscreen) or 0 } + + if M.taken then + local x, y = cursor() + if x ~= M.last_cx or y ~= M.last_cy then + M.last_cx, M.last_cy = x, y + emit("cursor", x .. "," .. y) + end + return + end + + local d = M.down_rect + if not d or d.a ~= r.a then + M.down_rect = r + return + end + local moved = math.abs(r.x - d.x) >= 2 or math.abs(r.y - d.y) >= 2 + local sameSize = math.abs(r.w - d.w) <= 2 and math.abs(r.h - d.h) <= 2 + if moved and (sameSize or d.fs ~= 0) and r.fs == 0 then + M.taken = true + hl.dispatch(hl.dsp.window.drag()) + local x, y = cursor() + M.last_cx, M.last_cy = x, y + emit("native", table.concat({ r.a, x, y, r.x, r.y, r.w, r.h }, ",")) + else + M.down_rect = r + end +end + +function M.resend() + M.last_window, M.last_monitors = nil, nil +end + +function M.tick() + local mons = monitor_state() + if mons ~= M.last_monitors then + M.last_monitors = mons + emit("monitors", mons) + end + + local win = window_state() + if win ~= M.last_window then + M.last_window = win + emit("window", win) + end + + watch_native_move() +end + +-- "Disable tiling" is a real window rule, so windows open floating instead of +-- being floated after the fact. Rules are named and created once; toggling +-- only enables the right one (with or without centering new windows). +function M.set_float_rule(enabled, center) + if not M.float_rules then + M.float_rules = { + center = hl.window_rule({ name = "mouse-first-float-center", match = { class = ".*" }, float = true, center = true }), + plain = hl.window_rule({ name = "mouse-first-float-plain", match = { class = ".*" }, float = true }), + } + end + M.float_rules.center:set_enabled(enabled and center and true or false) + M.float_rules.plain:set_enabled(enabled and not center and true or false) +end + +-- Optional keyboard shortcuts. They only report to the shell, which knows +-- the snap state and does the work. +local SHORTCUTS = { + { keys = "SUPER + CTRL + SHIFT + LEFT", action = "left", desc = "Snap window left" }, + { keys = "SUPER + CTRL + SHIFT + RIGHT", action = "right", desc = "Snap window right" }, + { keys = "SUPER + CTRL + SHIFT + UP", action = "up", desc = "Maximize or restore window" }, + { keys = "SUPER + CTRL + SHIFT + DOWN", action = "down", desc = "Restore or minimize window" }, +} + +local function remove_shortcuts() + if M.shortcut_binds then M.shortcut_binds = nil end -- legacy handles: never touched + if not M.shortcuts_installed then return end + for _, s in ipairs(SHORTCUTS) do pcall(hl.unbind, s.keys) end + M.shortcuts_installed = false +end + +function M.set_shortcuts(enabled) + remove_shortcuts() + if not enabled then return end + for _, s in ipairs(SHORTCUTS) do + local action = s.action + pcall(hl.bind, s.keys, function() emit("shortcut", action) end, { description = "Mouse-First: " .. s.desc }) + end + M.shortcuts_installed = true +end + +-- Minimize parks the window on special:minimized (shared with omadock) and +-- hands focus to the most recent window left on the origin workspace. +function M.minimize(address) + local w = hl.get_window("address:" .. address) + if not w then return end + local origin = w.workspace and w.workspace.id + hl.dispatch(hl.dsp.window.move({ workspace = MINIMIZED, window = "address:" .. address, follow = false })) + if not origin then return end + local best + for _, other in ipairs(hl.get_workspace_windows(origin) or {}) do + if other.address ~= address and other.mapped and not other.hidden then + if not best or other.focus_history_id < best.focus_history_id then best = other end + end + end + if best then + hl.dispatch(hl.dsp.focus({ window = "address:" .. best.address })) + end +end + +-- Restore brings the window back to its origin workspace, or to the current +-- one when the origin is unknown, raises it and focuses it. +function M.restore(address, workspace) + local target = workspace + if not target or target == "" then + local ws = hl.get_active_workspace() + target = ws and tostring(ws.id) or "1" + end + hl.dispatch(hl.dsp.window.move({ workspace = target, window = "address:" .. address, follow = true })) + hl.dispatch(hl.dsp.window.bring_to_top({ window = "address:" .. address })) + hl.dispatch(hl.dsp.focus({ window = "address:" .. address })) +end + +-- v0.1 parked windows on special:mf-minimized; move them to the shared one. +function M.migrate_legacy_minimized() + for _, w in ipairs(hl.get_windows() or {}) do + if w.workspace and w.workspace.name == "special:mf-minimized" then + hl.dispatch(hl.dsp.window.move({ workspace = MINIMIZED, window = "address:" .. w.address, follow = false })) + end + end +end + +-- `generation` guards against a stale shell instance stopping a newer one. +function M.stop(generation) + if generation and generation ~= M.generation then return end + if M.timer then M.timer:set_enabled(false) end + M.enabled = false + M.button_down = false + M.taken = false + if M.float_rules then + M.float_rules.center:set_enabled(false) + M.float_rules.plain:set_enabled(false) + end + remove_shortcuts() +end + +M.enabled = true +M.migrate_legacy_minimized() +install_binds() +M.resend() +M.timer = hl.timer(M.tick, { timeout = 16, type = "repeat" }) diff --git a/src/ui/AppMenu.qml b/src/ui/AppMenu.qml new file mode 100644 index 0000000..dd19294 --- /dev/null +++ b/src/ui/AppMenu.qml @@ -0,0 +1,136 @@ +import QtQuick +import Quickshell + +// Right-click menu for a bar icon: the app's windows, a new window, pin or +// unpin (shared with omadock), and close. A popup with a focus grab, so a +// click anywhere else closes it. +PopupWindow { + id: menu + + required property var svc + property var row: null + property Item anchorItem: null + + readonly property var windows: row && row.windows ? row.windows : [] + + function openFor(r, item) { + menu.row = r + menu.anchorItem = item + menu.visible = true + } + + function run(action) { + menu.visible = false + action() + } + + anchor.item: anchorItem + anchor.edges: Edges.Bottom + anchor.gravity: Edges.Bottom + anchor.margins.top: 6 + grabFocus: true + visible: false + color: "transparent" + implicitWidth: 260 + implicitHeight: list.implicitHeight + 12 + + Rectangle { + anchors.fill: parent + radius: 10 + color: menu.svc.bg + border.color: Qt.rgba(1, 1, 1, 0.14) + border.width: 1 + + Column { + id: list + x: 6 + y: 6 + width: parent.width - 12 + + Repeater { + model: menu.windows + MenuRow { + required property var modelData + label: modelData.title || (menu.row ? menu.row.name : "") + dot: modelData.state + onClicked: menu.run(() => menu.svc.activateWindow(modelData)) + } + } + + Rectangle { + visible: menu.windows.length > 0 + width: parent.width + height: 9 + color: "transparent" + Rectangle { anchors.centerIn: parent; width: parent.width - 8; height: 1; color: Qt.rgba(1, 1, 1, 0.08) } + } + + MenuRow { + label: menu.svc.tr("newWindow") + onClicked: menu.run(() => menu.svc.launch(menu.row.appId)) + } + MenuRow { + label: menu.svc.tr(menu.row && menu.row.pinned ? "unpin" : "pin") + onClicked: { + var r = menu.row + menu.run(() => menu.svc.setPinned(r.pinned ? r.pinId : r.appId, !r.pinned)) + } + } + MenuRow { + visible: menu.windows.length > 0 + label: menu.svc.tr(menu.windows.length > 1 ? "closeAllWindows" : "closeWindow") + danger: true + onClicked: { + var r = menu.row + menu.run(() => menu.svc.closeRow(r)) + } + } + } + } + + component MenuRow: Rectangle { + id: item + property string label: "" + property string dot: "" + property bool danger: false + signal clicked() + + width: parent ? parent.width : 0 + height: 30 + radius: 6 + color: area.containsMouse ? (danger ? Qt.rgba(0.9, 0.2, 0.2, 0.7) : Qt.rgba(1, 1, 1, 0.08)) : "transparent" + + Rectangle { + visible: item.dot !== "" + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 6 + height: 6 + radius: 3 + color: item.dot === "minimized" ? menu.svc.muted : menu.svc.accent + opacity: item.dot === "active" ? 1 : 0.6 + } + + Text { + anchors.left: parent.left + anchors.leftMargin: item.dot !== "" ? 24 : 10 + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: item.label + elide: Text.ElideRight + color: area.containsMouse && item.danger ? "white" : menu.svc.fg + font.family: menu.svc.fontFamily + font.pixelSize: 12 + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: item.clicked() + } + } +} diff --git a/src/ui/AppTile.qml b/src/ui/AppTile.qml new file mode 100644 index 0000000..c0adbd0 --- /dev/null +++ b/src/ui/AppTile.qml @@ -0,0 +1,111 @@ +import QtQuick + +// One app on the bar (all of its windows). Left click: launch, toggle, or +// cycle through the windows. Middle click: close the focused window. +// Right click: the app menu. +Rectangle { + id: tile + + required property var row + required property var svc + property var bar: null + property var menu: null + + readonly property int windowCount: row.windows ? row.windows.length : 0 + + readonly property string state_: row.state + readonly property string tooltip: windowCount > 1 ? row.name + " (" + windowCount + ")" + : (row.title ? row.name + " — " + row.title : (row.name || svc.tr("untitled"))) + + width: 26 + height: 26 + radius: 6 + color: { + var hover = area.containsMouse + if (state_ === "active") return Qt.rgba(1, 1, 1, hover ? 0.22 : 0.14) + if (state_ === "running") return Qt.rgba(1, 1, 1, hover ? 0.16 : 0.06) + if (state_ === "minimized") return Qt.rgba(1, 1, 1, hover ? 0.14 : 0.04) + return hover ? Qt.rgba(1, 1, 1, 0.12) : "transparent" + } + border.color: state_ === "active" ? Qt.rgba(svc.accent.r, svc.accent.g, svc.accent.b, 0.5) + : (area.containsMouse ? Qt.rgba(1, 1, 1, 0.22) : "transparent") + border.width: 1 + Behavior on color { ColorAnimation { duration: 80 } } + + Image { + id: icon + anchors.centerIn: parent + width: 16 + height: 16 + sourceSize: Qt.size(32, 32) + smooth: true + asynchronous: true + fillMode: Image.PreserveAspectFit + source: tile.row.icon || "" + opacity: tile.state_ === "minimized" ? 0.55 : (tile.state_ === "closed" ? 0.8 : 1) + visible: status === Image.Ready + } + + // Letter fallback when no icon can be found. + Text { + anchors.centerIn: parent + visible: !icon.visible + text: String(tile.row.name || tile.row.appId || "?").charAt(0).toUpperCase() + color: tile.svc.accent + font.family: tile.svc.fontFamily + font.pixelSize: 11 + font.bold: true + } + + // State dot: long for the focused window, short for running/minimized. + Rectangle { + anchors.bottom: parent.bottom + anchors.bottomMargin: 1.5 + anchors.horizontalCenter: parent.horizontalCenter + visible: tile.state_ !== "closed" + width: tile.state_ === "active" ? 10 : 4 + height: tile.state_ === "active" ? 2.5 : 2 + radius: 1.25 + color: tile.state_ === "minimized" ? tile.svc.muted : tile.svc.accent + opacity: tile.state_ === "active" ? 1 : (tile.state_ === "running" ? 0.85 : 0.65) + } + + // Window count when the app has more than one. + Rectangle { + visible: tile.windowCount > 1 + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: -3 + anchors.topMargin: 0 + width: Math.max(12, countText.implicitWidth + 4) + height: 12 + radius: 6 + color: tile.svc.accent + Text { + id: countText + anchors.centerIn: parent + text: tile.windowCount > 9 ? "9+" : String(tile.windowCount) + color: tile.svc.bg + font.family: tile.svc.fontFamily + font.pixelSize: 8 + font.bold: true + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + + onEntered: if (tile.bar && tile.bar.showTooltip) tile.bar.showTooltip(tile, tile.tooltip) + onExited: if (tile.bar && tile.bar.hideTooltip) tile.bar.hideTooltip(tile) + onClicked: mouse => { + if (tile.bar && tile.bar.hideTooltip) tile.bar.hideTooltip(tile) + if (mouse.button === Qt.LeftButton) tile.svc.activateRow(tile.row) + else if (mouse.button === Qt.RightButton) { if (tile.menu) tile.menu.openFor(tile.row, tile) } + else if (tile.row.address) tile.svc.close(tile.row.address) + } + } +} diff --git a/src/ui/ButtonIcon.qml b/src/ui/ButtonIcon.qml new file mode 100644 index 0000000..b510b7d --- /dev/null +++ b/src/ui/ButtonIcon.qml @@ -0,0 +1,92 @@ +import QtQuick + +// Vector glyphs for the window buttons, drawn with rectangles so they stay +// crisp at any scale and follow the theme colour. +Item { + id: icon + + required property string kind // float | minimize | maximize | close + property color color: "white" + property color background: "transparent" + property bool maximized: false + property bool floating: true + + width: 14 + height: 14 + + Rectangle { + visible: icon.kind === "minimize" + anchors.centerIn: parent + width: 10; height: 2; radius: 1 + color: icon.color + } + + Rectangle { + visible: icon.kind === "maximize" && !icon.maximized + anchors.centerIn: parent + width: 10; height: 10; radius: 1.5 + color: "transparent" + border.color: icon.color + border.width: 1.5 + } + + Item { + visible: icon.kind === "maximize" && icon.maximized + anchors.centerIn: parent + width: 11; height: 11 + Rectangle { + x: 2; y: 0; width: 8; height: 8; radius: 1 + color: "transparent" + border.color: icon.color + border.width: 1.2 + } + Rectangle { + x: 0; y: 3; width: 8; height: 8; radius: 1 + color: icon.background + border.color: icon.color + border.width: 1.2 + } + } + + Item { + visible: icon.kind === "close" + anchors.centerIn: parent + width: 14; height: 14 + Repeater { + model: [45, -45] + Rectangle { + required property var modelData + anchors.centerIn: parent + width: 13; height: 1.8; radius: 0.9 + rotation: modelData + antialiasing: true + color: icon.color + } + } + } + + // Float: a single frame; tile: a frame split in two. + Item { + visible: icon.kind === "float" + anchors.centerIn: parent + width: 11; height: 11 + Rectangle { + anchors.fill: parent + radius: 1.5 + color: "transparent" + border.color: icon.color + border.width: 1.4 + } + Rectangle { + visible: !icon.floating + anchors.centerIn: parent + width: 1.4; height: parent.height + color: icon.color + } + Rectangle { + visible: icon.floating + x: 3; y: 3; width: 5; height: 5; radius: 1 + color: icon.color + } + } +} diff --git a/src/ui/ControlBubble.qml b/src/ui/ControlBubble.qml new file mode 100644 index 0000000..b434022 --- /dev/null +++ b/src/ui/ControlBubble.qml @@ -0,0 +1,200 @@ +import QtQuick +import QtQuick.Layouts + +// The floating capsule with the window buttons, pinned to the top-right +// corner of the active window (plus the user's custom offset). +Rectangle { + id: bubble + + required property var svc + required property var overlay + property var win: null // window rect in overlay coordinates + + readonly property var cfg: svc.cfg + readonly property var buttons: cfg.buttonOrder.filter(b => cfg.buttonVisible[b] !== false) + readonly property bool revealed: cfg.alwaysVisible || hover.hovered || svc.settingsOpen || svc.menuDragging || grip.pressed + + visible: !!win + width: 12 + (cfg.showDragHandle ? 26 : 0) + buttons.length * 28 + Math.max(0, buttons.length - 1) * 4 + height: 34 + radius: 10 + color: svc.bg + border.color: Qt.rgba(1, 1, 1, 0.18) + border.width: 1 + + x: win ? Math.max(0, Math.min(overlay.width - width, win.x + win.w - width + svc.menuOffset.x)) : 0 + y: win ? Math.max(overlay.reservedTop, Math.min(overlay.height - height, win.y + svc.menuOffset.y)) : 0 + + opacity: revealed ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } + + HoverHandler { id: hover } + + // Right-click anywhere on the capsule toggles the settings. + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: bubble.svc.toggleSettings() + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 4 + + // Optional grip: drags the window like the top-edge zone. + Item { + id: gripItem + visible: bubble.cfg.showDragHandle + Layout.preferredWidth: 22 + Layout.preferredHeight: 28 + + Grid { + anchors.centerIn: parent + columns: 2 + spacing: 3 + Repeater { + model: 6 + Rectangle { + width: 3; height: 3; radius: 1.5 + color: bubble.svc.fg + opacity: grip.containsMouse || grip.pressed ? 0.75 : 0.3 + } + } + } + + MouseArea { + id: grip + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.SizeAllCursor + onPressed: mouse => bubble.svc.pressWindow(bubble.overlay.screen.name, bubble.overlay.toGlobal(grip, mouse.x, mouse.y)) + onPositionChanged: mouse => { if (pressed) bubble.svc.moveWindow(bubble.overlay.toGlobal(grip, mouse.x, mouse.y)) } + onReleased: bubble.svc.releaseWindow() + onCanceled: bubble.svc.releaseWindow() + } + } + + Repeater { + model: bubble.buttons + delegate: Rectangle { + id: button + required property string modelData + readonly property bool isClose: modelData === "close" + readonly property bool hovered: ma.containsMouse + readonly property color iconColor: hovered && isClose ? "white" + : (modelData === "float" && bubble.svc.win && bubble.svc.win.floating ? bubble.svc.accent : Qt.rgba(1, 1, 1, 0.85)) + + Layout.preferredWidth: 28 + Layout.preferredHeight: 28 + radius: 7 + color: { + if (isClose && ma.pressed) return Qt.rgba(0.85, 0.15, 0.15, 0.9) + if (isClose && hovered) return Qt.rgba(0.9, 0.2, 0.2, 0.7) + if (ma.pressed) return Qt.rgba(1, 1, 1, 0.15) + if (hovered) return Qt.rgba(1, 1, 1, 0.09) + return "transparent" + } + Behavior on color { ColorAnimation { duration: 80 } } + + ButtonIcon { + anchors.centerIn: parent + kind: button.modelData + color: button.iconColor + background: button.hovered && button.isClose ? "white" : bubble.svc.bg + maximized: bubble.svc.winMaximized + floating: !!bubble.svc.win && bubble.svc.win.floating + } + + MouseArea { + id: ma + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.RightButton) bubble.svc.toggleSettings() + else bubble.svc.trigger(button.modelData) + } + } + + // Tooltip below the button: above it would collide with the bar. + Rectangle { + visible: button.hovered && !bubble.svc.settingsOpen + anchors.top: parent.bottom + anchors.topMargin: 8 + anchors.horizontalCenter: parent.horizontalCenter + width: tip.implicitWidth + 14 + height: tip.implicitHeight + 8 + radius: 5 + color: Qt.rgba(0, 0, 0, 0.85) + border.color: Qt.rgba(1, 1, 1, 0.12) + border.width: 1 + Text { + id: tip + anchors.centerIn: parent + color: "white" + font.family: bubble.svc.fontFamily + font.pixelSize: 11 + text: { + var id = button.modelData + var w = bubble.svc.win + if (id === "float") return bubble.svc.tr(w && w.floating ? "tile" : "float") + if (id === "maximize") return bubble.svc.tr(bubble.svc.winMaximized ? "restore" : "maximize") + return bubble.svc.tr(id) + } + } + } + } + } + } + + // Thin handle along the bottom edge: drag to move the capsule itself, + // double-click to put it back in the corner. + Rectangle { + anchors.bottom: parent.bottom + anchors.bottomMargin: 2 + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - 24, 34) + height: 2 + radius: 1 + color: bubble.svc.accent + visible: bubble.cfg.enableMenuDrag + opacity: bubble.cfg.alwaysShowMenuDragZone || handle.containsMouse || handle.pressed ? 0.85 : 0 + Behavior on opacity { NumberAnimation { duration: 160 } } + } + + MouseArea { + id: handle + property point startMouse + property var startOffset + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - 16, 46) + height: 6 + z: 10 + enabled: bubble.cfg.enableMenuDrag + hoverEnabled: true + cursorShape: Qt.SizeAllCursor + + onPressed: mouse => { + startMouse = bubble.overlay.toGlobal(handle, mouse.x, mouse.y) + startOffset = bubble.svc.menuOffset + bubble.svc.menuDragging = true + } + onPositionChanged: mouse => { + if (!pressed || !bubble.win) return + var p = bubble.overlay.toGlobal(handle, mouse.x, mouse.y) + // Keep the capsule on screen: offsets are relative to the corner. + var baseX = bubble.win.x + bubble.win.w - bubble.width + var baseY = bubble.win.y + bubble.svc.setMenuOffset({ + x: Math.max(-baseX, Math.min(bubble.overlay.width - bubble.width - baseX, startOffset.x + p.x - startMouse.x)), + y: Math.max(bubble.overlay.reservedTop - baseY, Math.min(bubble.overlay.height - bubble.height - baseY, startOffset.y + p.y - startMouse.y)) + }) + } + onReleased: { bubble.svc.menuDragging = false; bubble.svc.commitMenuOffset() } + onCanceled: { bubble.svc.menuDragging = false; bubble.svc.commitMenuOffset() } + onDoubleClicked: bubble.svc.resetMenuOffset() + } +} diff --git a/src/ui/DragZone.qml b/src/ui/DragZone.qml new file mode 100644 index 0000000..289197d --- /dev/null +++ b/src/ui/DragZone.qml @@ -0,0 +1,61 @@ +import QtQuick + +// Invisible strip along the top edge of the active window. Press and drag it +// to move the window; double-click to maximize/restore; right-click opens +// the settings. A thin accent line fades in on hover. +Item { + id: zone + + required property var svc + required property var overlay + property var win: null // window rect in overlay coordinates + property real rightInset: 120 // keeps clear of the control bubble + + readonly property bool managed: zone.svc.winMaximized || !!(zone.svc.win && zone.svc.states.entry(zone.svc.win.address)) + // Leave room for back/refresh buttons at the left of floating apps; a + // snapped or maximized window gets the full width so it can be pulled + // out from anywhere along the top. + readonly property real leftInset: managed ? 6 : 72 + + enabled: !!win && zone.svc.cfg.dragFullWidth + visible: enabled + x: win ? win.x + leftInset : 0 + y: win ? win.y : 0 + width: win ? Math.max(0, win.w - leftInset - rightInset) : 0 + height: 8 + + Rectangle { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: Math.max(80, Math.min(parent.width - 40, Math.round(parent.width * 0.45))) + height: 3 + radius: 1.5 + color: zone.svc.accent + opacity: (area.containsMouse || zone.svc.drag.active) ? 0.65 : 0 + Behavior on opacity { NumberAnimation { duration: 160 } } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: zone.svc.drag.active ? Qt.ClosedHandCursor : Qt.SizeAllCursor + + onPressed: mouse => { + if (mouse.button === Qt.RightButton) { + zone.svc.toggleSettings() + return + } + zone.svc.pressWindow(zone.overlay.screen.name, zone.overlay.toGlobal(area, mouse.x, mouse.y)) + } + onPositionChanged: mouse => { + if (pressed) zone.svc.moveWindow(zone.overlay.toGlobal(area, mouse.x, mouse.y)) + } + onReleased: zone.svc.releaseWindow() + onCanceled: zone.svc.releaseWindow() + onDoubleClicked: mouse => { + if (mouse.button === Qt.LeftButton) zone.svc.trigger("maximize") + } + } +} diff --git a/src/ui/Overlay.qml b/src/ui/Overlay.qml new file mode 100644 index 0000000..adc61fc --- /dev/null +++ b/src/ui/Overlay.qml @@ -0,0 +1,97 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland + +// Transparent layer covering one screen. Only the controls, the drag strip +// and the settings panel take input (see `mask`); everything else clicks +// through to the windows underneath. +PanelWindow { + id: overlay + + required property var service + readonly property var svc: service + + // Monitor geometry as Hyprland reports it (global layout coordinates). + readonly property var monitor: svc.tracker.monitorByName(screen ? screen.name : "") + readonly property real originX: monitor ? monitor.x : (screen ? screen.x : 0) + readonly property real originY: monitor ? monitor.y : (screen ? screen.y : 0) + readonly property real reservedTop: monitor ? monitor.reserved.top : 0 + + // This overlay draws the controls when the active window is on its + // monitor. During a drag it keeps them (and the pointer grab) until the + // button is released, even if the window crosses to another screen. + readonly property bool hosting: { + if (!svc.controlsVisible || !screen) return false + if (svc.dragScreen !== "") return svc.dragScreen === screen.name + return svc.winMonitor === screen.name + } + // Active window rect in this overlay's local coordinates. + readonly property var winLocal: hosting && svc.winRect + ? { x: svc.winRect.x - originX, y: svc.winRect.y - originY, w: svc.winRect.w, h: svc.winRect.h } + : null + readonly property bool settingsHere: svc.settingsOpen && screen && svc.settingsMonitor === screen.name + + WlrLayershell.namespace: "mouse-first-controls" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + + mask: Region { + Region { item: overlay.hosting && overlay.svc.drag.pressed && !overlay.svc.drag.handedOver ? content : null } + Region { item: overlay.settingsHere ? content : null } + Region { item: dragZone.enabled ? dragZone : null } + Region { item: bubble.visible ? bubble : null } + } + + Item { + id: content + anchors.fill: parent + + SnapPreview { + svc: overlay.svc + originX: overlay.originX + originY: overlay.originY + screenW: overlay.width + screenH: overlay.height + } + + DragZone { + id: dragZone + svc: overlay.svc + overlay: overlay + win: overlay.winLocal + rightInset: bubble.width + 12 + } + + ControlBubble { + id: bubble + svc: overlay.svc + overlay: overlay + win: overlay.winLocal + } + + // Clicking anywhere outside the panel closes it. + MouseArea { + anchors.fill: parent + visible: overlay.settingsHere + acceptedButtons: Qt.AllButtons + onPressed: overlay.svc.settingsOpen = false + } + + SettingsPanel { + svc: overlay.svc + visible: overlay.settingsHere + anchorItem: bubble.visible ? bubble : null + maxHeight: overlay.height - overlay.reservedTop - 24 + topLimit: overlay.reservedTop + 8 + } + } + + // Converts an item-local mouse position to global layout coordinates. + function toGlobal(item, x, y) { + var p = item.mapToItem(content, x, y) + return { x: p.x + overlay.originX, y: p.y + overlay.originY } + } +} diff --git a/src/ui/SettingsPanel.qml b/src/ui/SettingsPanel.qml new file mode 100644 index 0000000..bcf3e0e --- /dev/null +++ b/src/ui/SettingsPanel.qml @@ -0,0 +1,448 @@ +import QtQuick +import QtQuick.Layouts +import "../js/i18n.js" as I18n + +// Settings, opened with a right-click on the control bar. Every change is +// saved immediately to ~/.config/omarchy/mouse-first.json. +Rectangle { + id: panel + + required property var svc + property Item anchorItem: null + property real maxHeight: 800 + property real topLimit: 8 + + readonly property var cfg: svc.cfg + readonly property Item host: parent + + width: 390 + height: Math.min(maxHeight, body.implicitHeight + 32) + radius: 12 + color: svc.bg + border.color: Qt.rgba(1, 1, 1, 0.12) + border.width: 1 + + x: { + var hostW = host ? host.width : 1920 + var want = anchorItem ? anchorItem.x + anchorItem.width - width : (hostW - width) / 2 + return Math.max(8, Math.min(hostW - width - 8, want)) + } + y: { + var hostH = host ? host.height : 1080 + var want = anchorItem ? anchorItem.y + anchorItem.height + 8 : (hostH - height) / 2 + return Math.max(topLimit, Math.min(hostH - height - 8, want)) + } + + function t(key) { return panel.svc.tr(key) } + function set(key, value) { panel.svc.cfgFile.set(key, value) } + + // Swallow clicks so they do not reach the backdrop behind the panel. + MouseArea { anchors.fill: parent; acceptedButtons: Qt.AllButtons } + + Flickable { + anchors.fill: parent + anchors.margins: 16 + contentHeight: body.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: body + width: parent.width + spacing: 12 + + Text { + text: panel.t("settingsTitle") + color: panel.svc.fg + font.family: panel.svc.fontFamily + font.pixelSize: 14 + font.bold: true + } + Caption { text: panel.t("settingsHint") } + Divider {} + + // Language + Row { + width: parent.width + spacing: 8 + Text { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - chips.width - 8 + leftPadding: 4 + text: panel.t("language") + color: panel.svc.fg + font.family: panel.svc.fontFamily + font.pixelSize: 12 + } + Row { + id: chips + spacing: 4 + Repeater { + model: I18n.LANGUAGES + Chip { + required property var modelData + label: modelData.label + selected: panel.cfg.language === modelData.id + onClicked: panel.set("language", modelData.id) + } + } + } + } + + Section { text: panel.t("systemBehavior") } + SettingsToggle { + svc: panel.svc + label: panel.t("disableTiling"); sublabel: panel.t("disableTilingSub") + checked: panel.cfg.disableTiling + onToggled: panel.set("disableTiling", !panel.cfg.disableTiling) + } + SettingsToggle { + x: 16 + width: parent.width - 16 + svc: panel.svc + enabled: panel.cfg.disableTiling + label: panel.t("centerNewWindows"); sublabel: panel.t("centerNewWindowsSub") + checked: panel.cfg.centerNewWindows + onToggled: panel.set("centerNewWindows", !panel.cfg.centerNewWindows) + } + + Section { text: panel.t("minimizedSectionTitle") } + SettingsToggle { + svc: panel.svc + label: panel.t("minimizeToBar"); sublabel: panel.t("minimizeToBarSub") + checked: panel.cfg.minimizeToBar + onToggled: panel.set("minimizeToBar", !panel.cfg.minimizeToBar) + } + Column { + x: 16 + width: parent.width - 16 + spacing: 6 + enabled: panel.svc.showAppsInBar + opacity: enabled ? 1 : 0.35 + Text { + width: parent.width + text: panel.t("minimizedAppsLocation") + color: panel.svc.fg + font.family: panel.svc.fontFamily + font.pixelSize: 12 + } + Caption { text: panel.t("minimizedAppsLocationSub") } + Row { + spacing: 4 + topPadding: 2 + Repeater { + model: [ + { id: "left", key: "locLeft" }, + { id: "center", key: "locCenter" }, + { id: "right", key: "locRight" } + ] + Chip { + required property var modelData + label: panel.t(modelData.key) + selected: panel.svc.barSection === modelData.id + onClicked: panel.svc.setBarSection(modelData.id) + } + } + } + } + + Section { text: panel.t("visibility") } + SettingsToggle { + svc: panel.svc + label: panel.t("alwaysVisible"); sublabel: panel.t("alwaysVisibleSub") + checked: panel.cfg.alwaysVisible + onToggled: panel.set("alwaysVisible", !panel.cfg.alwaysVisible) + } + + Section { text: panel.t("dragging") } + SettingsToggle { + svc: panel.svc + label: panel.t("dragZone"); sublabel: panel.t("dragZoneSub") + checked: panel.cfg.dragFullWidth + onToggled: panel.set("dragFullWidth", !panel.cfg.dragFullWidth) + } + SettingsToggle { + svc: panel.svc + label: panel.t("dragHandle"); sublabel: panel.t("dragHandleSub") + checked: panel.cfg.showDragHandle + onToggled: panel.set("showDragHandle", !panel.cfg.showDragHandle) + } + SettingsToggle { + svc: panel.svc + label: panel.t("ignoreDock"); sublabel: panel.t("ignoreDockSub") + checked: panel.cfg.ignoreDock + onToggled: panel.set("ignoreDock", !panel.cfg.ignoreDock) + } + SettingsToggle { + svc: panel.svc + label: panel.t("disableSnapping"); sublabel: panel.t("disableSnappingSub") + checked: panel.cfg.disableSnapping + onToggled: panel.set("disableSnapping", !panel.cfg.disableSnapping) + } + SettingsToggle { + svc: panel.svc + enabled: !panel.cfg.disableSnapping + label: panel.t("topEdgeMaximizes"); sublabel: panel.t("topEdgeMaximizesSub") + checked: panel.cfg.topEdgeMaximizes + onToggled: panel.set("topEdgeMaximizes", !panel.cfg.topEdgeMaximizes) + } + + Section { text: panel.t("shortcutsTitle") } + SettingsToggle { + svc: panel.svc + label: panel.t("enableShortcuts"); sublabel: panel.t("enableShortcutsSub") + checked: panel.cfg.enableShortcuts + onToggled: panel.set("enableShortcuts", !panel.cfg.enableShortcuts) + } + + Section { text: panel.t("excludedTitle") } + Caption { text: panel.t("excludedSub") } + Flow { + width: parent.width + spacing: 4 + Repeater { + model: panel.cfg.excludedClasses + Chip { + required property var modelData + label: modelData + " ✕" + selected: true + onClicked: panel.svc.setExcluded(modelData, false) + } + } + Chip { + readonly property string cls: panel.svc.win ? String(panel.svc.win.cls || "") : "" + visible: cls !== "" && panel.cfg.excludedClasses.indexOf(cls) < 0 + label: panel.t("hideForApp").replace("%1", cls) + onClicked: panel.svc.setExcluded(cls, true) + } + Caption { + width: implicitWidth + visible: panel.cfg.excludedClasses.length === 0 && !(panel.svc.win && panel.svc.win.cls) + text: panel.t("noExcluded") + } + } + + Section { text: panel.t("menuDragging") } + SettingsToggle { + svc: panel.svc + label: panel.t("enableMenuDrag"); sublabel: panel.t("enableMenuDragSub") + checked: panel.cfg.enableMenuDrag + onToggled: panel.set("enableMenuDrag", !panel.cfg.enableMenuDrag) + } + Column { + x: 16 + width: parent.width - 16 + spacing: 8 + enabled: panel.cfg.enableMenuDrag + SettingsToggle { + svc: panel.svc + label: panel.t("rememberMenuPerWindow"); sublabel: panel.t("rememberMenuPerWindowSub") + checked: panel.cfg.rememberMenuPerWindow + onToggled: { + var on = !panel.cfg.rememberMenuPerWindow + panel.set("rememberMenuPerWindow", on) + if (on) panel.set("rememberMenuGlobal", false) + } + } + SettingsToggle { + svc: panel.svc + label: panel.t("rememberMenuGlobal"); sublabel: panel.t("rememberMenuGlobalSub") + checked: panel.cfg.rememberMenuGlobal + onToggled: { + var on = !panel.cfg.rememberMenuGlobal + panel.set("rememberMenuGlobal", on) + if (on) panel.set("rememberMenuPerWindow", false) + } + } + SettingsToggle { + svc: panel.svc + label: panel.t("alwaysShowMenuDragZone"); sublabel: panel.t("alwaysShowMenuDragZoneSub") + checked: panel.cfg.alwaysShowMenuDragZone + onToggled: panel.set("alwaysShowMenuDragZone", !panel.cfg.alwaysShowMenuDragZone) + } + } + + Section { text: panel.t("buttonsTitle") } + Caption { text: panel.t("buttonsHint") } + Column { + width: parent.width + spacing: 4 + Repeater { + model: panel.cfg.buttonOrder + delegate: Rectangle { + id: btnRow + required property string modelData + required property int index + readonly property bool shown: panel.cfg.buttonVisible[modelData] !== false + width: parent.width + height: 40 + radius: 8 + color: Qt.rgba(1, 1, 1, 0.04) + border.color: Qt.rgba(1, 1, 1, 0.07) + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Rectangle { + Layout.preferredWidth: 26 + Layout.preferredHeight: 26 + radius: 6 + color: Qt.rgba(1, 1, 1, 0.07) + ButtonIcon { + anchors.centerIn: parent + kind: btnRow.modelData + color: btnRow.modelData === "close" ? panel.svc.red : panel.svc.fg + } + } + Text { + Layout.fillWidth: true + text: { + var id = btnRow.modelData + if (id === "float") return panel.t("floatTile") + if (id === "maximize") return panel.t("maxRestore") + return panel.t(id) + } + color: panel.svc.fg + font.family: panel.svc.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + } + ArrowButton { + glyph: "↑" + active: btnRow.index > 0 + onClicked: panel.svc.moveButton(btnRow.index, -1) + } + ArrowButton { + glyph: "↓" + active: btnRow.index < panel.cfg.buttonOrder.length - 1 + onClicked: panel.svc.moveButton(btnRow.index, 1) + } + Switch { + svc: panel.svc + checked: btnRow.shown + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: panel.svc.setButtonVisible(btnRow.modelData, !btnRow.shown) + } + } + } + } + } + } + + Section { text: panel.t("colorTitle") } + SettingsToggle { + svc: panel.svc + label: panel.t("themeFollow"); sublabel: panel.t("themeFollowSub") + checked: panel.cfg.colorMode !== "custom" + onToggled: panel.set("colorMode", panel.cfg.colorMode === "custom" ? "theme" : "custom") + } + + Divider {} + Rectangle { + width: parent.width + height: 32 + radius: 6 + color: closeArea.containsMouse ? Qt.rgba(1, 1, 1, 0.08) : "transparent" + Text { + anchors.centerIn: parent + text: panel.t("closeSettings") + color: panel.svc.muted + font.family: panel.svc.fontFamily + font.pixelSize: 12 + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: panel.svc.settingsOpen = false + } + } + } + } + + component Section: Text { + width: parent ? parent.width : 0 + topPadding: 4 + color: panel.svc.muted + font.family: panel.svc.fontFamily + font.pixelSize: 9 + font.bold: true + font.letterSpacing: 0.5 + } + + component Caption: Text { + width: parent ? parent.width : 0 + color: panel.svc.muted + font.family: panel.svc.fontFamily + font.pixelSize: 10 + wrapMode: Text.Wrap + } + + component Divider: Rectangle { + width: parent ? parent.width : 0 + height: 1 + color: Qt.rgba(1, 1, 1, 0.08) + } + + component Chip: Rectangle { + id: chip + property string label: "" + property bool selected: false + signal clicked() + width: chipText.implicitWidth + 20 + height: 24 + radius: 12 + color: selected ? Qt.rgba(panel.svc.accent.r, panel.svc.accent.g, panel.svc.accent.b, 0.22) + : (chipArea.containsMouse ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(1, 1, 1, 0.04)) + border.color: selected ? panel.svc.accent : Qt.rgba(1, 1, 1, 0.1) + border.width: 1 + Behavior on color { ColorAnimation { duration: 100 } } + Text { + id: chipText + anchors.centerIn: parent + text: chip.label + color: chip.selected ? panel.svc.fg : panel.svc.muted + font.family: panel.svc.fontFamily + font.pixelSize: 11 + } + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: chip.clicked() + } + } + + component ArrowButton: Rectangle { + id: arrow + property string glyph: "" + property bool active: true + signal clicked() + Layout.preferredWidth: 22 + Layout.preferredHeight: 22 + radius: 5 + color: arrowArea.containsMouse && active ? Qt.rgba(1, 1, 1, 0.10) : Qt.rgba(1, 1, 1, 0.04) + Text { + anchors.centerIn: parent + text: arrow.glyph + color: panel.svc.fg + opacity: arrow.active ? 1 : 0.25 + font.pixelSize: 13 + } + MouseArea { + id: arrowArea + anchors.fill: parent + hoverEnabled: true + enabled: arrow.active + onClicked: arrow.clicked() + } + } +} diff --git a/src/ui/SettingsToggle.qml b/src/ui/SettingsToggle.qml new file mode 100644 index 0000000..f1873e5 --- /dev/null +++ b/src/ui/SettingsToggle.qml @@ -0,0 +1,60 @@ +import QtQuick + +// A labelled switch row for the settings panel. +Item { + id: row + + required property var svc + property string label: "" + property string sublabel: "" + property bool checked: false + signal toggled() + + width: parent ? parent.width : 300 + height: Math.max(36, labels.implicitHeight + 10) + opacity: enabled ? 1 : 0.35 + Behavior on opacity { NumberAnimation { duration: 150 } } + + Column { + id: labels + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.right: pill.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Text { + width: parent.width + text: row.label + color: row.svc.fg + font.family: row.svc.fontFamily + font.pixelSize: 12 + wrapMode: Text.Wrap + } + Text { + width: parent.width + visible: row.sublabel !== "" + text: row.sublabel + color: row.svc.muted + font.family: row.svc.fontFamily + font.pixelSize: 10 + wrapMode: Text.Wrap + } + } + + Switch { + id: pill + anchors.right: parent.right + anchors.rightMargin: 4 + anchors.verticalCenter: parent.verticalCenter + svc: row.svc + checked: row.checked + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: row.toggled() + } + +} diff --git a/src/ui/SnapPreview.qml b/src/ui/SnapPreview.qml new file mode 100644 index 0000000..5f3703f --- /dev/null +++ b/src/ui/SnapPreview.qml @@ -0,0 +1,38 @@ +import QtQuick + +// Outline of where the window will land if released now. Each overlay draws +// the part of the target that falls on its own screen. +Rectangle { + id: preview + + required property var svc + property real originX: 0 + property real originY: 0 + property real screenW: 0 + property real screenH: 0 + + readonly property var target: svc.drag.active ? svc.drag.zoneRect : null + readonly property bool onThisScreen: !!target && + target.x < originX + screenW && target.x + target.w > originX && + target.y < originY + screenH && target.y + target.h > originY + + property var shown: null + onTargetChanged: if (target) shown = target + + visible: opacity > 0.01 + opacity: onThisScreen ? 1 : 0 + x: shown ? shown.x - originX : 0 + y: shown ? shown.y - originY : 0 + width: shown ? shown.w : 0 + height: shown ? shown.h : 0 + radius: 12 + color: Qt.rgba(svc.accent.r, svc.accent.g, svc.accent.b, 0.16) + border.color: svc.accent + border.width: 2 + + Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + Behavior on y { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + Behavior on width { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + Behavior on height { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 140 } } +} diff --git a/src/ui/Switch.qml b/src/ui/Switch.qml new file mode 100644 index 0000000..a3ed23d --- /dev/null +++ b/src/ui/Switch.qml @@ -0,0 +1,23 @@ +import QtQuick + +// On/off pill used by the settings rows. +Rectangle { + required property var svc + property bool checked: false + + width: 36 + height: 20 + radius: 10 + color: checked ? svc.accent : Qt.rgba(1, 1, 1, 0.12) + Behavior on color { ColorAnimation { duration: 150 } } + + Rectangle { + x: parent.checked ? 18 : 2 + y: 2 + width: 16 + height: 16 + radius: 8 + color: "white" + Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + } +} diff --git a/tests/geometry.test.mjs b/tests/geometry.test.mjs new file mode 100644 index 0000000..ec41269 --- /dev/null +++ b/tests/geometry.test.mjs @@ -0,0 +1,154 @@ +// Run with: node --test tests/*.test.mjs +import { test } from "node:test" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import vm from "node:vm" + +// geometry.js is a QML `.pragma library` script; drop the pragma and run it +// as a CommonJS-style module. +const source = readFileSync(new URL("../src/js/geometry.js", import.meta.url), "utf8") + .replace(/^\.pragma library\s*$/m, "") +const sandbox = { module: { exports: {} } } +vm.runInNewContext(source, sandbox) +const G = sandbox.module.exports +const plain = (v) => JSON.parse(JSON.stringify(v)) + +const monitor = { name: "A", x: 0, y: 0, w: 2560, h: 1440 } +const reserved = { left: 0, top: 26, right: 0, bottom: 69 } +const area = G.usableArea(monitor, reserved, 6) + +test("usable area subtracts reserved space on every side and the gap", () => { + assert.deepEqual(plain(area), { x: 6, y: 32, w: 2548, h: 1333 }) + const side = G.usableArea({ x: 100, y: 50, w: 1000, h: 800 }, { left: 30, top: 0, right: 10, bottom: 0 }, 0) + assert.deepEqual(plain(side), { x: 130, y: 50, w: 960, h: 800 }) +}) + +test("zone rects tile the usable area without overlap", () => { + const g = 6 + const l = G.zoneRect("left", area, g) + const r = G.zoneRect("right", area, g) + assert.equal(l.x, area.x) + assert.equal(l.x + l.w + g, r.x) + assert.equal(r.x + r.w, area.x + area.w) + const tl = G.zoneRect("top-left", area, g) + const bl = G.zoneRect("bottom-left", area, g) + assert.equal(tl.y + tl.h + g, bl.y) + assert.equal(bl.y + bl.h, area.y + area.h) + assert.deepEqual(plain(G.zoneRect("maximize", area, g)), plain(area)) + assert.equal(G.zoneRect("nope", area, g), null) +}) + +test("zones follow a monitor with an offset", () => { + const second = { x: 2560, y: -200, w: 1920, h: 1080 } + const a2 = G.usableArea(second, { left: 0, top: 26, right: 0, bottom: 0 }, 0) + const r = G.zoneRect("right", a2, 0) + assert.equal(r.x + r.w, 2560 + 1920) + assert.equal(r.y, -200 + 26) +}) + +test("monitorAt finds the monitor under a point, or the nearest one", () => { + const b = { name: "B", x: 2560, y: 0, w: 1920, h: 1080 } + assert.equal(G.monitorAt([monitor, b], 3000, 500).name, "B") + assert.equal(G.monitorAt([monitor, b], 100, 100).name, "A") + assert.equal(G.monitorAt([monitor, b], 5000, 500).name, "B") + assert.equal(G.monitorAt([], 0, 0), null) +}) + +test("dragTarget clamps inside the usable area", () => { + const raw = { x: -20, y: 5, w: 800, h: 600 } + const t = G.dragTarget(raw, area, monitor, { x: 300, y: 40 }) + assert.equal(t.x, area.x) + assert.equal(t.y, area.y) +}) + +test("dragTarget lets the window go when pushed past the break margin", () => { + const raw = { x: 2000, y: 400, w: 800, h: 600 } + const t = G.dragTarget(raw, area, monitor, { x: 2600, y: 500 }) + assert.equal(t.x, 2000) +}) + +test("snapZone: edges, corners and free space", () => { + const w = 800, h = 600 + const cur = (x, y) => ({ x, y }) + assert.equal(G.snapZone({ x: area.x, y: 400, w, h }, area, monitor, cur(300, 420)), "left") + assert.equal(G.snapZone({ x: area.x + area.w - w, y: 400, w, h }, area, monitor, cur(2400, 420)), "right") + assert.equal(G.snapZone({ x: 900, y: area.y, w, h }, area, monitor, cur(1200, 40)), "top") + assert.equal(G.snapZone({ x: 900, y: area.y + area.h - h, w, h }, area, monitor, cur(1200, 900)), "bottom") + assert.equal(G.snapZone({ x: area.x, y: area.y, w, h }, area, monitor, cur(300, 40)), "top-left") + assert.equal(G.snapZone({ x: 900, y: 400, w, h }, area, monitor, cur(5, 45)), "top-left") + assert.equal(G.snapZone({ x: 900, y: 400, w, h }, area, monitor, cur(1200, 420)), "") +}) + +test("snapZone ignores the far side for windows as wide as the area", () => { + const raw = { x: area.x, y: 400, w: area.w, h: 600 } + assert.equal(G.snapZone(raw, area, monitor, { x: 2500, y: 420 }), "right") + assert.equal(G.snapZone(raw, area, monitor, { x: 100, y: 420 }), "left") +}) + +test("snapZone is off while breaking out of the monitor", () => { + const raw = { x: 2300, y: 400, w: 800, h: 600 } + assert.equal(G.snapZone(raw, area, monitor, { x: 2600, y: 420 }), "") +}) + +test("restoreSize keeps a remembered size, shrinks it to fit, or falls back", () => { + assert.deepEqual(plain(G.restoreSize({ w: 900, h: 700 }, area)), { w: 900, h: 700 }) + assert.deepEqual(plain(G.restoreSize({ w: 9000, h: 7000 }, area)), { w: area.w, h: area.h }) + assert.deepEqual(plain(G.restoreSize(null, area)), plain(G.defaultRestoreSize(area))) +}) + +test("restoreUnderCursor keeps the grabbed point under the pointer", () => { + const size = { w: 1000, h: 700 } + const r = G.restoreUnderCursor(size, { x: 1200, y: 40 }, 0.5, 4, area) + assert.equal(r.x, 700) + assert.equal(r.y, 36) + const high = G.restoreUnderCursor(size, { x: 1200, y: 10 }, 0.5, 4, area) + assert.equal(high.y, area.y) + const edge = G.restoreUnderCursor(size, { x: 2550, y: 600 }, 0.9, 4, area) + assert.equal(edge.x + edge.w, area.x + area.w) +}) + +test("fitInside shrinks oversized windows and pulls them below the bar", () => { + assert.equal(G.fitInside({ x: 100, y: 100, w: 800, h: 600 }, area), null) + const big = G.fitInside({ x: 0, y: 0, w: 4000, h: 3000 }, area) + assert.ok(big.w <= area.w && big.h <= area.h) + const under = G.fitInside({ x: 100, y: 0, w: 800, h: 600 }, area) + assert.equal(under.y, area.y) +}) + +test("rectsMatch tolerates a few pixels", () => { + assert.ok(G.rectsMatch({ x: 0, y: 0, w: 10, h: 10 }, { x: 3, y: -2, w: 12, h: 9 })) + assert.ok(!G.rectsMatch({ x: 0, y: 0, w: 10, h: 10 }, { x: 30, y: 0, w: 10, h: 10 })) +}) + +test("clampInto keeps the size and pulls the rect inside", () => { + const r = G.clampInto({ x: 2400, y: 1300, w: 800, h: 600 }, area) + assert.deepEqual(plain(r), { x: area.x + area.w - 800, y: area.y + area.h - 600, w: 800, h: 600 }) +}) + +test("followEdge keeps a side-by-side neighbour against the moved edge", () => { + const g = 6 + const left = G.zoneRect("left", area, g) + const right = G.zoneRect("right", area, g) + const wider = { ...left, w: left.w + 200 } + const r = G.followEdge(left, wider, right, g) + assert.equal(r.x, wider.x + wider.w + g) + assert.equal(r.x + r.w, right.x + right.w) + // Resizing the right window from its left edge shrinks the left one. + const narrower = { ...right, x: right.x + 100, w: right.w - 100 } + const l = G.followEdge(right, narrower, left, g) + assert.equal(l.x, left.x) + assert.equal(l.x + l.w + g, narrower.x) +}) + +test("followEdge handles stacked quarters and ignores unrelated windows", () => { + const g = 6 + const tl = G.zoneRect("top-left", area, g) + const bl = G.zoneRect("bottom-left", area, g) + const br = G.zoneRect("bottom-right", area, g) + const taller = { ...tl, h: tl.h + 100 } + const b = G.followEdge(tl, taller, bl, g) + assert.equal(b.y, taller.y + taller.h + g) + assert.equal(b.y + b.h, bl.y + bl.h) + assert.equal(G.followEdge(tl, taller, br, g), null) // only touches at a corner + assert.equal(G.followEdge(tl, tl, bl, g), null) // nothing moved +}) diff --git a/tests/i18n.test.mjs b/tests/i18n.test.mjs new file mode 100644 index 0000000..25a3920 --- /dev/null +++ b/tests/i18n.test.mjs @@ -0,0 +1,23 @@ +// Run with: node --test tests/*.test.mjs +import { test } from "node:test" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import vm from "node:vm" + +const source = readFileSync(new URL("../src/js/i18n.js", import.meta.url), "utf8") + .replace(/^\.pragma library\s*$/m, "") +const I18n = {} +vm.runInNewContext(source, I18n) + +test("every language translates every English key", () => { + const keys = Object.keys(I18n.STRINGS.en) + for (const { id } of I18n.LANGUAGES) { + const missing = keys.filter((k) => !(k in I18n.STRINGS[id])) + assert.deepEqual(missing, [], `${id} is missing keys`) + } +}) + +test("tr falls back to English, then to the key", () => { + assert.equal(I18n.tr("xx", "close"), I18n.STRINGS.en.close) + assert.equal(I18n.tr("pt", "no-such-key"), "no-such-key") +})