Last active
October 12, 2025 20:12
-
-
Save rcarmo/5132874cdaf2755e42907508802e864f to your computer and use it in GitHub Desktop.
Phoenix configuration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ******************************************************************************** | |
| // Setup and TODO | |
| // ******************************************************************************** | |
| Phoenix.set({ | |
| daemon: false, | |
| openAtLogin: true | |
| }); | |
| /* | |
| * API docs: https://kasper.github.io/phoenix/ | |
| * | |
| * TODO: | |
| * - [ ] Drag and Drop Snap (can't be implemented in current Phoenix, apparently) | |
| * - [X] Move mode | |
| * - [x] Move to adjacent window | |
| * - [x] More logical handling of sixths | |
| * - [x] Hint Manager class | |
| * - [x] Cleanup Constants | |
| * - [x] Basic Space handling | |
| * - [x] Move mouse pointer on rotation focus changes | |
| * - [x] Centered popup | |
| * - [x] Frame abstraction | |
| */ | |
| // ******************************************************************************** | |
| // Constants & Feature Flags | |
| // ******************************************************************************** | |
| const DEBUG = false; // Toggle verbose logging & optional debug helpers. | |
| /** | |
| * Direction identifiers used throughout layout, navigation and tiling logic. | |
| * Semantic (capitalized) strings are used instead of single-letter aliases for clarity. | |
| */ | |
| const DIR = Object.freeze({ | |
| NONE: "none", | |
| COLS: "Cols", | |
| FULL: "Full", | |
| NORTH: "North", | |
| SOUTH: "South", | |
| EAST: "East", | |
| WEST: "West", | |
| NW: "North-West", | |
| NE: "North-East", | |
| SW: "South-West", | |
| SE: "South-East" | |
| }); | |
| /** | |
| * Layout related configuration. | |
| * MODES enumerates the available tiling strategies applied per-screen in rotation. | |
| */ | |
| const LAYOUT = Object.freeze({ | |
| MODES: [DIR.NONE, DIR.EAST, DIR.WEST, DIR.COLS, 'CenterRing', 'CenterColumn'] // Added CenterColumn: 40% wide central column | |
| }); | |
| /** | |
| * Hint system tuning. | |
| * CHARS defines the selection alphabet; ICON batching avoids UI stalls on large window counts. | |
| */ | |
| const HINT = Object.freeze({ | |
| APPEARANCE: "dark", | |
| CANCEL_KEY: "escape", | |
| CHARS: "FJDKSLAGHRUEIWOVNCM", | |
| LAZY_ICONS: true, | |
| ICON_BATCH_DELAY: 0.015, | |
| ICON_BATCH_SIZE: 12, | |
| MAX_OVERLAP_ADJUST: 500, | |
| TEXT_ONLY_THRESHOLD: 25, | |
| DEBUG_OVERLAY: false, | |
| }); | |
| // Direction aliases removed (use DIR.* directly for clarity and grep-ability) | |
| /** Geometry and heuristic values (pixel units). */ | |
| const GEOM = Object.freeze({ | |
| PADDING: 8, | |
| OVERLAP_TOLERANCE: 6 | |
| }); | |
| /** Performance tuning thresholds for window registry & event debounce. */ | |
| const PERF = Object.freeze({ | |
| // Registry TTL/adaptive parameters removed in simplified model (left here for backward compatibility no-ops) | |
| REGISTRY_TTL_BASE_MS: 0, | |
| REGISTRY_TTL_MAX_MS: 0, | |
| REGISTRY_TTL_MIN_MS: 0, | |
| REGISTRY_ADAPTIVE: false, | |
| EVENT_DEBOUNCE_MS: 80, | |
| NEIGHBOR_SCAN_MAX_DIST: 600, // px: early break distance when building directional neighbor lists (horizontal & vertical) | |
| NEIGHBOR_DISTANCE_ENABLED: true, // set false to disable early break distance caps | |
| FOCUS_FORCE_REBUILD_ON_MISS: true, // if no candidates found, trigger one registry rebuild attempt | |
| REGISTRY_SLOW_GROW_FACTOR: 0, | |
| REGISTRY_FAST_SHRINK_FACTOR: 0, | |
| REGISTRY_MISS_STREAK_TRIGGER: 0, | |
| REGISTRY_DISABLE_TTL: true, | |
| FOCUS_ENUM_CACHE_MS: 2500 // reuse per-screen enumeration & indices if another focusAdjacent fires within this window | |
| ,FOCUS_MAX_SCAN: 160 // cap directional scan iterations before fallback tie-breaks | |
| ,FOCUS_DISTANCE_CAP: 4000 // absolute primary distance cap (px) to ignore very distant windows | |
| }); | |
| /** Focus tracking limits & feature toggles. */ | |
| const FOCUS = Object.freeze({ | |
| ENABLE_GLOBAL_MRU: true, | |
| GLOBAL_MRU_LIMIT: 25, | |
| ENABLE_DIRECTIONAL_MRU: true, | |
| DIRECTIONAL_MRU_LIMIT: 15, | |
| MOVE_POINTER_ON_FOCUS: true // Toggle mouse centering after focus changes | |
| }); | |
| /** Move mode nudge distances (pixels). */ | |
| const MOVE = Object.freeze({ | |
| NUDGE_SMALL: 10, | |
| NUDGE_LARGE: 50 | |
| }); | |
| // Runtime mutable tracking | |
| var LAST_POSITION = { | |
| window: null, | |
| grid: "", | |
| positions: [] | |
| }; | |
| var LAST_POSITION_INDEX = -1; | |
| var ICON_CACHE = {}; | |
| function iconKey(win) { | |
| try { | |
| const app = win.app(); | |
| if (app.bundleIdentifier) { | |
| const bid = app.bundleIdentifier(); | |
| if (bid) return bid; | |
| } | |
| return 'pid:' + app.pid(); | |
| } catch (e) { | |
| return 'hash:' + win.hash(); | |
| } | |
| } | |
| // -------------------------------------------------------------------------------- | |
| // Utilities Namespace | |
| // -------------------------------------------------------------------------------- | |
| const Util = (() => { | |
| function log(...args) { if (DEBUG) Phoenix.log(args.join(' ')); } | |
| function rotateArray(arr, n) { | |
| if (!arr.length) return arr; | |
| n = ((n % arr.length) + arr.length) % arr.length; | |
| return arr.slice(n).concat(arr.slice(0, n)); | |
| } | |
| function clamp(v, min, max) { return v < min ? min : (v > max ? max : v); } | |
| /** All visible windows on a given screen (ordered by recency). */ | |
| function windowsForScreen(screen) { | |
| try { | |
| const recent = Window.recent().filter(w => { | |
| try { return w && w.isVisible && w.isVisible() && w.screen() && w.screen().isEqual(screen); } catch (e) { return false; } | |
| }); | |
| // Defensive augmentation: some versions/setups may yield an incomplete Window.recent() list. | |
| // Merge in any visible windows from screen.windows({visible:true}) that are missing. | |
| let full = recent; | |
| try { | |
| const byScreen = screen.windows ? (screen.windows({ visible: true }) || []) : []; | |
| if (byScreen.length > recent.length) { | |
| const recentHashes = new Set(recent.map(w => w.hash())); | |
| byScreen.forEach(w => { | |
| try { if (w && w.isVisible && w.isVisible() && w.screen() && w.screen().isEqual(screen) && !recentHashes.has(w.hash())) full.push(w); } catch (e) { } | |
| }); | |
| } | |
| } catch (e) { } | |
| return full; | |
| } catch (e) { | |
| // Fallback to legacy per-screen enumeration if recent() fails | |
| try { return screen.windows({ visible: true }) || []; } catch (e2) { return []; } | |
| } | |
| } | |
| /** All visible windows across all screens (ordered by recency). */ | |
| function allVisibleWindows() { | |
| try { return Window.recent().filter(w => { try { return w.isVisible && w.isVisible(); } catch (e) { return false; } }); } catch (e) { return Window.all({ visible: true }); } | |
| } | |
| return { log, rotateArray, clamp, windowsForScreen, allVisibleWindows }; | |
| })(); | |
| // ******************************************************************************** | |
| // Keyboard Bindings | |
| // Cheat Sheet: | |
| // ctrl+opt+return / arrows : stepped sizing / grid cycles | |
| // shift+ctrl+opt+arrows : vertical reposition sequences | |
| // ctrl+opt+h/j/k/l : focus adjacent (vim) | |
| // ctrl+opt+cmd+←/→ : move window to screen | |
| // shift+ctrl+opt+cmd+←/→ : move window to space | |
| // shift+ctrl+z : cycle tiling mode | |
| // shift+cmd+space : hints | |
| // ctrl+opt+cmd+m : move mode (later binding) | |
| // ******************************************************************************** | |
| // --- 1. Grid / Size Cycling (Moom-like) --- | |
| // ******************************************************************************** | |
| // Keyboard Bindings | |
| // ******************************************************************************** | |
| // Moom-like bindings | |
| Key.on("return", ["control", "option"], () => { const w=Window.focused(); if(!w) return; w.positionInGrid(4, 0, 3).centerMouse() }); | |
| // Mixed grid bases (4/6/16) intentionally provide progressive sizing steps. | |
| Key.on("left", ["control", "option"], () => { const w=Window.focused(); if(!w) return; steppedSizing(w, [[4, 0, 2], [6, 0, 3], [16, 0, 9]]) }); | |
| Key.on("right", ["control", "option"], () => { const w=Window.focused(); if(!w) return; steppedSizing(w, [[4, 1, 3], [6, 2, 5], [16, 6, 15]]) }); | |
| Key.on("up", ["control", "option"], () => { const w=Window.focused(); if(!w) return; steppedSizing(w, [[4, 0, 1], [4, 0, 3]]) }); | |
| Key.on("down", ["control", "option"], () => { const w=Window.focused(); if(!w) return; steppedSizing(w, [[4, 2, 3], [8, 2, 13], [6, 1, 4]]) }); | |
| Key.on("left", ["shift", "control", "option"], () => { const w=Window.focused(); if(!w) return; w.reposition(DIR.WEST).centerMouse() }); | |
| Key.on("right", ["shift", "control", "option"], () => { const w=Window.focused(); if(!w) return; w.reposition(DIR.EAST).centerMouse() }); | |
| Key.on("up", ["shift", "control", "option"], () => { const w=Window.focused(); if(!w) return; w.reposition(DIR.NORTH).centerMouse() }); | |
| Key.on("down", ["shift", "control", "option"], () => { const w=Window.focused(); if(!w) return; w.reposition(DIR.SOUTH).centerMouse() }); | |
| // Move horizontally between screens | |
| // --- 2. Screen Transfer --- | |
| Key.on("right", ["control", "option", "command"], () => { const w=Window.focused(); if(!w) return; w.toScreen(DIR.EAST).centerMouse() }); | |
| Key.on("left", ["control", "option", "command"], () => { const w=Window.focused(); if(!w) return; w.toScreen(DIR.WEST).centerMouse() }); | |
| // Move horizontally between spaces | |
| // --- 3. Space Transfer --- | |
| Key.on("right", ["shift", "control", "option", "command"], () => { const w=Window.focused(); if(!w) return; w.toSpace(DIR.EAST) }); | |
| Key.on("left", ["shift", "control", "option", "command"], () => { const w=Window.focused(); if(!w) return; w.toSpace(DIR.WEST) }); | |
| // Change focus to adjacent window (Vim-like: h=West, l=East) | |
| // --- 4. Focus Navigation (Vim-like) --- | |
| Key.on("h", ["control", "option"], () => { const w=Window.focused(); if(!w) return; w.focusAdjacent(DIR.WEST).centerMouse() }); | |
| Key.on("j", ["control", "option"], () => { const w=Window.focused(); if(!w) return; w.focusAdjacent(DIR.SOUTH).centerMouse() }); | |
| Key.on("k", ["control", "option"], () => { const w=Window.focused(); if(!w) return; w.focusAdjacent(DIR.NORTH).centerMouse() }); | |
| Key.on("l", ["control", "option"], () => { const w=Window.focused(); if(!w) return; w.focusAdjacent(DIR.EAST).centerMouse() }); | |
| // --- 5. Tiling Mode / Rotation --- | |
| Key.on("z", ["shift", "control"], () => wm.change_tiling_mode()) | |
| Key.on("n", ["shift", "control"], () => wm.rotate(-1, false)); | |
| Key.on("n", ["shift", "control", "option"], () => wm.rotate(-1, true)); | |
| Key.on("m", ["shift", "control"], () => wm.rotate(1, false)); | |
| Key.on("m", ["shift", "control", "option"], () => wm.rotate(1, true)); | |
| // Re-tile current mode on focused screen | |
| Key.on("x", ["shift", "control"], () => wm.retile()); | |
| // --- 7. Modes & Hints --- | |
| Key.on('m', ['control', 'option', 'command'], () => { const w=Window.focused(); if(!w) return; MoveMode.activate(w) }); | |
| Key.on("space", ["shift", "command"], () => HintManager.activate()); | |
| // ******************************************************************************** | |
| // Size steps | |
| // ******************************************************************************** | |
| function steppedSizing(win, gridPositions) { | |
| if (!gridPositions || !gridPositions.length) return win; // Guard against empty cycles | |
| if (!win.isEqual(LAST_POSITION.window)) { | |
| LAST_POSITION_INDEX = 0; | |
| LAST_POSITION.window = win; | |
| } else if (JSON.stringify(LAST_POSITION.positions) != JSON.stringify(gridPositions)) { | |
| LAST_POSITION_INDEX = 0; // Accept simple stringify comparison (small arrays) | |
| } | |
| const res = win.positionInGrid.apply(win, gridPositions[LAST_POSITION_INDEX]).centerMouse(); | |
| LAST_POSITION.grid = gridPositions[LAST_POSITION_INDEX].join(","); | |
| LAST_POSITION_INDEX = (LAST_POSITION_INDEX + 1) % gridPositions.length; | |
| LAST_POSITION.positions = gridPositions; | |
| return res | |
| } | |
| // ******************************************************************************** | |
| // Hints | |
| // ******************************************************************************** | |
| class Hints { | |
| constructor() { | |
| this.active = false; | |
| this.keys = []; | |
| this.hints = {}; | |
| this.escbind = null; | |
| this.bsbind = null; | |
| this.id = Math.random(); | |
| } // constructor | |
| cancel() { | |
| for (var activator in this.hints) { | |
| if (this.hints[activator]) | |
| this.hints[activator].modal.close(); | |
| }; | |
| // remove all key bindings | |
| Key.off(this.escbind); | |
| Key.off(this.bsbind); | |
| this.keys.map(Key.off); | |
| // clear hints | |
| this.hints = {}; | |
| this.keys = []; | |
| this.active = false; | |
| return this; | |
| } // cancel | |
| show(windows, prefix) { | |
| const self = this; | |
| prefix = prefix || ""; | |
| const t0 = Date.now(); | |
| // Allow callers to omit windows: pull from WindowRegistry (all screens) then filter to current space | |
| if (!windows) { | |
| try { | |
| const focused = Window.focused(); | |
| let spaceId = null; | |
| if (focused && focused.spaces) { | |
| const sp = focused.spaces(); | |
| if (sp && sp.length) { | |
| try { spaceId = sp[0].hash ? sp[0].hash() : sp[0]; } catch (e) { } | |
| } | |
| } | |
| if (spaceId) { | |
| // Directly query space-partitioned entries (avoids allocating all entries and filtering) | |
| windows = WindowRegistry.getSpaceEntries(spaceId).map(e => e.win); | |
| } else { | |
| // Fallback to all entries if space cannot be determined | |
| windows = WindowRegistry.getAllEntries().map(e => e.win); | |
| } | |
| } catch (e) { | |
| windows = WindowRegistry.getAllEntries().map(e => e.win); | |
| } | |
| } | |
| if (!windows || !windows.length) return this; | |
| const totalWindows = windows.length; | |
| const textOnly = totalWindows > HINT.TEXT_ONLY_THRESHOLD; | |
| // If more windows than hint characters, build a two-level selection rather than recursive early-return. | |
| // Partition windows into buckets keyed by first-level char. | |
| if (windows.length > HINT.CHARS.length) { | |
| const buckets = {}; | |
| windows.forEach((win, idx) => { | |
| const key = HINT.CHARS[idx % HINT.CHARS.length]; | |
| (buckets[key] = buckets[key] || []).push(win); | |
| }); | |
| // Show only first-level hints now; activating one expands second level. | |
| Object.keys(buckets).forEach((ch, i) => { | |
| const pseudoWin = buckets[ch][0]; // anchor modal to first window in bucket | |
| const hash = iconKey(pseudoWin); | |
| if (!ICON_CACHE[hash]) ICON_CACHE[hash] = pseudoWin.app().icon(); | |
| const hint = Modal.build({ | |
| text: prefix + ch, | |
| appearance: HINT.APPEARANCE, | |
| icon: ICON_CACHE[hash], | |
| weight: 16, | |
| duration: 0, | |
| }).attach(pseudoWin); | |
| self.hints[prefix + ch] = { win: pseudoWin, modal: hint, position: 0, active: true, bucket: buckets[ch] }; | |
| }); | |
| self._prepareKeyHandlersForBuckets(prefix); | |
| this.active = true; | |
| return this; | |
| } | |
| // Use WindowRegistry metadata directly (avoids duplicate frame/title/icon lookups) | |
| const entriesByHash = {}; | |
| WindowRegistry.getAllEntries().forEach(e => { entriesByHash[e.hash] = e; }); | |
| const tMetaStart = Date.now(); | |
| const meta = windows.map((win, i) => { | |
| const h = win.hash(); | |
| const snap = entriesByHash[h]; | |
| if (snap) return { win, i, hash: h, multi: snap.multi, frame: snap.frame, titleShort: snap.titleShort }; | |
| // fallback minimal (rare path) | |
| return { win, i, hash: h, multi: false, frame: win.frame(), titleShort: "" }; | |
| }); | |
| const tMetaEnd = Date.now(); | |
| // Build hints in one pass | |
| // Create all modals without (or with cached) icons first for fast initial paint | |
| const lazyQueue = []; | |
| const tModalStart = Date.now(); | |
| meta.forEach(m => { | |
| const ikey = iconKey(m.win); | |
| const hasIcon = !!ICON_CACHE[ikey]; | |
| if (!textOnly) { | |
| if (hasIcon === false && HINT.LAZY_ICONS) lazyQueue.push({ key: ikey, win: m.win }); | |
| else if (!hasIcon && !HINT.LAZY_ICONS) { // eager path if lazy disabled | |
| try { ICON_CACHE[ikey] = m.win.app().icon(); } catch (e) { } | |
| } | |
| } | |
| let label = ""; | |
| if (!textOnly && m.multi) label += " | " + (m.titleShort || ""); | |
| const hint = Modal.build({ | |
| text: prefix + HINT.CHARS[m.i] + label, | |
| appearance: HINT.APPEARANCE, | |
| icon: (textOnly ? undefined : (ICON_CACHE[ikey] || undefined)), | |
| weight: 16, | |
| duration: 0, | |
| }).attach(m.win); | |
| self.hints[prefix + HINT.CHARS[m.i]] = { win: m.win, modal: hint, position: 0, active: true, _iconKey: ikey }; | |
| }); | |
| const tModalEnd = Date.now(); | |
| // Lazy icon loading in small timed batches to keep UI responsive | |
| if (!textOnly && HINT.LAZY_ICONS && lazyQueue.length) { | |
| let idx = 0; | |
| function loadBatch() { | |
| const slice = lazyQueue.slice(idx, idx + HINT.ICON_BATCH_SIZE); | |
| slice.forEach(entry => { | |
| if (!ICON_CACHE[entry.key]) { | |
| try { ICON_CACHE[entry.key] = entry.win.app().icon(); } catch (e) { } | |
| } | |
| // Update modal icon if still active | |
| for (const activator in self.hints) { | |
| const h = self.hints[activator]; | |
| if (h && h._iconKey === entry.key && ICON_CACHE[entry.key]) { | |
| h.modal.icon = ICON_CACHE[entry.key]; | |
| } | |
| } | |
| }); | |
| idx += HINT.ICON_BATCH_SIZE; | |
| if (idx < lazyQueue.length) Timer.after(HINT.ICON_BATCH_DELAY, loadBatch); | |
| } | |
| Timer.after(HINT.ICON_BATCH_DELAY, loadBatch); | |
| } | |
| // Overlap adjustment (single pass, sorted by origin.y) | |
| const activators = Object.keys(self.hints).sort((a, b) => self.hints[a].modal.origin.y - self.hints[b].modal.origin.y); | |
| if (!textOnly && activators.length > 1) { // skip costly adjustments in text-only mode | |
| let adjustments = 0; | |
| for (let i = 0; i < activators.length; i++) { | |
| const a = self.hints[activators[i]].modal; | |
| const aBottom = a.origin.y + a.frame().height + GEOM.PADDING; | |
| for (let j = i + 1; j < activators.length; j++) { | |
| if (adjustments > HINT.MAX_OVERLAP_ADJUST) break; | |
| const b = self.hints[activators[j]].modal; | |
| // Early vertical non-overlap break (list sorted by y increasing) | |
| if (b.origin.y > aBottom) break; | |
| // Overlap test | |
| if ( | |
| a.origin.x < b.origin.x + b.frame().width + GEOM.PADDING && | |
| a.origin.x + a.frame().width > b.origin.x - GEOM.PADDING && | |
| a.origin.y < b.origin.y + b.frame().height + GEOM.PADDING && | |
| a.origin.y + a.frame().height > b.origin.y - GEOM.PADDING | |
| ) { | |
| b.origin = { x: b.origin.x, y: a.origin.y + a.frame().height + GEOM.PADDING }; | |
| adjustments++; | |
| } | |
| } | |
| if (adjustments > HINT.MAX_OVERLAP_ADJUST) break; | |
| } | |
| } | |
| self.escbind = Key.on(HINT.CANCEL_KEY, [], () => self.cancel()); | |
| this.active = true; | |
| // Debug overlay (appears briefly) | |
| if (HINT.DEBUG_OVERLAY) { | |
| const tEnd = Date.now(); | |
| try { | |
| const stats = [ | |
| `win:${totalWindows}`, | |
| `text:${textOnly}`, | |
| `meta:${tMetaEnd - tMetaStart}ms`, | |
| `modal:${tModalEnd - tModalStart}ms`, | |
| `lazyQ:${lazyQueue.length}`, | |
| `total:${tEnd - t0}ms` | |
| ].join(' '); | |
| Modal.build({ | |
| text: stats, | |
| appearance: HINT.APPEARANCE, | |
| weight: 12, | |
| duration: 0.9, | |
| }).flash(Screen.main ? Screen.main() : Screen.all()[0]); | |
| } catch (e) { } | |
| } | |
| return this; | |
| } // show | |
| _prepareKeyHandlersForBuckets(prefix) { | |
| // Replace existing handlers with bucket-expansion logic. | |
| const self = this; | |
| const sequences = Object.keys(self.hints); | |
| self.keys = []; | |
| HINT.CHARS.split("").forEach(ch => { | |
| self.keys.push(Key.on(ch, [], function () { | |
| const activator = prefix + ch; | |
| const entry = self.hints[activator]; | |
| if (!entry) return; // not a valid first-level key | |
| // Close first-level modals except chosen bucket | |
| sequences.forEach(seq => { if (seq !== activator) self.hints[seq].modal.close(); }); | |
| const bucket = entry.bucket; | |
| // Clear hints and rebuild with second-level inside bucket | |
| const bucketWins = bucket; | |
| self.cancel(); | |
| self.show(bucketWins, activator); // recursing with prefix expands second level | |
| })); | |
| }); | |
| } | |
| activate() { | |
| var self = this; | |
| if (this.active) { | |
| self.cancel(); | |
| } else { | |
| Event.once("mouseDidLeftClick", function () { self.cancel(); }); | |
| this.show(Util.allVisibleWindows()); | |
| var sequence = ""; | |
| self.keys = []; | |
| HINT.CHARS.split("").forEach(function (hintchar) { | |
| // set up each individual hint handler | |
| self.keys.push(Key.on(hintchar, [], function () { | |
| // ISSUE: Potential race if windows close mid-sequence; consider validating hint.win.exists(). | |
| sequence += hintchar; | |
| for (var activator in self.hints) { | |
| var hint = self.hints[activator]; | |
| if (!hint.active) continue; | |
| if (activator[hint.position] === hintchar) { | |
| hint.position++; | |
| if (hint.position === activator.length) { | |
| hint.win.focus(); | |
| Mouse.move({ | |
| x: hint.modal.origin.x + hint.modal.frame().width / 2, | |
| y: Screen.all()[0].frame().height - hint.modal.origin.y - hint.modal.frame().height / 2 | |
| }); | |
| return self.cancel(); | |
| } | |
| hint.modal.text = hint.modal.text.substr(1); | |
| } else { | |
| hint.modal.close(); | |
| hint.active = false; | |
| } | |
| } | |
| })); | |
| }); | |
| self.bsbind = Key.on("delete", [], function () { | |
| if (!sequence.length) | |
| self.cancel(); | |
| var letter = sequence[sequence.length - 1]; | |
| sequence = sequence.substr(0, sequence.length - 1); | |
| for (var activator in self.hints) { | |
| var hint = self.hints[activator]; | |
| if (hint.active) { | |
| hint.position--; | |
| hint.modal.text = letter + hint.modal.text; | |
| } else if (activator.substr(0, sequence.length) === sequence) { | |
| hint.modal.show(); | |
| hint.active = true; | |
| } | |
| } | |
| }); | |
| } | |
| } // activate | |
| } | |
| // ******************************************************************************** | |
| // Window Manager Abstraction | |
| // ******************************************************************************** | |
| class WindowManager { | |
| constructor() { | |
| this.tiling_modes = Array(Screen.all().length) | |
| this.tiling_modes.fill(DIR.NONE) | |
| this.timers = Array(Screen.all().length) | |
| this.layouts = Array(Screen.all().length) | |
| } | |
| change_tiling_mode() { | |
| const focused = Window.focused(); | |
| if (!focused) return this; // No focused window -> nothing to tile | |
| let screen = null; | |
| try { screen = focused.screen && focused.screen(); } catch (e) { } | |
| if (!screen) return this; // Cannot resolve screen | |
| const screens = Screen.all(); | |
| const index = screens.indexOf(screen); | |
| if (index === -1) return this; // Screen not tracked | |
| // unified enumeration | |
| const visible = Util.windowsForScreen(screen); | |
| // rotate tiling mode | |
| this.tiling_modes[index] = LAYOUT.MODES[(LAYOUT.MODES.indexOf(this.tiling_modes[index]) + 1) % LAYOUT.MODES.length] | |
| if (DEBUG) Phoenix.log(index + " " + this.tiling_modes[index]); | |
| // Try layout cache reuse | |
| const key = layoutCacheKey(screen, this.tiling_modes[index], visible) | |
| let cached = LAYOUT_CACHE.get(key) | |
| if (cached) { | |
| const layout = new Layout(screen, this.tiling_modes[index]) | |
| layout.frames = cached.map(f => new Frame(f.x, f.y, f.width, f.height)) | |
| this.layouts[index] = layout | |
| } else { | |
| this.layouts[index] = new Layout(screen, this.tiling_modes[index]) | |
| // store frames snapshot | |
| LAYOUT_CACHE.set(key, this.layouts[index].frames.map(f => ({ x: f.x, y: f.y, width: f.width, height: f.height }))) | |
| } | |
| var modal = Modal.build({ | |
| text: "Tiling mode: " + this.tiling_modes[index], | |
| appearance: HINT.APPEARANCE, | |
| weight: 24, | |
| icon: App.get('Phoenix').icon(), | |
| duration: 0.5, | |
| }).flash(screen) | |
| if (DEBUG) Phoenix.log("layouts pre-apply count=" + this.layouts[index].frames.length); | |
| this.layouts[index].windows = visible | |
| this.layouts[index].apply() | |
| if (DEBUG) Phoenix.log("layouts applied"); | |
| return this | |
| } | |
| rotate(dir, focus_only) { | |
| const focused = Window.focused(); | |
| if (!focused) return this; | |
| let screen = null; try { screen = focused.screen && focused.screen(); } catch(e) {} | |
| if (!screen) return this; | |
| const index = Screen.all().indexOf(screen); | |
| if (index === -1) return this; | |
| const layout = this.layouts[index]; | |
| if (!layout) return this; | |
| const list = (layout.windows || []); | |
| if (DEBUG) { | |
| try { Phoenix.log(index + " rotate " + dir + " [" + list.map(w=>{try{return w.hash()}catch(e){return 'invalid'}}).join(',') + "]"); } catch(e) {} | |
| } | |
| layout.rotate(dir).apply(screen) | |
| return this | |
| } | |
| // Re-apply current tiling mode on focused screen without changing mode. | |
| retile() { | |
| const focused = Window.focused(); | |
| if (!focused) return this; | |
| let screen = null; try { screen = focused.screen && focused.screen(); } catch(e) {} | |
| if (!screen) return this; | |
| const screens = Screen.all(); | |
| const index = screens.indexOf(screen); | |
| if (index === -1) return this; | |
| const mode = this.tiling_modes[index]; | |
| if (!mode || mode === DIR.NONE) return this; // nothing to tile | |
| const visible = Util.windowsForScreen(screen); | |
| // Rebuild layout for current mode | |
| const key = layoutCacheKey(screen, mode, visible); | |
| let layout; | |
| const cached = LAYOUT_CACHE.get(key); | |
| if (cached) { | |
| layout = new Layout(screen, mode); | |
| layout.frames = cached.map(f => new Frame(f.x, f.y, f.width, f.height)); | |
| } else { | |
| layout = new Layout(screen, mode); | |
| LAYOUT_CACHE.set(key, layout.frames.map(f => ({ x: f.x, y: f.y, width: f.width, height: f.height }))); | |
| } | |
| layout.windows = visible; | |
| layout.apply(); | |
| this.layouts[index] = layout; | |
| if (DEBUG) try { Phoenix.log("retile applied mode=" + mode + " windows=" + visible.length); } catch(e) {} | |
| return this; | |
| } | |
| } | |
| const wm = new WindowManager() | |
| // ******************************************************************************** | |
| // Layout Abstraction | |
| // ******************************************************************************** | |
| class Layout { | |
| constructor(screen, mode) { | |
| this.frames = [] | |
| this.windows = [] | |
| switch (mode) { | |
| case DIR.EAST: | |
| this.east(screen) | |
| break; | |
| case DIR.WEST: | |
| this.west(screen) | |
| break; | |
| case DIR.COLS: | |
| this.cols(screen) | |
| break; | |
| case 'CenterRing': | |
| this.centerRing(screen) | |
| break; | |
| case 'CenterColumn': | |
| this.centerColumn(screen) | |
| break; | |
| } | |
| } | |
| east(screen) { | |
| const f = screen.flippedVisibleFrame(), | |
| // unified enumeration | |
| v = Util.windowsForScreen(screen), | |
| c = v.length - 1, | |
| w = f.width / 2, | |
| h = ~~(f.height / c), | |
| self = this | |
| this.screen = screen | |
| if (v.length === 1) { | |
| this.frames.push(new Frame(f.x, f.y, f.width, f.height).pad()) | |
| } else { | |
| // main window | |
| self.frames.push(new Frame(f.x, f.y, w, f.height) | |
| .displace(f.width / 2, 0).pad()) | |
| // secondary windows | |
| for (var i = 0; i < c; i++) { | |
| self.frames.push(new Frame(f.x, f.y, w, h) | |
| .displace(0, h * i).pad()) | |
| } | |
| } | |
| return this | |
| } | |
| west(screen) { | |
| const f = screen.flippedVisibleFrame(), | |
| v = Util.windowsForScreen(screen), | |
| c = v.length - 1, | |
| w = f.width / 2, | |
| h = ~~(f.height / c), | |
| self = this | |
| this.screen = screen | |
| if (v.length === 1) { | |
| self.frames.push(new Frame(f.x, f.y, f.width, f.height).pad()) | |
| } else { | |
| // main window | |
| self.frames.push(new Frame(f.x, f.y, w, f.height) | |
| .pad()) | |
| // secondary windows | |
| for (var i = 0; i < c; i++) { | |
| self.frames.push(new Frame(f.x, f.y, w, h) | |
| .displace(w, h * i).pad()) | |
| } | |
| } | |
| return this | |
| } | |
| cols(screen) { | |
| const f = screen.flippedVisibleFrame(), | |
| v = Util.windowsForScreen(screen), | |
| c = v.length, | |
| w = ~~(f.width / c), | |
| h = f.height, | |
| self = this | |
| this.screen = screen | |
| // all windows | |
| for (var i = 0; i < c; i++) { | |
| self.frames.push(new Frame(f.x, f.y, w, h) | |
| .displace(w * i, 0).pad()) | |
| } | |
| return this | |
| } | |
| // CenterRing layout: first (most recent) window gets a centered 40% frame. | |
| // Remaining windows are distributed in bands: top, bottom, left, right around the center. | |
| // Simplifying assumptions: we ignore corner micro-tiling; top/bottom span full width, left/right span the vertical middle strip. | |
| // Distribution strategy: round-robin across bands in order [top,bottom,left,right] for visual balance. | |
| centerRing(screen) { | |
| const f = screen.flippedVisibleFrame(); | |
| const v = Util.windowsForScreen(screen); | |
| this.screen = screen; | |
| if (!v.length) return this; | |
| const cxw = Math.round(f.width * 0.40); | |
| const cxh = Math.round(f.height * 0.40); | |
| const remW = f.width - cxw; | |
| const remH = f.height - cxh; | |
| // Split remaining equally top/bottom and left/right | |
| const topH = Math.round(remH / 2); | |
| const bottomH = remH - topH; | |
| const leftW = Math.round(remW / 2); | |
| const rightW = remW - leftW; | |
| const centerX = f.x + leftW; | |
| const centerY = f.y + topH; | |
| // Center window frame | |
| this.frames.push(new Frame(centerX, centerY, cxw, cxh).pad()); | |
| if (v.length === 1) return this; | |
| // Pre-calculate band rectangles | |
| const bands = { | |
| top: { x: f.x, y: f.y, width: f.width, height: topH }, | |
| bottom: { x: f.x, y: f.y + topH + cxh, width: f.width, height: bottomH }, | |
| left: { x: f.x, y: centerY, width: leftW, height: cxh }, | |
| right: { x: centerX + cxw, y: centerY, width: rightW, height: cxh }, | |
| }; | |
| // Collect remaining windows and distribute counts per band for simple tiling. | |
| const remaining = v.slice(1); // exclude center | |
| if (!remaining.length) return this; | |
| const bandOrder = ['top','bottom','left','right']; | |
| const allocation = { top:[], bottom:[], left:[], right:[] }; | |
| remaining.forEach((win, i) => { | |
| allocation[bandOrder[i % bandOrder.length]].push(win); | |
| }); | |
| function tileHoriz(rect, count) { | |
| if (count === 0) return []; | |
| const w = Math.round(rect.width / count); | |
| const frames = []; | |
| for (let i=0;i<count;i++) { | |
| frames.push(new Frame(rect.x + w*i, rect.y, (i === count-1 ? rect.x + rect.width - (rect.x + w*i) : w), rect.height).pad()); | |
| } | |
| return frames; | |
| } | |
| function tileVert(rect, count) { | |
| if (count === 0) return []; | |
| const h = Math.round(rect.height / count); | |
| const frames = []; | |
| for (let i=0;i<count;i++) { | |
| frames.push(new Frame(rect.x, rect.y + h*i, rect.width, (i === count-1 ? rect.y + rect.height - (rect.y + h*i) : h)).pad()); | |
| } | |
| return frames; | |
| } | |
| // Tile each band according to orientation | |
| const topFrames = tileHoriz(bands.top, allocation.top.length); | |
| const bottomFrames = tileHoriz(bands.bottom, allocation.bottom.length); | |
| const leftFrames = tileVert(bands.left, allocation.left.length); | |
| const rightFrames = tileVert(bands.right, allocation.right.length); | |
| // Preserve original window ordering (center first), then follow bandOrder sequence | |
| this.frames.push(...topFrames, ...bottomFrames, ...leftFrames, ...rightFrames); | |
| return this; | |
| } | |
| // CenterColumn layout: first window gets middle column (40% width, full height). | |
| // Remaining windows are distributed vertically in the left and right columns (each ~30% width). | |
| // We alternate assignment left/right to keep balance, then within each side column windows are stacked top->bottom. | |
| centerColumn(screen) { | |
| const f = screen.flippedVisibleFrame(); | |
| const v = Util.windowsForScreen(screen); | |
| this.screen = screen; | |
| if (!v.length) return this; | |
| const centerW = Math.round(f.width * 0.40); | |
| const sideTotal = f.width - centerW; // remaining width | |
| const leftW = Math.round(sideTotal / 2); | |
| const rightW = sideTotal - leftW; | |
| const centerX = f.x + leftW; | |
| // Center (primary) window full height | |
| this.frames.push(new Frame(centerX, f.y, centerW, f.height).pad()); | |
| if (v.length === 1) return this; | |
| const remaining = v.slice(1); | |
| const leftWins = []; | |
| const rightWins = []; | |
| remaining.forEach((w,i)=>{ (i % 2 === 0 ? leftWins : rightWins).push(w); }); | |
| function verticalStack(x, width, y, height, count) { | |
| if (!count) return []; | |
| const cell = Math.round(height / count); | |
| const frames = []; | |
| for (let i=0;i<count;i++) { | |
| const h = (i === count-1) ? (y + height) - (y + cell*i) : cell; | |
| frames.push(new Frame(x, y + cell*i, width, h).pad()); | |
| } | |
| return frames; | |
| } | |
| const leftFrames = verticalStack(f.x, leftW, f.y, f.height, leftWins.length); | |
| const rightFrames = verticalStack(centerX + centerW, rightW, f.y, f.height, rightWins.length); | |
| // Maintain deterministic order: center, then interleave left/right stacks by original grouping order. | |
| // For simplicity just append all left then all right; window order mapping will still align with frames sequence. | |
| this.frames.push(...leftFrames, ...rightFrames); | |
| return this; | |
| } | |
| none(screen) { | |
| this.frames = [] | |
| return this | |
| } | |
| rotate(dir) { | |
| // Use utility rotation (non-mutating) instead of Array.prototype extension. | |
| this.windows = Util.rotateArray(this.windows, dir); | |
| return this | |
| } | |
| apply() { | |
| // Prune invalid/closed windows | |
| const pruned = []; | |
| for (let i=0;i<this.windows.length;i++) { | |
| const w = this.windows[i]; | |
| try { if (w && w.app && w.app()) pruned.push(w); } catch(e){} | |
| } | |
| this.windows = pruned; | |
| if (!this.frames.length || !this.windows.length) return this; | |
| const n = Math.min(this.frames.length, this.windows.length); | |
| for (let i=0;i<n;i++) { | |
| try { this.windows[i].setFrame(this.frames[i]); } catch(e) { /* ignore individual failures */ } | |
| } | |
| return this; | |
| } | |
| } | |
| // ******************************************************************************** | |
| // Frame Abstraction | |
| // ******************************************************************************** | |
| class Frame { | |
| constructor(x, y, width, height) { | |
| this.x = x; | |
| this.y = y; | |
| this.width = width; | |
| this.height = height; | |
| } | |
| pad() { | |
| return new Frame( | |
| this.x + GEOM.PADDING / 2, | |
| this.y + GEOM.PADDING / 2, | |
| this.width - GEOM.PADDING, | |
| this.height - GEOM.PADDING | |
| ) | |
| } | |
| snap(screen, dir) { // Expects a DIR.* token; unsupported values leave frame unchanged. | |
| const s = screen.flippedVisibleFrame(); | |
| const f = new Frame(this.x, this.y, this.width, this.height); | |
| // Align to right side for east-oriented quadrants | |
| if ([DIR.EAST, DIR.NE, DIR.SE].includes(dir)) f.x += s.width - f.width; | |
| // Align to bottom side for south-oriented quadrants | |
| if ([DIR.SE, DIR.SW].includes(dir)) f.y += s.height - f.height; | |
| // Full width if explicitly requested | |
| if (dir === DIR.FULL) f.width = s.width; | |
| // Full height for full or pure east/west halves | |
| if ([DIR.FULL, DIR.EAST, DIR.WEST].includes(dir)) f.height = s.height; | |
| return f; | |
| } | |
| displace(x, y) { // Does not clamp to screen intentionally (used for relative moves) | |
| return new Frame( | |
| this.x + x, | |
| this.y + y, | |
| this.width, | |
| this.height | |
| ) | |
| } | |
| log() { | |
| Phoenix.log(this.x + ", " + this.y + ", " + this.width + ", " + this.height); | |
| return this; | |
| } | |
| rect() { | |
| return { | |
| x: this.x, | |
| y: this.y, | |
| width: this.width, | |
| height: this.height | |
| } | |
| } | |
| ratio(a, b) { // Returns a transformer that scales a frame from space a -> space b | |
| var wr = b.width / a.width, | |
| hr = b.height / a.height; | |
| return ({ x, y, width, height }) => { | |
| x = Math.round(b.x + (x - a.x) * wr); | |
| y = Math.round(b.y + (y - a.y) * hr); | |
| width = Math.round(width * wr); | |
| height = Math.round(height * hr); | |
| return { x, y, width, height } | |
| } | |
| } | |
| } | |
| // ******************************************************************************** | |
| // Window Extensions | |
| // ******************************************************************************** | |
| // Snap a window in a given direction | |
| Window.prototype.to = function (direction) { // Half-screen quadrant snap utility | |
| var s = this.screen(), | |
| f = s.flippedVisibleFrame(), | |
| frame = new Frame( | |
| f.x, | |
| f.y, | |
| f.width / 2, | |
| f.height / 2, | |
| ).snap(s, direction).pad(); | |
| this.setFrame(frame); | |
| }; | |
| // Move a window to a given screen | |
| Window.prototype.toScreen = function (dir) { // East/West nearest screen by X origin (vertical stack not handled yet) | |
| var screen = null; | |
| if (dir === DIR.EAST || dir === DIR.WEST) { | |
| const cur = this.screen(); | |
| const curX = cur.origin().x; | |
| // Find nearest screen strictly to the east or west | |
| screen = Screen.all() | |
| .filter(s => dir === DIR.EAST ? s.origin().x > curX : s.origin().x < curX) | |
| .sort((a, b) => Math.abs(a.origin().x - curX) - Math.abs(b.origin().x - curX))[0]; | |
| if (screen) { | |
| const ratio = Frame.prototype.ratio(cur.flippedVisibleFrame(), screen.flippedVisibleFrame()); | |
| this.setFrame(ratio(this.frame())); | |
| } | |
| } | |
| return this; | |
| }; | |
| // Algorithmic vertical repositioning: cycles a column between spanning both rows and a single-top/single-bottom cell. | |
| // Grid assumptions: 2 rows layout (cells even; indices laid out row-major). Examples (4 cells: 2x2, 6 cells: 3x2) | |
| // Behavior: | |
| // - If window spans both rows in a column and moved NORTH -> collapse to top cell of that column. | |
| // - If window spans both rows and moved SOUTH -> collapse to bottom cell of that column. | |
| // - If window is a single bottom cell and moved NORTH -> move to top cell (same column). | |
| // - If window is a single top cell and moved SOUTH -> move to bottom cell (same column). | |
| // - Otherwise no change. | |
| Window.prototype.reposition = function(dir) { | |
| if (!this.isEqual(LAST_POSITION.window)) return this; | |
| const spec = LAST_POSITION.grid; // e.g. "6,2,5" | |
| if (!spec) return this; | |
| const parts = spec.split(',').map(s => parseInt(s,10)); | |
| if (parts.length !== 3 || parts.some(isNaN)) return this; | |
| const [cells,start,end] = parts; | |
| if (cells % 2 !== 0) return this; // only handle 2-row grids (row-major assumption) | |
| const cols = cells / 2; | |
| const startCol = start % cols; | |
| const endCol = end % cols; | |
| const startRow = Math.floor(start / cols); | |
| const endRow = Math.floor(end / cols); | |
| let targetStart = start, targetEnd = end; | |
| if (dir === DIR.NORTH || dir === DIR.SOUTH) { | |
| // Vertical cycling (existing logic, slightly reorganized) | |
| // Operate only when selection is a single column span (may cover both rows or a single cell) | |
| if (startCol !== endCol) return this; | |
| const spansBoth = startRow !== endRow; // two rows | |
| if (dir === DIR.NORTH) { | |
| if (spansBoth || startRow === 1) { // spanning or bottom single -> collapse to top | |
| targetStart = startCol; // row 0 | |
| targetEnd = targetStart; | |
| } else return this; // already top | |
| } else { // SOUTH | |
| if (spansBoth || startRow === 0) { // spanning or top single -> collapse to bottom | |
| targetStart = startCol + cols; // row 1 | |
| targetEnd = targetStart; | |
| } else return this; // already bottom | |
| } | |
| } else if (dir === DIR.EAST || dir === DIR.WEST) { | |
| // Horizontal cycling: mirror vertical behavior across columns within a single row. | |
| // Only operate when selection spans a single row (may cover multiple columns) OR is a single cell. | |
| if (startRow !== endRow) return this; // ignore multi-row spans (vertical logic handles those) | |
| const spansMultiple = startCol !== endCol; | |
| if (spansMultiple) { | |
| // Collapse a multi-column span to an edge cell depending on direction. | |
| if (dir === DIR.WEST) { | |
| targetStart = startRow * cols + startCol; // leftmost | |
| targetEnd = targetStart; | |
| } else { // EAST | |
| targetStart = startRow * cols + endCol; // rightmost | |
| targetEnd = targetStart; | |
| } | |
| } else { // single cell -> move laterally within row if possible | |
| if (dir === DIR.WEST && startCol > 0) { | |
| targetStart = targetEnd = start - 1; | |
| } else if (dir === DIR.EAST && startCol < cols - 1) { | |
| targetStart = targetEnd = start + 1; | |
| } else return this; // edge cell, no change | |
| } | |
| } else { | |
| return this; // unsupported direction | |
| } | |
| if (targetStart !== start || targetEnd !== end) { | |
| this.positionInGrid(cells, targetStart, targetEnd); | |
| } | |
| return this; | |
| }; | |
| Window.prototype.positionInGrid = function (cells, start, end) { | |
| if (cells <= 0) return this; | |
| if (start < 0 || end < 0 || start >= cells || end >= cells) return this; // Guard invalid indices | |
| LAST_POSITION = { | |
| window: this, | |
| grid: cells + "," + start + "," + end | |
| } | |
| var cols = ~~(cells / 2); | |
| var screen = this.screen(); | |
| var cellwidth = (screen.width() - ((cols - 1) * GEOM.PADDING)) / cols; | |
| var cellheight = (screen.height() - GEOM.PADDING) / 2; | |
| var startc = start % cols, | |
| startw = ~~(start / cols), | |
| startl = screen.origin().x + (cellwidth + GEOM.PADDING) * startc, | |
| startt = screen.origin().y + (cellheight + GEOM.PADDING) * startw, | |
| startr = startl + cellwidth, | |
| startb = startt + cellheight; | |
| var endc = end % cols, | |
| endw = ~~(end / cols), | |
| endl = screen.origin().x + (cellwidth + GEOM.PADDING) * endc, | |
| endt = screen.origin().y + (cellheight + GEOM.PADDING) * endw, | |
| endr = endl + cellwidth, | |
| endb = endt + cellheight; | |
| var frame = this.frame(); | |
| frame.x = Math.min(startl, endl); | |
| frame.y = Math.min(startt, endt); | |
| frame.width = Math.max(startr, endr) - frame.x; | |
| frame.height = Math.max(startb, endb) - frame.y; | |
| this.setFrame(frame); | |
| return this; | |
| } | |
| // Resize a window by coeff units in the given direction | |
| // coeff: -n shrinks by pixels units, +n grows by n pixels | |
| Window.prototype.resize = function (dir, coeff) { | |
| var frame = this.frame() | |
| if (dir === DIR.WEST) frame.x += coeff * -1 | |
| if (dir === DIR.NORTH) frame.y += coeff * -1 | |
| if ([DIR.EAST, DIR.WEST].indexOf(dir) > -1) frame.width += coeff | |
| if ([DIR.NORTH, DIR.SOUTH].indexOf(dir) > -1) frame.height += coeff | |
| // Minimal size clamp | |
| if (frame.width < 40) frame.width = 40; | |
| if (frame.height < 40) frame.height = 40; | |
| this.setFrame(frame) | |
| return this | |
| } | |
| Window.prototype.toSpace = function (dir) { | |
| var curSpace = this.spaces()[0], | |
| newSpace = curSpace.next() | |
| if (dir === DIR.WEST) | |
| newSpace = curSpace.previous() | |
| if (!newSpace) return this; // Guard end-of-chain | |
| curSpace.removeWindows([this]) | |
| newSpace.addWindows([this]) | |
| this.focus() | |
| return this | |
| } | |
| Window.prototype.centerMouse = function () { | |
| if (!FOCUS.MOVE_POINTER_ON_FOCUS) return this; | |
| Mouse.move({ | |
| x: this.frame().x + this.frame().width / 2, | |
| y: this.frame().y + this.frame().height / 2 | |
| }) | |
| return this | |
| } | |
| // Nudge the window by an (x,y) offset determined by direction | |
| // dir: one of EAST/WEST/NORTH/SOUTH or single-char h/j/k/l synonyms | |
| // amount: pixels (default MOVE.NUDGE_SMALL) | |
| Window.prototype.nudge = function (dir, amount) { | |
| amount = amount || MOVE.NUDGE_SMALL; | |
| const frame = this.frame(); | |
| switch (dir) { | |
| case DIR.EAST: case 'E': case 'l': frame.x += amount; break; | |
| case DIR.WEST: case 'W': case 'h': frame.x -= amount; break; | |
| case DIR.SOUTH: case 'S': case 'j': frame.y += amount; break; | |
| case DIR.NORTH: case 'N': case 'k': frame.y -= amount; break; | |
| default: return this; | |
| } | |
| // Clamp to screen visible frame | |
| try { | |
| const s = this.screen().flippedVisibleFrame(); | |
| if (frame.x < s.x) frame.x = s.x; | |
| if (frame.y < s.y) frame.y = s.y; | |
| if (frame.x + frame.width > s.x + s.width) frame.x = s.x + s.width - frame.width; | |
| if (frame.y + frame.height > s.y + s.height) frame.y = s.y + s.height - frame.height; | |
| } catch (e) { } | |
| this.setFrame(frame); | |
| return this; | |
| } | |
| Window.prototype.log = function () { | |
| Phoenix.log(this.frame().x + "," + this.frame().y + "," + this.frame().width + "," + this.frame().height) | |
| return this | |
| } | |
| // ******************************************************************************** | |
| // Focus Adjacent Window | |
| // ******************************************************************************** | |
| // Refined adjacency: uses frame edges & overlap rather than just centers. | |
| // Ranking (updated): | |
| // 1. Overlapping along perpendicular axis (preferred) | |
| // 2. Larger overlap span (how much they overlap along the perpendicular axis) | |
| // 3. Larger window area (prefer substantial targets) | |
| // 4. Primary directional distance (edge-to-edge, nearer is better) | |
| // 5. Perpendicular center distance (tie-breaker) | |
| // If no overlapping candidates exist, non-overlapping ones are considered (overlap span = 0). | |
| // Does not traverse to other screens (future extension point). | |
| // Simplified fallback implementation: rebuild candidate list on-the-fly each invocation. | |
| // Ignores registry neighbor indices; uses direct spatial filtering + ranking. | |
| Window.prototype.focusAdjacent = function (dir) { | |
| const cur = this.frame(); | |
| const screen = this.screen(); | |
| // Ensure registry is fresh if we recently augmented enumeration (some windows might have been missing earlier) | |
| // TTL-based purging disabled: we no longer clear the registry here; event hooks keep it current. | |
| // (Retained guard intentionally lightweight.) | |
| try { if (!WindowRegistry.containsWindow || !WindowRegistry.containsWindow(this)) { /* no action */ } } catch (e) { } | |
| // Fast path: pull stats first (does not rebuild). If snapshot still valid soon, we avoid revalidation churn across rapid key repeats. | |
| const stats = (WindowRegistry.stats && WindowRegistry.stats()) || null; | |
| const enumRec = (WindowRegistry.getIndices && WindowRegistry.getIndices(screen)) ? WindowRegistry.getIndices(screen) : null; | |
| const allEntries = (WindowRegistry.get && WindowRegistry.get(screen)) || []; | |
| const screenEntries = allEntries.filter(e => !e.win.isEqual(this)); | |
| if (!screenEntries.length) return this; | |
| const { byLeft, byRight, byTop, byBottom } = enumRec || { byLeft:[], byRight:[], byTop:[], byBottom:[] }; | |
| // Allow a small tolerance so touching or slightly overlapping edges still count | |
| const TOL = FOCUS.ENABLE_GLOBAL_MRU ? GEOM.OVERLAP_TOLERANCE : 0; // pixels (centralized) | |
| const curCenterX = cur.x + cur.width / 2; | |
| const curCenterY = cur.y + cur.height / 2; | |
| const horizOverlap = (a, b) => !(a.x + a.width <= b.x + TOL || b.x + b.width <= a.x + TOL); | |
| const vertOverlap = (a, b) => !(a.y + a.height <= b.y + TOL || b.y + b.height <= a.y + TOL); | |
| // MRU bias helper (lower rank = more recent). Missing => large number. | |
| function mruRank(win) { | |
| if (!FOCUS.ENABLE_GLOBAL_MRU || !FOCUS_MRU.length) return 9999; | |
| const h = win.hash(); | |
| const idx = FOCUS_MRU.indexOf(h); | |
| return idx === -1 ? 9999 : idx; // index 0 = most recent | |
| } | |
| function directionalMruRank(win) { | |
| if (!FOCUS.ENABLE_DIRECTIONAL_MRU) return 9999; | |
| const list = DIRECTIONAL_FOCUS_MRU[dir]; | |
| if (!list || !list.length) return 9999; | |
| const h = win.hash(); | |
| const idx = list.indexOf(h); | |
| return idx === -1 ? 9999 : idx; | |
| } | |
| function classify(entry) { | |
| const w = entry.win; | |
| const f = entry.frame; | |
| const centerX = entry.cx; | |
| const centerY = entry.cy; | |
| let primaryDist = Infinity; | |
| let overlapsPerp = false; | |
| let overlapSpan = 0; | |
| switch (dir) { | |
| case DIR.NORTH: | |
| if (f.y + f.height <= cur.y + TOL || centerY < curCenterY) { | |
| overlapsPerp = horizOverlap(f, cur); | |
| if (overlapsPerp) { | |
| const left = Math.max(f.x, cur.x); | |
| const right = Math.min(f.x + f.width, cur.x + cur.width); | |
| overlapSpan = Math.max(0, right - left); | |
| } | |
| primaryDist = Math.max(0, cur.y - (f.y + f.height)); | |
| } | |
| break; | |
| case DIR.SOUTH: | |
| if (f.y >= cur.y + cur.height - TOL || centerY > curCenterY) { | |
| overlapsPerp = horizOverlap(f, cur); | |
| if (overlapsPerp) { | |
| const left = Math.max(f.x, cur.x); | |
| const right = Math.min(f.x + f.width, cur.x + cur.width); | |
| overlapSpan = Math.max(0, right - left); | |
| } | |
| primaryDist = Math.max(0, f.y - (cur.y + cur.height)); | |
| } | |
| break; | |
| case DIR.EAST: | |
| if (f.x >= cur.x + cur.width - TOL || centerX > curCenterX) { | |
| overlapsPerp = vertOverlap(f, cur); | |
| if (overlapsPerp) { | |
| const top = Math.max(f.y, cur.y); | |
| const bottom = Math.min(f.y + f.height, cur.y + cur.height); | |
| overlapSpan = Math.max(0, bottom - top); | |
| } | |
| primaryDist = Math.max(0, f.x - (cur.x + cur.width)); | |
| } | |
| break; | |
| case DIR.WEST: | |
| if (f.x + f.width <= cur.x + TOL || centerX < curCenterX) { | |
| overlapsPerp = vertOverlap(f, cur); | |
| if (overlapsPerp) { | |
| const top = Math.max(f.y, cur.y); | |
| const bottom = Math.min(f.y + f.height, cur.y + cur.height); | |
| overlapSpan = Math.max(0, bottom - top); | |
| } | |
| primaryDist = Math.max(0, cur.x - (f.x + f.width)); | |
| } | |
| break; | |
| default: | |
| return null; | |
| } | |
| if (primaryDist === Infinity) return null; | |
| // Lazy compute of perpendicular center delta only if needed later; store centers for tie-break. | |
| return { w, f, overlapsPerp, overlapSpan, area: f.width * f.height, primaryDist, cx: centerX, cy: centerY, z: entry.z, mru: mruRank(w), dmru: directionalMruRank(w) }; | |
| } | |
| // Binary search helpers for lower/upper bounds | |
| function upperBound(arr, getter, value) { // last index with getter(el) <= value | |
| let lo = 0, hi = arr.length - 1, ans = -1; | |
| while (lo <= hi) { | |
| const mid = (lo + hi) >> 1; | |
| if (getter(arr[mid]) <= value) { ans = mid; lo = mid + 1; } else hi = mid - 1; | |
| } | |
| return ans; | |
| } | |
| function lowerBound(arr, getter, value) { // first index with getter(el) >= value | |
| let lo = 0, hi = arr.length - 1, ans = arr.length; | |
| while (lo <= hi) { | |
| const mid = (lo + hi) >> 1; | |
| if (getter(arr[mid]) >= value) { ans = mid; hi = mid - 1; } else lo = mid + 1; | |
| } | |
| return ans; | |
| } | |
| let candidatePool = []; | |
| // Directional coarse filtering | |
| // Optimized scanning: iterate only directional-sorted list, early exit once distance exceeds best. | |
| let directionalList = []; | |
| let forward = true; // iteration direction | |
| if (dir === DIR.NORTH) { directionalList = byBottom; forward = true; } | |
| else if (dir === DIR.SOUTH) { directionalList = byTop; forward = true; } | |
| else if (dir === DIR.EAST) { directionalList = byLeft; forward = true; } | |
| else if (dir === DIR.WEST) { directionalList = byRight; forward = true; } | |
| else directionalList = screenEntries; // fallback | |
| const MAX_SCAN = PERF.FOCUS_MAX_SCAN || 80; // safety cap | |
| const DIST_CAP = PERF.FOCUS_DISTANCE_CAP || Infinity; // optional absolute distance cap | |
| let bestDist = Infinity; | |
| let ties = []; | |
| function consider(entry) { | |
| const c = classify(entry); if (!c) return; | |
| if (c.primaryDist > DIST_CAP) return; | |
| if (c.primaryDist < bestDist) { bestDist = c.primaryDist; ties = [c]; } | |
| else if (c.primaryDist === bestDist) { ties.push(c); } | |
| } | |
| // Determine starting index bounds for directional scan to reduce iterations | |
| if (dir === DIR.NORTH) { | |
| // Find last index whose bottom edge <= cur.y + TOL | |
| const ub = upperBound(directionalList, e => e.frame.y + e.frame.height, cur.y + TOL); | |
| for (let i = ub, scanned = 0; i >= 0 && scanned < MAX_SCAN; i--, scanned++) { | |
| const e = directionalList[i]; | |
| const distEdge = cur.y - (e.frame.y + e.frame.height); | |
| if (bestDist !== Infinity && distEdge > bestDist + TOL) break; // further ones are farther north | |
| consider(e); | |
| } | |
| } else if (dir === DIR.SOUTH) { | |
| // Start at first window whose top edge >= bottom of current - TOL | |
| const lb = lowerBound(directionalList, e => e.frame.y, cur.y + cur.height - TOL); | |
| for (let i = lb, scanned = 0; i < directionalList.length && scanned < MAX_SCAN; i++, scanned++) { | |
| const e = directionalList[i]; | |
| const distEdge = e.frame.y - (cur.y + cur.height); | |
| if (bestDist !== Infinity && distEdge > bestDist + TOL) break; // remaining tops only increase distance | |
| consider(e); | |
| } | |
| } else if (dir === DIR.EAST) { | |
| const lb = lowerBound(directionalList, e => e.frame.x, cur.x + cur.width - TOL); | |
| for (let i = lb, scanned = 0; i < directionalList.length && scanned < MAX_SCAN; i++, scanned++) { | |
| const e = directionalList[i]; | |
| const distEdge = e.frame.x - (cur.x + cur.width); | |
| if (bestDist !== Infinity && distEdge > bestDist + TOL) break; | |
| consider(e); | |
| } | |
| } else if (dir === DIR.WEST) { | |
| const ub = upperBound(directionalList, e => e.frame.x + e.frame.width, cur.x + TOL); | |
| for (let i = ub, scanned = 0; i >= 0 && scanned < MAX_SCAN; i--, scanned++) { | |
| const e = directionalList[i]; | |
| const distEdge = cur.x - (e.frame.x + e.frame.width); | |
| if (bestDist !== Infinity && distEdge > bestDist + TOL) break; | |
| consider(e); | |
| } | |
| } else { | |
| // Fallback: classify all (rare path) | |
| screenEntries.forEach(consider); | |
| } | |
| if (!ties.length) { | |
| // cross-screen fallback logic below will handle empty | |
| } else if (ties.length > 1) { | |
| // Apply secondary tie-break ordering (overlap -> span -> perpendicular center -> MRU -> area -> z) | |
| ties.sort((a,b)=>{ | |
| const ov = (a.overlapsPerp===b.overlapsPerp?0:(a.overlapsPerp?-1:1)); if (ov) return ov; | |
| const span = b.overlapSpan - a.overlapSpan; if (span) return span; | |
| const aPerp = (dir===DIR.NORTH||dir===DIR.SOUTH)?Math.abs(curCenterX-a.cx):Math.abs(curCenterY-a.cy); | |
| const bPerp = (dir===DIR.NORTH||dir===DIR.SOUTH)?Math.abs(curCenterX-b.cx):Math.abs(curCenterY-b.cy); | |
| if (aPerp!==bPerp) return aPerp-bPerp; | |
| const dmru = a.dmru - b.dmru; if (dmru) return dmru; | |
| const mru = a.mru - b.mru; if (mru) return mru; | |
| const area = b.area - a.area; if (area) return area; | |
| return (a.z??9999)-(b.z??9999); | |
| }); | |
| } | |
| const target = ties[0]; | |
| if (target) { | |
| target.w.focus(); | |
| // Record directional MRU hit | |
| recordDirectionalFocus(dir, target.w); | |
| // Return the newly focused window so method chaining (e.g. .centerMouse()) applies to it | |
| return target.w; | |
| } | |
| // Fallback: if no candidate on this screen, optionally jump to nearest window on adjacent screen in that direction | |
| try { | |
| const allScreens = Screen.all(); | |
| const curIdx = allScreens.findIndex(s => s.isEqual(screen)); | |
| let nextScreen = null; | |
| if (dir === DIR.EAST && curIdx < allScreens.length - 1) nextScreen = allScreens[curIdx + 1]; | |
| else if (dir === DIR.WEST && curIdx > 0) nextScreen = allScreens[curIdx - 1]; | |
| else if ((dir === DIR.NORTH || dir === DIR.SOUTH) && allScreens.length > 1) { | |
| // Pick screen whose vertical center is closest (simple heuristic) | |
| const curMidX = cur.x + cur.width / 2; | |
| let best = null, bestDist = Infinity; | |
| for (let s of allScreens) { | |
| if (s.isEqual(screen)) continue; | |
| const f = s.frame(); | |
| const midX = f.x + f.width / 2; | |
| const d = Math.abs(midX - curMidX); | |
| if (d < bestDist) { best = s; bestDist = d; } | |
| } | |
| nextScreen = best; | |
| } | |
| if (nextScreen) { | |
| const foreignEntries = WindowRegistry.get(nextScreen); | |
| if (foreignEntries && foreignEntries.length) { | |
| // Choose top candidate by proximity of centers along intended axis | |
| let best = null, bestMetric = Infinity; | |
| foreignEntries.forEach(e => { | |
| const f = e.frame; | |
| let metric = 0; | |
| if (dir === DIR.EAST) metric = Math.max(0, f.x - (cur.x + cur.width)); | |
| else if (dir === DIR.WEST) metric = Math.max(0, cur.x - (f.x + f.width)); | |
| else if (dir === DIR.NORTH) metric = Math.max(0, cur.y - (f.y + f.height)); | |
| else if (dir === DIR.SOUTH) metric = Math.max(0, f.y - (cur.y + cur.height)); | |
| if (metric < bestMetric) { bestMetric = metric; best = e.win; } | |
| }); | |
| if (best) { best.focus(); recordDirectionalFocus(dir, best); return best; } | |
| } | |
| } | |
| } catch (e) { } | |
| return this; // no change | |
| } | |
| // ******************************************************************************** | |
| // Modal Extensions | |
| // ******************************************************************************** | |
| // Flash a modal in the center of a given screen | |
| Modal.prototype.flash = function (screen) { | |
| var tf = this.frame(), | |
| sf = screen.frame(); | |
| this.origin = { | |
| x: sf.x + sf.width / 2 - tf.width / 2, | |
| y: sf.y + sf.height / 2 - tf.height / 2 | |
| } | |
| this.show(); | |
| return this; | |
| } | |
| Modal.prototype.attach = function (window) { | |
| var tf = this.frame(), | |
| wf = window.frame(), | |
| sf = window.screen().frame(); | |
| this.origin = { | |
| x: Math.min( | |
| Math.max(wf.x + wf.width / 2 - tf.width / 2, sf.x), | |
| sf.x + sf.width - tf.width | |
| ), | |
| y: Math.min( | |
| Math.max(Screen.all()[0].frame().height - (wf.y + wf.height / 2 + tf.height / 2), sf.y), | |
| sf.y + sf.height - tf.height | |
| ) | |
| }; | |
| this.show(); | |
| return this; | |
| } | |
| // ******************************************************************************** | |
| // Screen Extensions | |
| // ******************************************************************************** | |
| Screen.prototype.width = function () { // DEPRECATED | |
| return this.flippedVisibleFrame().width - GEOM.PADDING * 2 | |
| } | |
| Screen.prototype.height = function () { // DEPRECATED | |
| return this.flippedVisibleFrame().height - GEOM.PADDING * 2 | |
| } | |
| Screen.prototype.origin = function () { // DEPRECATED | |
| return { | |
| x: this.flippedVisibleFrame().x + GEOM.PADDING, | |
| y: this.flippedVisibleFrame().y + GEOM.PADDING | |
| } | |
| } | |
| // ******************************************************************************** | |
| // Utilities | |
| // ******************************************************************************** | |
| // Simplified WindowRegistry (On-Demand) | |
| // ------------------------------------ | |
| // Design goals: | |
| // - Zero state / zero invalidation: every call enumerates live windows via Util.windowsForScreen. | |
| // - Stable API surface for existing consumers (hints, layout) without adaptive TTL / neighbor graphs. | |
| // - Cheap enough for interactive usage given typical window counts (< ~150). If this grows, add shallow memoization. | |
| // - Space partitioning retained (first-space heuristic) for hint scoping. | |
| // - Methods that previously exposed advanced data (neighbors, stats, miss streak) now return benign placeholders. | |
| const WindowRegistry = (() => { | |
| // Ephemeral per-screen enumeration micro-cache for focusAdjacent. | |
| // Stores: { ts, screenId, entries:[{win,frame,cx,cy,z,hash}], indices:{byLeft,byRight,byTop,byBottom}, hashSig } | |
| // hashSig is a sorted concatenation of window hashes to detect structural changes. | |
| let focusEnumCache = new Map(); // screenIndex -> cache record | |
| function buildScreenEnumeration(screen) { | |
| const wins = Util.windowsForScreen(screen) || []; | |
| const entries = wins.map((w,i) => { const f = w.frame(); return { win: w, frame: f, x: f.x, y: f.y, w: f.width, h: f.height, cx: f.x + f.width/2, cy: f.y + f.height/2, area: f.width*f.height, z: i, hash: w.hash() }; }); | |
| const byLeft = entries.slice().sort((a,b)=>a.x-b.x); | |
| const byRight = entries.slice().sort((a,b)=>(a.x+a.w)-(b.x+b.w)); | |
| const byTop = entries.slice().sort((a,b)=>a.y-b.y); | |
| const byBottom = entries.slice().sort((a,b)=>(a.y+a.h)-(b.y+b.h)); | |
| const hashSig = entries.map(e=>e.hash).sort().join(':'); | |
| return { ts: Date.now(), entries, indices:{byLeft,byRight,byTop,byBottom}, hashSig }; | |
| } | |
| function getScreenEnumeration(screen) { | |
| const idx = Screen.all().indexOf(screen); | |
| if (idx === -1) return { entries: [], indices:{byLeft:[],byRight:[],byTop:[],byBottom:[]}}; | |
| const now = Date.now(); | |
| const rec = focusEnumCache.get(idx); | |
| if (rec && (now - rec.ts) <= (PERF.FOCUS_ENUM_CACHE_MS||0)) { | |
| // Validate structural signature still matches to avoid stale after open/close | |
| const wins = Util.windowsForScreen(screen) || []; | |
| const sig = wins.map(w=>w.hash()).sort().join(':'); | |
| if (sig === rec.hashSig) return rec; // reuse fully | |
| } | |
| const fresh = buildScreenEnumeration(screen); | |
| focusEnumCache.set(idx, fresh); | |
| return fresh; | |
| } | |
| function enumerateAll() { | |
| const allEntries = []; | |
| const bySpace = new Map(); | |
| Screen.all().forEach((scr, sIdx) => { | |
| const wins = Util.windowsForScreen(scr); | |
| wins.forEach((w, i) => { | |
| let f; try { f = w.frame(); } catch (e) { return; } | |
| const frame = f; | |
| const entry = { | |
| win: w, | |
| frame, | |
| x: frame.x, y: frame.y, w: frame.width, h: frame.height, | |
| cx: frame.x + frame.width / 2, | |
| cy: frame.y + frame.height / 2, | |
| area: frame.width * frame.height, | |
| z: i, | |
| hash: w.hash(), | |
| }; | |
| allEntries.push({ screenIndex: sIdx, entry }); | |
| // space partition (first space heuristic) | |
| try { | |
| const sp = w.spaces && w.spaces(); | |
| if (sp && sp.length) { | |
| const sid = sp[0].hash ? sp[0].hash() : sp[0]; | |
| if (!bySpace.has(sid)) bySpace.set(sid, []); | |
| bySpace.get(sid).push(entry); | |
| } | |
| } catch (e) { } | |
| }); | |
| }); | |
| return { allEntries, bySpace }; | |
| } | |
| function allEntriesFlat() { return enumerateAll().allEntries.map(o => o.entry); } | |
| return { | |
| get(screen) { | |
| // Provide flat list of entries for this screen using cached enumeration | |
| const idx = Screen.all().indexOf(screen); | |
| if (idx === -1) return []; | |
| return getScreenEnumeration(screen).entries; | |
| }, | |
| containsWindow(win) { | |
| try { const h = win.hash(); return allEntriesFlat().some(e => e.hash === h); } catch (e) { return false; } | |
| }, | |
| getIndices(screen) { | |
| const rec = getScreenEnumeration(screen); | |
| return rec.indices; | |
| }, | |
| getAllEntries() { return allEntriesFlat(); }, | |
| getAllEntriesFlattened() { return allEntriesFlat(); }, | |
| getSpaceEntries(spaceId) { const { bySpace } = enumerateAll(); return bySpace.get(spaceId) || []; }, | |
| updateWindow(win, kind) { | |
| // Incremental update of cached screen enumeration for move/resize. | |
| // If anything unexpected occurs, fall back by deleting the cache entry (forces rebuild on next access). | |
| if (!win) return; | |
| try { | |
| const screen = win.screen && win.screen(); | |
| if (!screen) return; | |
| const idx = Screen.all().indexOf(screen); | |
| if (idx === -1) return; | |
| const rec = focusEnumCache.get(idx); | |
| if (!rec) return; // nothing cached yet | |
| // Locate entry by hash | |
| const h = win.hash(); | |
| const entry = rec.entries.find(e => e.hash === h); | |
| if (!entry) { // structure changed (new window) -> invalidate | |
| focusEnumCache.delete(idx); | |
| return; | |
| } | |
| // Update geometry | |
| const f = win.frame(); | |
| entry.frame = f; | |
| entry.x = f.x; entry.y = f.y; entry.w = f.width; entry.h = f.height; | |
| entry.cx = f.x + f.width / 2; entry.cy = f.y + f.height / 2; | |
| entry.area = f.width * f.height; | |
| // Rebuild directional indices cheaply (local sort on updated entries) | |
| const entries = rec.entries; | |
| rec.indices.byLeft = entries.slice().sort((a,b)=>a.x-b.x); | |
| rec.indices.byRight = entries.slice().sort((a,b)=>(a.x+a.w)-(b.x+b.w)); | |
| rec.indices.byTop = entries.slice().sort((a,b)=>a.y-b.y); | |
| rec.indices.byBottom = entries.slice().sort((a,b)=>(a.y+a.h)-(b.y+b.h)); | |
| rec.ts = Date.now(); // refresh timestamp so cache isn't immediately discarded | |
| // hashSig only needs updating if structure changed (it didn't), so we leave it. | |
| } catch (e) { | |
| // fallback: remove all caches so next access fully rebuilds | |
| try { const screen = win.screen && win.screen(); const idx = Screen.all().indexOf(screen); if (idx !== -1) focusEnumCache.delete(idx); } catch(e2) {} | |
| } | |
| }, | |
| neighbors() { return { North: [], South: [], East: [], West: [] }; }, | |
| clear() { focusEnumCache.clear(); }, | |
| stats() { return { ttl: 0, expiresIn: 0, lastDuration: 0, version: 0, cachedFlat: false }; }, | |
| recordFocusMiss() {}, getMissStreak() { return 0; }, resetMissStreak() {}, | |
| }; | |
| })(); | |
| // Layout cache: key = screenIndex|mode|windowHashes (sorted). Stores array of frames. | |
| const LAYOUT_CACHE = new Map(); | |
| function layoutCacheKey(screen, mode, windows) { | |
| const idx = Screen.all().indexOf(screen); | |
| const hashes = windows.map(w => w.hash()).sort().join(':'); | |
| return idx + '|' + mode + '|' + hashes; | |
| } | |
| // (Removed) Array.prototype.rotate -- replaced with Util.rotateArray to avoid prototype pollution. | |
| // Opposite direction lookup (includes diagonals for symmetry operations) | |
| const OPPOSITE = Object.freeze({ | |
| [DIR.NORTH]: DIR.SOUTH, | |
| [DIR.SOUTH]: DIR.NORTH, | |
| [DIR.EAST]: DIR.WEST, | |
| [DIR.WEST]: DIR.EAST, | |
| [DIR.NW]: DIR.SE, | |
| [DIR.NE]: DIR.SW, | |
| [DIR.SW]: DIR.NE, | |
| [DIR.SE]: DIR.NW, | |
| }); | |
| function opposite(dir) { return OPPOSITE[dir] || dir; } | |
| // ******************************************************************************** | |
| // Focus MRU Tracking | |
| // ******************************************************************************** | |
| // Tracks most recently focused windows to bias adjacency (recency preference after area/overlap). | |
| const FOCUS_MRU = []; | |
| // Directional MRU: direction -> array of window hashes (most recent first) | |
| const DIRECTIONAL_FOCUS_MRU = { North: [], South: [], East: [], West: [] }; | |
| function recordFocus(win) { | |
| if (!FOCUS.ENABLE_GLOBAL_MRU || !win) return; | |
| try { | |
| const h = win.hash(); | |
| const idx = FOCUS_MRU.indexOf(h); | |
| if (idx !== -1) FOCUS_MRU.splice(idx, 1); | |
| FOCUS_MRU.unshift(h); | |
| if (FOCUS_MRU.length > FOCUS.GLOBAL_MRU_LIMIT) FOCUS_MRU.pop(); | |
| } catch (e) { } | |
| } | |
| function recordDirectionalFocus(dir, win) { | |
| if (!FOCUS.ENABLE_DIRECTIONAL_MRU || !win) return; | |
| const bucket = DIRECTIONAL_FOCUS_MRU[dir]; | |
| if (!bucket) return; | |
| try { | |
| const h = win.hash(); | |
| const idx = bucket.indexOf(h); | |
| if (idx !== -1) bucket.splice(idx, 1); | |
| bucket.unshift(h); | |
| if (bucket.length > FOCUS.DIRECTIONAL_MRU_LIMIT) bucket.pop(); | |
| } catch (e) { } | |
| } | |
| ['windowDidBecomeMain', 'windowDidFocus'].forEach(ev => Event.on(ev, () => { | |
| try { const w = Window.focused(); if (w) recordFocus(w); } catch (e) { } | |
| })); | |
| // ******************************************************************************** | |
| // Startup | |
| // ******************************************************************************** | |
| const HintManager = new Hints() // ISSUE: Singleton pattern; if reloaded dynamically may leak old key bindings. | |
| // ******************************************************************************** | |
| // Move Mode Controller | |
| // ******************************************************************************** | |
| class MoveModeController { | |
| constructor() { | |
| this.active = false; | |
| this.window = null; | |
| this.overlay = null; | |
| this.bindings = []; | |
| this.lastMoveDir = null; // Track last nudge direction for anchored resize | |
| } | |
| _buildOverlay() { | |
| if (!this.window) return; | |
| try { if (this.overlay) this.overlay.close(); } catch (e) { } | |
| try { | |
| // Fetch or cache the app icon for the focused window | |
| let ic = undefined; | |
| try { | |
| const key = iconKey(this.window); | |
| if (!ICON_CACHE[key]) { | |
| ICON_CACHE[key] = this.window.app().icon(); | |
| } | |
| ic = ICON_CACHE[key]; | |
| } catch (e) { } | |
| this.overlay = Modal.build({ | |
| text: '[ MOVE ]\nEnter/Esc to exit', | |
| appearance: HINT.APPEARANCE, | |
| weight: 18, | |
| duration: 0, // persistent until closed | |
| icon: ic | |
| }).attach(this.window); | |
| } catch (e) { } | |
| } | |
| _repositionOverlay() { // refresh modal position after window move | |
| if (!this.overlay || !this.window) return; | |
| try { this.overlay.attach(this.window); } catch (e) { } | |
| } | |
| activate(win) { | |
| if (this.active) { // toggle off | |
| return this.deactivate(); | |
| } | |
| this.window = win || Window.focused(); | |
| if (!this.window) return; | |
| this.active = true; | |
| this._buildOverlay(); | |
| this._bindKeys(); | |
| } | |
| _bindKeys() { | |
| const self = this; | |
| const push = (k, mods, fn) => { self.bindings.push(Key.on(k, mods, fn)); }; | |
| function nudgeDir(dir, amt) { | |
| if (!self.window || !self.active) return; | |
| self.window.nudge(dir, amt); | |
| self.lastMoveDir = dir; // record direction for anchored resizing | |
| self._repositionOverlay(); | |
| } | |
| function resizeAxis(dir, amt, grow=true) { | |
| if (!self.window || !self.active) return; | |
| const w = self.window; | |
| const f = w.frame(); | |
| const MIN = 40; | |
| // Simplified, predictable semantics: | |
| // Horizontal: | |
| // Control+L grow width to the right (anchor left) | |
| // Control+H shrink width from the right (anchor left) | |
| // Control+Shift+L shrink from left (anchor right) | |
| // Control+Shift+H grow to left (anchor right) | |
| // For grow=true: extend in direction; grow=false: shrink from direction. | |
| if (dir === DIR.EAST) { | |
| if (grow) { | |
| // Grow to right (anchor left) | |
| f.width += amt; | |
| } else { | |
| // Shrink from right (anchor left) | |
| f.width = Math.max(MIN, f.width - amt); | |
| } | |
| } else if (dir === DIR.WEST) { | |
| if (grow) { | |
| // Grow to left (anchor right) | |
| f.x -= amt; f.width += amt; | |
| } else { | |
| // Shrink from left (anchor right) | |
| if (f.width - amt >= MIN) { f.x += amt; f.width -= amt; } else { f.x += f.width - MIN; f.width = MIN; } | |
| } | |
| } else if (dir === DIR.SOUTH) { | |
| if (grow) { | |
| // Grow downward (anchor top) | |
| f.height += amt; | |
| } else { | |
| // Shrink from bottom (anchor top) | |
| f.height = Math.max(MIN, f.height - amt); | |
| } | |
| } else if (dir === DIR.NORTH) { | |
| if (grow) { | |
| // Grow upward (anchor bottom) | |
| f.y -= amt; f.height += amt; | |
| } else { | |
| // Shrink from top (anchor bottom) | |
| if (f.height - amt >= MIN) { f.y += amt; f.height -= amt; } else { f.y += f.height - MIN; f.height = MIN; } | |
| } | |
| } | |
| if (f.width < MIN) f.width = MIN; | |
| if (f.height < MIN) f.height = MIN; | |
| w.setFrame(f); | |
| self._repositionOverlay(); | |
| } | |
| // Unmodified keys = LARGE nudges | |
| push('h', [], () => nudgeDir(DIR.WEST, MOVE.NUDGE_LARGE)); | |
| push('j', [], () => nudgeDir(DIR.SOUTH, MOVE.NUDGE_LARGE)); | |
| push('k', [], () => nudgeDir(DIR.NORTH, MOVE.NUDGE_LARGE)); | |
| push('l', [], () => nudgeDir(DIR.EAST, MOVE.NUDGE_LARGE)); | |
| push('left', [], () => nudgeDir(DIR.WEST, MOVE.NUDGE_LARGE)); | |
| push('down', [], () => nudgeDir(DIR.SOUTH, MOVE.NUDGE_LARGE)); | |
| push('up', [], () => nudgeDir(DIR.NORTH, MOVE.NUDGE_LARGE)); | |
| push('right', [], () => nudgeDir(DIR.EAST, MOVE.NUDGE_LARGE)); | |
| // Shift-modified keys = SMALL nudges | |
| push('h', ['shift'], () => nudgeDir(DIR.WEST, MOVE.NUDGE_SMALL)); | |
| push('j', ['shift'], () => nudgeDir(DIR.SOUTH, MOVE.NUDGE_SMALL)); | |
| push('k', ['shift'], () => nudgeDir(DIR.NORTH, MOVE.NUDGE_SMALL)); | |
| push('l', ['shift'], () => nudgeDir(DIR.EAST, MOVE.NUDGE_SMALL)); | |
| push('left', ['shift'], () => nudgeDir(DIR.WEST, MOVE.NUDGE_SMALL)); | |
| push('down', ['shift'], () => nudgeDir(DIR.SOUTH, MOVE.NUDGE_SMALL)); | |
| push('up', ['shift'], () => nudgeDir(DIR.NORTH, MOVE.NUDGE_SMALL)); | |
| push('right', ['shift'], () => nudgeDir(DIR.EAST, MOVE.NUDGE_SMALL)); | |
| // Control = grow toward direction; Control+Shift = shrink from the *opposite* edge (i.e. shrink in opposite direction). | |
| const g = MOVE.NUDGE_SMALL; | |
| // Grow | |
| push('h', ['control'], () => resizeAxis(DIR.WEST, g, true)); | |
| push('l', ['control'], () => resizeAxis(DIR.EAST, g, true)); | |
| push('left', ['control'], () => resizeAxis(DIR.WEST, g, true)); | |
| push('right', ['control'], () => resizeAxis(DIR.EAST, g, true)); | |
| push('k', ['control'], () => resizeAxis(DIR.NORTH, g, true)); | |
| push('j', ['control'], () => resizeAxis(DIR.SOUTH, g, true)); | |
| push('up', ['control'], () => resizeAxis(DIR.NORTH, g, true)); | |
| push('down', ['control'], () => resizeAxis(DIR.SOUTH, g, true)); | |
| // Shrink using opposite direction target (so key direction indicates anchoring side visually) | |
| push('h', ['control','shift'], () => resizeAxis(DIR.EAST, g, false)); // shrink from right edge | |
| push('l', ['control','shift'], () => resizeAxis(DIR.WEST, g, false)); // shrink from left edge | |
| push('left', ['control','shift'], () => resizeAxis(DIR.EAST, g, false)); | |
| push('right', ['control','shift'], () => resizeAxis(DIR.WEST, g, false)); | |
| push('k', ['control','shift'], () => resizeAxis(DIR.SOUTH, g, false)); // shrink from bottom | |
| push('j', ['control','shift'], () => resizeAxis(DIR.NORTH, g, false)); // shrink from top | |
| push('up', ['control','shift'], () => resizeAxis(DIR.SOUTH, g, false)); | |
| push('down', ['control','shift'], () => resizeAxis(DIR.NORTH, g, false)); | |
| // Exit keys | |
| push('escape', [], () => self.deactivate()); | |
| push('return', [], () => self.deactivate()); | |
| // Auto-cancel on mouse click (like hints) | |
| Event.once("mouseDidLeftClick", () => self.deactivate()); | |
| } | |
| deactivate() { | |
| if (!this.active) return; | |
| this.active = false; | |
| this.window = null; | |
| this.bindings.forEach(Key.off); this.bindings = []; | |
| try { if (this.overlay) this.overlay.close(); } catch (e) { } | |
| this.overlay = null; | |
| } | |
| } | |
| const MoveMode = new MoveModeController(); | |
| Modal.build({ | |
| text: "Ready", | |
| appearance: HINT.APPEARANCE, | |
| weight: 24, | |
| icon: App.get('Phoenix').icon(), | |
| duration: 0.5, | |
| }).flash(Screen.all()[0]); | |
| // ******************************************************************************** | |
| // Cache Invalidation Hooks | |
| // ******************************************************************************** | |
| // Debounced invalidation so rapid bursts of events (e.g. live resize) only rebuild once. | |
| ; (function () { | |
| if (typeof WindowRegistry === 'undefined') return; | |
| let invalidateTimer = null; | |
| const DEBOUNCE_MS = PERF.EVENT_DEBOUNCE_MS; // migrated from WINDOW_EVENT_DEBOUNCE_MS | |
| function scheduleInvalidate(kind, winFetcher) { | |
| // For open/close we still fallback to full clear; for move/resize attempt incremental | |
| if (kind === 'move' || kind === 'resize') { | |
| try { const w = winFetcher && winFetcher(); if (w) WindowRegistry.updateWindow(w, kind); } catch (e) { } | |
| return; // no debounce needed for simple geometry update | |
| } | |
| if (invalidateTimer) return; // coalesce full rebuild triggers | |
| invalidateTimer = Timer.after(DEBOUNCE_MS / 1000, () => { WindowRegistry.clear(); invalidateTimer = null; }); | |
| } | |
| Event.on('windowDidMove', () => scheduleInvalidate('move', () => Window.focused())); | |
| Event.on('windowDidResize', () => scheduleInvalidate('resize', () => Window.focused())); | |
| Event.on('windowDidOpen', () => scheduleInvalidate('open')); | |
| Event.on('windowDidClose', () => scheduleInvalidate('close')); | |
| Event.on('windowDidMinimize', () => scheduleInvalidate('close')); | |
| Event.on('windowDidUnminimize', () => scheduleInvalidate('open')); | |
| Event.on('windowDidChangeScreen', () => scheduleInvalidate('open')); | |
| Event.on('windowDidChangeSpace', () => scheduleInvalidate('open')); | |
| Event.on('windowDidBecomeMain', () => scheduleInvalidate('move', () => Window.focused())); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment