diff --git a/exceptions.txt b/exceptions.txt new file mode 100644 index 0000000..b8c52a0 --- /dev/null +++ b/exceptions.txt @@ -0,0 +1,11 @@ +# --- Ausnahmeliste für den Tiler --- +# Jede Zeile enthält die WM_CLASS einer Anwendung, die ignoriert werden soll. +# Die Groß- und Kleinschreibung wird ignoriert. + +# Befehl zum Finden der WM_CLASS: +# 1. Terminal öffnen +# 2. 'xprop WM_CLASS' eingeben und Enter drücken +# 3. Mit dem Kreuz auf das gewünschte Fenster klicken + +gnome-screenshot + diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..e2ef6a2 --- /dev/null +++ b/extension.js @@ -0,0 +1,446 @@ +// Simple-Tiling – GNOME Shell 3.38 (X11) - Version 1.0 +// Features: Fibonacci-Stack-Layout (50/50) · Tiling-Lock · Drag- & Keyboard-Swap +// Pop-ups: zentriert + Always-on-Top + Exception-List +// © 2025 domoel – MIT + +// Global Imports +const Main = imports.ui.main; +const Meta = imports.gi.Meta; +const Shell = imports.gi.Shell; +const Mainloop = imports.mainloop; +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const ExtensionUtils = imports.misc.extensionUtils; +const ByteArray = imports.byteArray; + +const Me = ExtensionUtils.getCurrentExtension(); +const SCHEMA_NAME = 'org.gnome.shell.extensions.simple-tiling.domoel'; +const WM_SCHEMA = 'org.gnome.desktop.wm.keybindings'; + +// InteractionHandler +// Verwaltung aller Nutzerinteraktionen (Maus & Tastatur) +class InteractionHandler { + constructor(tiler) { + this.tiler = tiler; + this._settings = ExtensionUtils.getSettings(SCHEMA_NAME); + this._wmSettings = new Gio.Settings({ schema: WM_SCHEMA }); + this._wmKeysToDisable = []; + this._savedWmShortcuts = {}; + this._grabOpIds = []; + this._settingsChangedId = null; + + this._onSettingsChanged = this._onSettingsChanged.bind(this); + this._prepareWmShortcuts(); + } + + enable() { + if (this._wmKeysToDisable.length) { + this._wmKeysToDisable.forEach(key => this._wmSettings.set_value(key, new GLib.Variant('as', []))); + } + this._bindAllShortcuts(); + this._settingsChangedId = this._settings.connect('changed', this._onSettingsChanged); + this._grabOpIds.push(global.display.connect('grab-op-begin', (display, screen, window) => { + if (this.tiler.windows.includes(window)) { this.tiler.grabbedWindow = window; } + })); + this._grabOpIds.push(global.display.connect('grab-op-end', this._onGrabEnd.bind(this))); + } + + disable() { + if (this._wmKeysToDisable.length) { + this._wmKeysToDisable.forEach(key => this._wmSettings.set_value(key, this._savedWmShortcuts[key])); + } + this._unbindAllShortcuts(); + if (this._settingsChangedId) { + this._settings.disconnect(this._settingsChangedId); + this._settingsChangedId = null; + } + this._grabOpIds.forEach(id => global.display.disconnect(id)); + } + + _bind(key, callback) { + Main.wm.addKeybinding(key, this._settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL, callback.bind(this)); + } + + _bindAllShortcuts() { + this._bind('swap-master-window', this._swapWithMaster); + this._bind('swap-left-window', () => this._swapInDirection('left')); + this._bind('swap-right-window', () => this._swapInDirection('right')); + this._bind('swap-up-window', () => this._swapInDirection('up')); + this._bind('swap-down-window', () => this._swapInDirection('down')); + } + + _unbindAllShortcuts() { + ['swap-master-window', 'swap-left-window', 'swap-right-window', 'swap-up-window', 'swap-down-window'] + .forEach(key => Main.wm.removeKeybinding(key)); + } + + _onSettingsChanged() { + this._unbindAllShortcuts(); + this._bindAllShortcuts(); + } + + _prepareWmShortcuts() { + const schema = this._wmSettings.settings_schema; + const keys = []; + if (schema.has_key('toggle-tiled-left')) keys.push('toggle-tiled-left', 'toggle-tiled-right'); + else if (schema.has_key('tile-left')) keys.push('tile-left', 'tile-right'); + if (schema.has_key('toggle-maximized')) keys.push('toggle-maximized'); + else { + if (schema.has_key('maximize')) keys.push('maximize'); + if (schema.has_key('unmaximize')) keys.push('unmaximize'); + } + if (keys.length) { + this._wmKeysToDisable = keys; + keys.forEach(key => this._savedWmShortcuts[key] = this.tiler.wmSettings.get_value(key)); + } + } + + _swapWithMaster() { + const windows = this.tiler.windows; + if (windows.length < 2) return; + const focusedWindow = global.display.get_focus_window(); + if (!focusedWindow || !windows.includes(focusedWindow)) return; + const focusedIndex = windows.indexOf(focusedWindow); + if (focusedIndex > 0) { + [windows[0], windows[focusedIndex]] = [windows[focusedIndex], windows[0]]; + } else if (focusedIndex === 0) { + [windows[0], windows[1]] = [windows[1], windows[0]]; + } + this.tiler.tileNow(); + if (windows.length > 0) windows[0].activate(global.get_current_time()); + } + + _swapInDirection(direction) { + const sourceWindow = global.display.get_focus_window(); + if (!sourceWindow || !this.tiler.windows.includes(sourceWindow)) return; + let targetWindow = null; + const sourceIndex = this.tiler.windows.indexOf(sourceWindow); + if (sourceIndex === 0 && direction === 'right' && this.tiler.windows.length > 1) { + targetWindow = this.tiler.windows[1]; + } else { + targetWindow = this._findTargetInDirection(sourceWindow, direction); + } + if (!targetWindow) return; + const targetIndex = this.tiler.windows.indexOf(targetWindow); + [this.tiler.windows[sourceIndex], this.tiler.windows[targetIndex]] = [this.tiler.windows[targetIndex], this.tiler.windows[sourceIndex]]; + this.tiler.tileNow(); + sourceWindow.activate(global.get_current_time()); + } + + _findTargetInDirection(source, direction) { + const sourceRect = source.get_frame_rect(); + let candidates = []; + for (const win of this.tiler.windows) { + if (win === source) continue; + const targetRect = win.get_frame_rect(); + switch (direction) { + case 'left': if (targetRect.x < sourceRect.x) candidates.push(win); break; + case 'right': if (targetRect.x > sourceRect.x) candidates.push(win); break; + case 'up': if (targetRect.y < sourceRect.y) candidates.push(win); break; + case 'down': if (targetRect.y > sourceRect.y) candidates.push(win); break; + } + } + if (candidates.length === 0) return null; + let bestTarget = null; + let minDeviation = Infinity; + for (const win of candidates) { + const targetRect = win.get_frame_rect(); + let deviation; + if (direction === 'left' || direction === 'right') { + deviation = Math.abs(sourceRect.y - targetRect.y); + } else { + deviation = Math.abs(sourceRect.x - targetRect.x); + } + if (deviation < minDeviation) { + minDeviation = deviation; + bestTarget = win; + } + } + return bestTarget; + } + + _onGrabEnd() { + const grabbedWindow = this.tiler.grabbedWindow; + if (!grabbedWindow) return; + const targetWindow = this._findTargetUnderPointer(grabbedWindow); + if (targetWindow) { + const sourceIndex = this.tiler.windows.indexOf(grabbedWindow); + const targetIndex = this.tiler.windows.indexOf(targetWindow); + [this.tiler.windows[sourceIndex], this.tiler.windows[targetIndex]] = [this.tiler.windows[targetIndex], this.tiler.windows[sourceIndex]]; + } + this.tiler.queueTile(); + this.tiler.grabbedWindow = null; + } + + _findTargetUnderPointer(excludeWindow) { + let [pointerX, pointerY] = global.get_pointer(); + let windows = global.get_window_actors().map(actor => actor.meta_window).filter(win => { + if (!win || win === excludeWindow || !this.tiler.windows.includes(win)) return false; + let frame = win.get_frame_rect(); + return pointerX >= frame.x && pointerX < frame.x + frame.width && + pointerY >= frame.y && pointerY < frame.y + frame.height; + }); + if (windows.length > 0) { return windows[windows.length - 1]; } + + let bestTarget = null; + let maxOverlap = 0; + const sourceFrame = excludeWindow.get_frame_rect(); + for (const win of this.tiler.windows) { + if (win === excludeWindow) continue; + const targetFrame = win.get_frame_rect(); + const overlapX = Math.max(0, Math.min(sourceFrame.x + sourceFrame.width, targetFrame.x + targetFrame.width) - Math.max(sourceFrame.x, targetFrame.x)); + const overlapY = Math.max(0, Math.min(sourceFrame.y + sourceFrame.height, targetFrame.y + targetFrame.height) - Math.max(sourceFrame.y, targetFrame.y)); + const overlapArea = overlapX * overlapY; + if (overlapArea > maxOverlap) { + maxOverlap = overlapArea; + bestTarget = win; + } + } + return bestTarget; + } +} + + +// Tiler +// Die Hauptklasse für die Tiling-Logik. +class Tiler { + constructor() { + this.windows = []; + this.grabbedWindow = null; + this.wmSettings = new Gio.Settings({ schema: WM_SCHEMA }); + this._signalIds = new Map(); + this._tileInProgress = false; + + // Layout-Konfiguration + this._innerGap = 10; + this._outerGapVertical = 5; + this._outerGapHorizontal = 10; + + // Delay-Zeiten für das Tiling und Exception Windows + this._tilingDelay = 20; + this._centeringDelay = 5; + + this._exceptions = []; + this._interactionHandler = new InteractionHandler(this); + + this._onWindowAdded = this._onWindowAdded.bind(this); + this._onWindowRemoved = this._onWindowRemoved.bind(this); + this._onActiveWorkspaceChanged = this._onActiveWorkspaceChanged.bind(this); + this._onWindowMinimizedStateChanged = this._onWindowMinimizedStateChanged.bind(this); + } + + enable() { + this._loadExceptions(); + const workspaceManager = global.workspace_manager; + this._signalIds.set('workspace-changed', { object: workspaceManager, id: workspaceManager.connect('active-workspace-changed', this._onActiveWorkspaceChanged) }); + this._connectToWorkspace(); + this._interactionHandler.enable(); + } + + disable() { + this._interactionHandler.disable(); + this._disconnectFromWorkspace(); + for (const [, signal] of this._signalIds) { + try { signal.object.disconnect(signal.id); } catch(e) {} + } + this._signalIds.clear(); + this.windows = []; + } + + _loadExceptions() { + const file = Gio.file_new_for_path(Me.path + '/exceptions.txt'); + if (!file.query_exists(null)) { this._exceptions = []; return; } + const [ok, data] = file.load_contents(null); + this._exceptions = ok ? ByteArray.toString(data).split('\n') + .map(l => l.trim()).filter(l => l && !l.startsWith('#')) + .map(l => l.toLowerCase()) : []; + } + + _isException(win) { + return !!win && this._exceptions.includes((win.get_wm_class() || '').toLowerCase()); + } + + _isTileable(win) { + return win && !win.minimized && !this._isException(win) && win.get_window_type() === Meta.WindowType.NORMAL; + } + + _centerWindow(win) { + Mainloop.timeout_add(this._centeringDelay, () => { + if (!win || !win.get_display()) return GLib.SOURCE_REMOVE; + if (win.get_maximized()) { win.unmaximize(Meta.MaximizeFlags.BOTH); } + const monitorIndex = win.get_monitor(); + const workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + const frame = win.get_frame_rect(); + win.move_frame(true, + workArea.x + Math.floor((workArea.width - frame.width) / 2), + workArea.y + Math.floor((workArea.height - frame.height) / 2)); + Mainloop.idle_add(() => { + if (win.get_display()) { + if (typeof win.set_keep_above === 'function') win.set_keep_above(true); + else if (typeof win.make_above === 'function') win.make_above(); + } + return GLib.SOURCE_REMOVE; + }); + return GLib.SOURCE_REMOVE; + }); + } + + _onWindowMinimizedStateChanged() { + this.queueTile(); + } + + _onWindowAdded(workspace, win) { + if (this.windows.includes(win)) return; + + if (this._isException(win)) { + this._centerWindow(win); + return; + } + + if (this._isTileable(win)) { + this.windows.push(win); + const id = win.get_id(); + this._signalIds.set(`unmanaged-${id}`, { object: win, id: win.connect('unmanaged', () => this._onWindowRemoved(null, win)) }); + this._signalIds.set(`size-changed-${id}`, { object: win, id: win.connect('size-changed', () => { if (!this.grabbedWindow) this.queueTile(); }) }); + this._signalIds.set(`minimized-${id}`, { object: win, id: win.connect('notify::minimized', this._onWindowMinimizedStateChanged) }); + this.queueTile(); + } + } + + _onWindowRemoved(workspace, win) { + const index = this.windows.indexOf(win); + if (index > -1) { + this.windows.splice(index, 1); + } + + ['unmanaged', 'size-changed', 'minimized'].forEach(prefix => { + const key = `${prefix}-${win.get_id()}`; + if (this._signalIds.has(key)) { + const { object, id } = this._signalIds.get(key); + try { object.disconnect(id); } catch(e) {} + this._signalIds.delete(key); + } + }); + this.queueTile(); + } + + _onActiveWorkspaceChanged() { + this._disconnectFromWorkspace(); + this._connectToWorkspace(); + } + + _connectToWorkspace() { + const workspace = global.workspace_manager.get_active_workspace(); + workspace.list_windows().forEach(win => this._onWindowAdded(workspace, win)); + this._signalIds.set('window-added', { object: workspace, id: workspace.connect('window-added', this._onWindowAdded) }); + this._signalIds.set('window-removed', { object: workspace, id: workspace.connect('window-removed', this._onWindowRemoved) }); + this.queueTile(); + } + + _disconnectFromWorkspace() { + this.windows.slice().forEach(win => this._onWindowRemoved(null, win)); + + ['window-added', 'window-removed'].forEach(key => { + if (this._signalIds.has(key)) { + const { object, id } = this._signalIds.get(key); + try { object.disconnect(id); } catch(e) {} + this._signalIds.delete(key); + } + }); + } + + queueTile() { + if (this._tileInProgress) return; + this._tileInProgress = true; + Mainloop.timeout_add(this._tilingDelay, () => { + this._tileWindows(); + this._tileInProgress = false; + return GLib.SOURCE_REMOVE; + }); + } + + tileNow() { + if (!this._tileInProgress) { + this._tileWindows(); + } + } + + _splitLayout(windows, area) { + if (windows.length === 0) return; + if (windows.length === 1) { + windows[0].move_resize_frame(true, area.x, area.y, area.width, area.height); + return; + } + + const gap = Math.floor(this._innerGap / 2); + const primaryWindows = [windows[0]]; + const secondaryWindows = windows.slice(1); + let primaryArea, secondaryArea; + + if (area.width > area.height) { + const primaryWidth = Math.floor(area.width / 2) - gap; + primaryArea = { x: area.x, y: area.y, width: primaryWidth, height: area.height }; + secondaryArea = { x: area.x + primaryWidth + this._innerGap, y: area.y, width: area.width - primaryWidth - this._innerGap, height: area.height }; + } else { + const primaryHeight = Math.floor(area.height / 2) - gap; + primaryArea = { x: area.x, y: area.y, width: area.width, height: primaryHeight }; + secondaryArea = { x: area.x, y: area.y + primaryHeight + this._innerGap, width: area.width, height: area.height - primaryHeight - this._innerGap }; + } + + this._splitLayout(primaryWindows, primaryArea); + this._splitLayout(secondaryWindows, secondaryArea); + } + + _tileWindows() { + const windowsToTile = this.windows.filter(win => !win.minimized); + if (windowsToTile.length === 0) return; + const monitor = Main.layoutManager.primaryMonitor; + const workArea = Main.layoutManager.getWorkAreaForMonitor(monitor.index); + const innerArea = { + x: workArea.x + this._outerGapHorizontal, + y: workArea.y + this._outerGapVertical, + width: workArea.width - 2 * this._outerGapHorizontal, + height: workArea.height - 2 * this._outerGapVertical + }; + + windowsToTile.forEach(win => { if (win.get_maximized()) win.unmaximize(Meta.MaximizeFlags.BOTH); }); + + if (windowsToTile.length === 1) { + windowsToTile[0].move_resize_frame(true, innerArea.x, innerArea.y, innerArea.width, innerArea.height); + return; + } + + const gap = Math.floor(this._innerGap / 2); + const masterWidth = Math.floor(innerArea.width / 2) - gap; + const master = windowsToTile[0]; + master.move_resize_frame(true, innerArea.x, innerArea.y, masterWidth, innerArea.height); + const stackArea = { + x: innerArea.x + masterWidth + this._innerGap, + y: innerArea.y, + width: innerArea.width - masterWidth - this._innerGap, + height: innerArea.height + }; + this._splitLayout(windowsToTile.slice(1), stackArea); + } +} + +// Extension Wrapper +class SimpleTilingExtension { + constructor() { + this.tiler = null; + } + enable() { + this.tiler = new Tiler(); + this.tiler.enable(); + } + disable() { + if (this.tiler) { + this.tiler.disable(); + this.tiler = null; + } + } +} + +function init() { + return new SimpleTilingExtension(); +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..ca3bcae --- /dev/null +++ b/metadata.json @@ -0,0 +1,11 @@ +{ + "uuid": "simple-tiling@domoel", + "name": "Simple Tiling", + "description": "A Simple Tiling Extension for Gnome Shell 3.38.", + "version": 1, + "shell-version": [ "3.38" ], + "settings-schema": "org.gnome.shell.extensions.simple-tiling.domoel", + "preferences_ui": "prefs.js", + "url": "https://github.com/Domoel/Simple-Tiling", + "gettext-domain": "simple-tiling-domoel" +} diff --git a/prefs.js b/prefs.js new file mode 100644 index 0000000..eff8bc7 --- /dev/null +++ b/prefs.js @@ -0,0 +1,121 @@ +// prefs.js - Finale Version nach dem Vorbild von Focus-Switcher + +'use strict'; + +const { Gtk, GObject } = imports.gi; +const ExtensionUtils = imports.misc.extensionUtils; + +// Definiere die Spalten für unser Datenmodell +const COLUMN_ID = 0; // z.B. 'swap-master-window' +const COLUMN_DESC = 1; // z.B. 'Master-Fenster tauschen' +const COLUMN_KEY = 2; // Der Key-Code (eine Zahl) +const COLUMN_MODS = 3; // Die Modifier-Maske (eine Zahl) + +function init() {} + +function buildPrefsWidget() { + const settings = ExtensionUtils.getSettings('org.gnome.shell.extensions.simple-tiling.domoel'); + + const prefsWidget = new Gtk.Box({ + orientation: Gtk.Orientation.VERTICAL, + margin: 20, + spacing: 12, + visible: true + }); + + const title = new Gtk.Label({ + label: 'Tastenkürzel für Simple-Tiling', + use_markup: true, + halign: Gtk.Align.START, + visible: true + }); + prefsWidget.add(title); + + // 1. Das Datenmodell (ListStore) erstellen + let store = new Gtk.ListStore(); + store.set_column_types([ + GObject.TYPE_STRING, // COLUMN_ID + GObject.TYPE_STRING, // COLUMN_DESC + GObject.TYPE_INT, // COLUMN_KEY + GObject.TYPE_INT, // COLUMN_MODS + ]); + + // Fülle das Datenmodell mit unseren Einstellungen + addKeybinding(store, settings, 'swap-master-window', 'Master-Fenster tauschen'); + addKeybinding(store, settings, 'swap-left-window', 'Fenster nach links tauschen'); + addKeybinding(store, settings, 'swap-right-window', 'Fenster nach rechts tauschen'); + addKeybinding(store, settings, 'swap-up-window', 'Fenster nach oben tauschen'); + addKeybinding(store, settings, 'swap-down-window', 'Fenster nach unten tauschen'); + + // 2. Die Ansicht (TreeView) erstellen, die das Modell anzeigt + let treeView = new Gtk.TreeView({ + model: store, + headers_visible: false, + hexpand: true, + visible: true + }); + + // Erstelle die Spalte für die Beschreibung + let descRenderer = new Gtk.CellRendererText(); + let descColumn = new Gtk.TreeViewColumn({ expand: true }); + descColumn.pack_start(descRenderer, true); + descColumn.add_attribute(descRenderer, 'text', COLUMN_DESC); + treeView.append_column(descColumn); + + // 3. Erstelle die Spalte für das Tastenkürzel mit dem Spezialisten Gtk.CellRendererAccel + let accelRenderer = new Gtk.CellRendererAccel({ + 'accel-mode': Gtk.CellRendererAccelMode.GTK, + 'editable': true + }); + let accelColumn = new Gtk.TreeViewColumn(); + accelColumn.pack_end(accelRenderer, false); + accelColumn.add_attribute(accelRenderer, 'accel-key', COLUMN_KEY); + accelColumn.add_attribute(accelRenderer, 'accel-mods', COLUMN_MODS); + treeView.append_column(accelColumn); + + prefsWidget.add(treeView); + + // Verbinde die Events, die ausgelöst werden, wenn der Nutzer ein Kürzel ändert + accelRenderer.connect('accel-edited', (renderer, path_string, key, mods, hw_code) => { + let [ok, iter] = store.get_iter_from_string(path_string); + if (!ok) return; + + // Aktualisiere das Datenmodell... + store.set(iter, [COLUMN_KEY, COLUMN_MODS], [key, mods]); + + // ...und speichere die Änderung in den GSettings + let id = store.get_value(iter, COLUMN_ID); + let accelString = Gtk.accelerator_name(key, mods); + settings.set_strv(id, [accelString]); + }); + + // Event für das Löschen eines Kürzels (z.B. mit Backspace) + accelRenderer.connect('accel-cleared', (renderer, path_string) => { + let [ok, iter] = store.get_iter_from_string(path_string); + if (!ok) return; + + store.set(iter, [COLUMN_KEY, COLUMN_MODS], [0, 0]); + let id = store.get_value(iter, COLUMN_ID); + settings.set_strv(id, []); + }); + + return prefsWidget; +} + +// Hilfsfunktion zum Befüllen des Datenmodells +function addKeybinding(model, settings, id, description) { + let [key, mods] = [0, 0]; + + // KORREKTUR: Leerzeichen zwischen const und strv eingefügt + const strv = settings.get_strv(id); + if (strv && strv.length > 0 && strv[0]) { + [key, mods] = Gtk.accelerator_parse(strv[0]); + } + + // Füge eine neue Zeile zum Datenmodell hinzu + let iter = model.append(); + model.set(iter, + [COLUMN_ID, COLUMN_DESC, COLUMN_KEY, COLUMN_MODS], + [id, description, key, mods] + ); +}