From 45a6ecf654c42144be7f4708e2444e3fab4148b9 Mon Sep 17 00:00:00 2001 From: team3 Date: Fri, 18 Sep 2026 22:02:53 +0200 Subject: [PATCH] init --- Makefile | 37 ++ README.md | 72 ++++ desktop@marha.de/calendar.js | 609 ++++++++++++++++++++++++++++++++ desktop@marha.de/dates.js | 128 +++++++ desktop@marha.de/extension.js | 68 ++++ desktop@marha.de/markdown.js | 79 +++++ desktop@marha.de/metadata.json | 6 + desktop@marha.de/notes.js | 277 +++++++++++++++ desktop@marha.de/store.js | 213 +++++++++++ desktop@marha.de/stylesheet.css | 195 ++++++++++ desktop@marha.de/todos.js | 129 +++++++ desktop@marha.de/widgets.js | 330 +++++++++++++++++ dev/click.sh | 14 + dev/enable.py | 15 + dev/eval.sh | 11 + dev/fake-ding.py | 18 + dev/input.sh | 35 ++ dev/nested.sh | 25 ++ dev/press.sh | 20 ++ dev/restart.sh | 16 + dev/seed.js | 43 +++ dev/shot.sh | 7 + dev/stop.sh | 5 + dev/tests/input.sh | 262 ++++++++++++++ dev/tests/run.js | 66 ++++ dev/unsafe@dev/extension.js | 11 + dev/unsafe@dev/metadata.json | 6 + 27 files changed, 2697 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 desktop@marha.de/calendar.js create mode 100644 desktop@marha.de/dates.js create mode 100644 desktop@marha.de/extension.js create mode 100644 desktop@marha.de/markdown.js create mode 100644 desktop@marha.de/metadata.json create mode 100644 desktop@marha.de/notes.js create mode 100644 desktop@marha.de/store.js create mode 100644 desktop@marha.de/stylesheet.css create mode 100644 desktop@marha.de/todos.js create mode 100644 desktop@marha.de/widgets.js create mode 100755 dev/click.sh create mode 100755 dev/enable.py create mode 100755 dev/eval.sh create mode 100644 dev/fake-ding.py create mode 100755 dev/input.sh create mode 100755 dev/nested.sh create mode 100755 dev/press.sh create mode 100755 dev/restart.sh create mode 100644 dev/seed.js create mode 100755 dev/shot.sh create mode 100755 dev/stop.sh create mode 100755 dev/tests/input.sh create mode 100644 dev/tests/run.js create mode 100644 dev/unsafe@dev/extension.js create mode 100644 dev/unsafe@dev/metadata.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8793691 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +# make install – Extension installieren, falls noch nicht vorhanden, und aktivieren +# make update – installierte Extension mit dem Stand aus diesem Ordner überschreiben +UUID = desktop@marha.de +SRC = $(CURDIR)/$(UUID) +DEST = $(HOME)/.local/share/gnome-shell/extensions/$(UUID) + +.ONESHELL: +.PHONY: install update enable + +install: + @if [ -e "$(DEST)" ]; then + echo "Schon installiert: $(DEST)" + echo "Zum Aktualisieren: make update" + exit 0 + fi + mkdir -p "$(dir $(DEST))" + cp -r "$(SRC)" "$(DEST)" + $(MAKE) --no-print-directory enable + echo "Installiert. Jetzt ab- und wieder anmelden." + +update: + @if [ ! -e "$(DEST)" ]; then + echo "Nicht installiert. Zuerst: make install" + exit 1 + fi + if [ -L "$(DEST)" ]; then + echo "$(DEST) ist ein Symlink auf $(SRC), nichts zu kopieren." + else + rm -rf "$(DEST)" + cp -r "$(SRC)" "$(DEST)" + echo "Aktualisiert." + fi + echo "Änderungen wirken nach dem nächsten Ab- und Anmelden." + +# UUID in die aktiven Extensions eintragen, falls sie fehlt +enable: + @python3 dev/enable.py "$(UUID)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b9595d --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Desktop + +GNOME-Shell-Extension (GNOME 46, Ubuntu 24.04): Kalender, Todos und Notizen direkt auf dem Desktop, +unter allen Fenstern, über dem Wallpaper. + +## Bedienung + +- **Termin anlegen:** in der Wochenansicht auf eine Stunde drücken und bis zur Endstunde ziehen, + auch über mehrere Tage. Klick ohne Ziehen = eine Stunde. Ziehen in der Zeile „ganz“ = ganztägig. + Oder Rechtsklick auf einen Tag (Monat), eine Stunde (Woche) oder die Tagesfläche: „Termin erstellen“. + Popover: Titel, Von und Bis je mit Datum und Uhrzeit. Ohne Ende gilt eine Stunde, ohne Uhrzeit ganztägig. +- **Todo anlegen:** Rechtsklick ins Todo-Panel oder in den Kalender, „Todo erstellen“. Text plus Datum und Uhrzeit. +- **Notiz:** Rechtsklick irgendwo im Notiz-Panel, „Notiz erstellen“ fragt den Namen und öffnet den Editor. + Klick in die Notizfläche bearbeitet. Mit Fokus steht der rohe Markdown-Text, ohne Fokus die formatierte Ansicht. + Gespeichert wird alle paar Sekunden und beim Klick woanders hin. +- **Bearbeiten:** Linksklick auf Termin, Todo oder Notiz. +- **Rechtsklick auf freie Fläche:** Erstellen (Todos, Tagesansicht, Notizbaum im Wurzelordner). +- **Rechtsklick auf Ordner:** Notiz oder Unterordner darin erstellen, Umbenennen, Löschen. +- **Rechtsklick auf Eintrag:** Umbenennen, Löschen. +- Popover: Enter speichert, Esc oder Klick daneben bricht ab. Keine Buttons. + +## Aufbau + +- `desktop@marha.de/` – die Extension + - `extension.js` – Layout auf dem Arbeitsbereich, Stapelung über Desktop-Icons + - `calendar.js` – Monat, Woche (Drag), Tag, Termin-Popover + - `todos.js` – Todo-Liste, Todo-Popover + - `notes.js` – Ordnerbaum, Markdown-Anzeige, Inline-Editor, Kontextmenü + - `widgets.js` – Popover (Grab, Esc, Tab, Klick daneben), Kontextmenü, Datums- und Zeitauswahl + - `markdown.js`, `dates.js` – reine Logik, ohne Shell testbar + - `store.js` – Ablage +- `dev/` – Test-Werkzeuge + +## Daten + +`~/.local/share/desktop-app/` + +- `events.json` – Termine +- `todos.json` – Todos +- `notes/` – Notizen als `.md`, Ordner beliebig verschachtelt (Obsidian kann den Ordner öffnen) + +## Installation + +``` +make install # kopiert die Extension nach ~/.local/share/gnome-shell/extensions und aktiviert sie +make update # überschreibt eine installierte Extension mit dem Stand aus diesem Ordner +``` + +Danach ab- und wieder anmelden. Unter Wayland lädt die Shell neuen Code erst nach einem neuen Login. +Voraussetzung: GNOME 46 (Ubuntu 24.04). + +## Entwicklung + +``` +gjs -m dev/tests/run.js # Unit-Tests für dates.js und markdown.js +dev/restart.sh # Test-Shell als Fenster starten (zum Selbst-Ausprobieren) +dev/restart.sh headless # Test-Shell unsichtbar, für simulierte Eingaben +dev/tests/input.sh # Ablauf-Tests mit echter Maus und Tastatur (headless) +dev/eval.sh dev/seed.js # Testdaten einspielen +dev/eval.sh 'js' # JavaScript in der Test-Shell ausführen (oder Dateipfad) +dev/shot.sh [datei.png] # Screenshot der Test-Shell +dev/input.sh click X Y # Maus und Tastatur simulieren (nur headless gefahrlos) +dev/press.sh "Text" # Button mit diesem Text direkt auslösen +dev/stop.sh # Test-Shell beenden +``` + +Die Test-Shell läuft mit `GSETTINGS_BACKEND=keyfile` und eigenem `XDG_DATA_HOME` +unter `/tmp/desktop-app-nested/`. Die echte Session bleibt unberührt. + +Im Fenstermodus (nested, X11-Backend) würden virtuelle Eingabegeräte auf dem echten Desktop +landen. Deshalb nutzt `dev/input.sh` nur den headless Modus (natives Backend). +`dev/fake-ding.py` startet ein Fenster wie Desktop Icons, um die Stapelung zu prüfen. diff --git a/desktop@marha.de/calendar.js b/desktop@marha.de/calendar.js new file mode 100644 index 0000000..8733558 --- /dev/null +++ b/desktop@marha.de/calendar.js @@ -0,0 +1,609 @@ +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; + +import * as D from './dates.js'; +import {editTodo, todoMenu, todoRow, todoTitle} from './todos.js'; +import {button, contextMenu, dropdown, openDatePicker, openTimePicker, Popover, scrollBox, textButton} from './widgets.js'; + +const FIRST_VISIBLE_HOUR = 7; +const MONTH_CELL_ENTRIES = 2; +const ALLDAY_LANE_HEIGHT = 20; + +function pad(n) { + return String(n).padStart(2, '0'); +} + +function endDateOf(event) { + return event.endDate ?? event.date; +} + +// Ohne gesetztes Ende gilt eine Stunde. +function oneHourLater(start) { + return D.minutesToTime(Math.min(D.timeToMinutes(start) + 60, 23 * 60 + 59)); +} + +function endTimeOf(event) { + return event.end ?? oneHourLater(event.start); +} + +// 'ganztags', '09:00–10:30', bei mehrtägigen Terminen mit Enddatum. +function timeRange(event) { + const multiDay = endDateOf(event) !== event.date; + if (!event.start) + return multiDay ? `ganztags bis ${D.formatDayMonth(endDateOf(event))}` : 'ganztags'; + if (multiDay) + return `${event.start} – ${D.formatDayMonth(endDateOf(event))} ${endTimeOf(event)}`; + return `${event.start}–${endTimeOf(event)}`; +} + +// Anteil eines Termins an einem Tag in Minuten: [von, bis). +function minuteSpan(event, day) { + const from = day === event.date ? D.timeToMinutes(event.start) : 0; + const to = day === endDateOf(event) ? D.timeToMinutes(endTimeOf(event)) : 24 * 60; + return [from, Math.max(to, from + 30)]; +} + +// Überlappende Termine bekommen Spalten nebeneinander. +function assignColumns(items) { + items.sort((a, b) => a.from - b.from || b.to - a.to); + const overlaps = (a, b) => a.from < b.to && b.from < a.to; + for (const item of items) { + const used = new Set(items.filter(o => o.col !== undefined && overlaps(o, item)).map(o => o.col)); + item.col = 0; + while (used.has(item.col)) + item.col++; + } + for (const item of items) + item.cols = Math.max(...items.filter(o => overlaps(o, item)).map(o => o.col)) + 1; +} + +// Ganztägige Termine bekommen Zeilen untereinander, wenn sich ihre Tage überschneiden. +function assignLanes(items) { + items.sort((a, b) => a.from - b.from || b.to - a.to); + const laneEnds = []; + for (const item of items) { + let lane = laneEnds.findIndex(end => end < item.from); + if (lane === -1) + lane = laneEnds.length; + laneEnds[lane] = item.to; + item.lane = lane; + } + return laneEnds.length; +} + +// Menü an der Mausposition: Termin oder Todo für Tag und optional Uhrzeit anlegen. +function createMenu(store, day, time = null) { + const [x, y] = global.get_pointer(); + contextMenu([ + {label: 'Termin erstellen', onSelect: () => editEvent(store, {date: day, start: time}, {x, y})}, + {label: 'Todo erstellen', onSelect: () => editTodo(store, {date: day, time}, {x, y})}, + ]); +} + +// Popover mit Titel sowie Von und Bis (Datum und Uhrzeit). Enter speichert. +export function editEvent(store, event, anchor) { + const popover = new Popover(anchor); + popover.addLabel(event.id ? 'Termin bearbeiten' : 'Neuer Termin', 'popover-title'); + const title = popover.addEntry(event.title ?? '', 'Titel'); + + let date = event.date; + let start = event.start ?? null; + let endDate = event.endDate ?? null; + let end = event.end ?? null; + const focus = title.clutter_text; + const startDate = dropdown('', b => openDatePicker(b, date, picked => { + if (picked) + date = picked; + update(); + }), focus); + const startTime = dropdown('', b => openTimePicker(b, start, picked => { + start = picked; + update(); + }), focus); + const endDateButton = dropdown('', b => openDatePicker(b, endDate ?? date, picked => { + endDate = picked; + update(); + }), focus); + const endTime = dropdown('', b => openTimePicker(b, end, picked => { + end = picked; + update(); + }), focus); + const update = () => { + startDate.setLabel(D.formatDate(date)); + startTime.setLabel(start ?? 'Keine Uhrzeit'); + endDateButton.setLabel(D.formatDate(endDate ?? date)); + endTime.setLabel(end ?? 'Keine Uhrzeit'); + }; + update(); + popover.addRow('Von', startDate, startTime); + popover.addRow('Bis', endDateButton, endTime); + + popover.setSave(() => { + const text = title.get_text().trim(); + if (!text) + return 'Titel fehlt'; + const saved = {...event, title: text, date, start: null, end: null, endDate: null}; + if (endDate && endDate !== date) { + if (endDate < date) + return 'Ende liegt vor dem Anfang'; + saved.endDate = endDate; + } + if (start) { + saved.start = start; + saved.end = end ?? oneHourLater(start); + if (!saved.endDate && D.timeToMinutes(saved.end) <= D.timeToMinutes(start)) + return 'Ende liegt vor dem Anfang'; + } + store.saveEvent(saved); + }); + popover.open(title.clutter_text); + return popover; +} + +export function eventMenu(store, event) { + contextMenu([{label: 'Löschen', onSelect: () => store.removeEvent(event.id)}]); +} + +export const CalendarPanel = GObject.registerClass( +class CalendarPanel extends St.BoxLayout { + _init(store) { + super._init({vertical: true, style_class: 'desktop-panel calendar', reactive: true, x_expand: true, y_expand: false}); + this._store = store; + this._view = 'month'; + this._cursor = D.today(); + this._drag = null; + this._slots = []; + + this._buildHeader(); + this._body = new St.Bin({x_expand: true, y_expand: true, x_align: Clutter.ActorAlign.FILL, y_align: Clutter.ActorAlign.FILL}); + this.add_child(this._body); + + store.onChange(() => this._render()); + this._render(); + } + + _buildHeader() { + const header = new St.BoxLayout({style_class: 'panel-header'}); + header.add_child(button('‹', () => this._shift(-1), 'desktop-button nav-button')); + this._title = new St.Label({style_class: 'panel-title', y_align: Clutter.ActorAlign.CENTER}); + header.add_child(this._title); + header.add_child(button('›', () => this._shift(1), 'desktop-button nav-button')); + header.add_child(new Clutter.Actor({x_expand: true})); + + this._viewButtons = {}; + for (const [view, text] of [['month', 'Monat'], ['week', 'Woche'], ['day', 'Tag']]) { + const b = button(text, () => this._go(this._cursor, view), 'desktop-button view-button'); + this._viewButtons[view] = b; + header.add_child(b); + } + this.add_child(header); + } + + _go(date, view = this._view) { + this._cursor = date; + this._view = view; + this._render(); + } + + _shift(n) { + if (this._view === 'month') { + const date = D.fromDateString(this._cursor); + date.setDate(1); + date.setMonth(date.getMonth() + n); + this._go(D.toDateString(date)); + } else if (this._view === 'week') { + this._go(D.addDays(this._cursor, 7 * n)); + } else { + this._go(D.addDays(this._cursor, n)); + } + } + + _render() { + for (const [view, b] of Object.entries(this._viewButtons)) + b.checked = view === this._view; + + this._slots = []; + this._alldaySlots = []; + this._alldayBlocks = []; + const date = D.fromDateString(this._cursor); + let content; + if (this._view === 'month') { + this._title.text = D.formatMonth(date.getFullYear(), date.getMonth()); + content = this._monthView(date.getFullYear(), date.getMonth()); + } else if (this._view === 'week') { + const days = D.weekDays(this._cursor); + this._title.text = `${D.formatDayMonth(days[0])} – ${D.formatDateInput(days[6])}`; + content = this._weekView(days); + } else { + this._title.text = D.formatDate(this._cursor); + content = this._dayView(this._cursor); + } + this._body.get_child()?.destroy(); + this._body.set_child(content); + } + + // Kurze Zeile für Termin oder Todo in Monats- und Wochenzellen. + _eventEntry(event, styleClass) { + const singleDay = endDateOf(event) === event.date; + const text = event.start && singleDay ? `${event.start} ${event.title}` : event.title; + return textButton(text, (b, mouseButton) => { + if (mouseButton === 3) + eventMenu(this._store, event); + else + editEvent(this._store, event, b); + }, styleClass); + } + + _todoEntry(todo, styleClass) { + const text = `${todo.done ? '☑' : '☐'} ${todo.time ? `${todo.time} ` : ''}${todoTitle(todo)}`; + return textButton(text, (b, mouseButton) => { + if (mouseButton === 3) + todoMenu(this._store, todo); + else + editTodo(this._store, todo, b); + }, `${styleClass} todo`); + } + + // ---- Monat ---- + + _monthView(year, month) { + const box = new St.BoxLayout({vertical: true, x_expand: true, y_expand: true}); + + const weekdays = new St.BoxLayout({style_class: 'weekday-row'}); + weekdays.layout_manager.homogeneous = true; + for (const name of D.WEEKDAYS) + weekdays.add_child(new St.Label({text: name, style_class: 'weekday', x_expand: true})); + box.add_child(weekdays); + + const layout = new Clutter.GridLayout({column_homogeneous: true, row_homogeneous: true, column_spacing: 2, row_spacing: 2}); + const grid = new St.Widget({layout_manager: layout, x_expand: true, y_expand: true}); + box.add_child(grid); + + const today = D.today(); + D.monthGrid(year, month).forEach((day, i) => { + const cell = new St.Button({ + style_class: 'month-day', + can_focus: true, + x_expand: true, + y_expand: true, + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + }); + const inner = new St.BoxLayout({vertical: true, x_expand: true}); + cell.set_child(inner); + inner.add_child(new St.Label({text: String(Number(day.slice(8))), style_class: 'month-day-number'})); + + const entries = [ + ...this._store.eventsOn(day).map(e => this._eventEntry(e, 'month-entry')), + ...this._store.todosOn(day).map(t => this._todoEntry(t, 'month-entry')), + ]; + entries.slice(0, MONTH_CELL_ENTRIES).forEach(e => inner.add_child(e)); + if (entries.length > MONTH_CELL_ENTRIES) + inner.add_child(new St.Label({text: `+${entries.length - MONTH_CELL_ENTRIES}`, style_class: 'month-more'})); + + if (D.fromDateString(day).getMonth() !== month) + cell.add_style_class_name('other-month'); + if (day === today) + cell.add_style_class_name('today'); + cell.connect('clicked', (_b, mouseButton) => { + if (mouseButton === 3) + createMenu(this._store, day); + else + this._go(day, 'day'); + }); + layout.attach(cell, i % 7, Math.floor(i / 7), 1, 1); + }); + return box; + } + + // ---- Woche ---- + + _weekView(days) { + const box = new St.BoxLayout({vertical: true, x_expand: true, y_expand: true}); + const today = D.today(); + this._weekDays = days; + this._slots = days.map(() => []); + this._alldaySlots = []; + + box.add_child(this._weekRow(`KW ${D.isoWeek(days[0])}`, days.map(day => { + const label = new St.Label({text: D.formatDate(day).slice(0, 10), style_class: 'week-day-head', x_expand: true}); + if (day === today) + label.add_style_class_name('today'); + return label; + }), 'head')); + + // Ganztägige Termine als Blöcke über ihre Tage, darunter Todos ohne Uhrzeit. + // Drücken und Ziehen in der Zeile wählt die Tage. + const alldayEvents = [...new Set(days.flatMap(day => this._store.eventsOn(day).filter(e => !e.start)))]; + this._alldayBlocks = alldayEvents.map(event => ({ + event, + from: Math.max(0, days.indexOf(event.date) === -1 ? 0 : days.indexOf(event.date)), + to: days.indexOf(endDateOf(event)) === -1 ? 6 : days.indexOf(endDateOf(event)), + block: this._alldayBlock(event), + })); + const lanes = assignLanes(this._alldayBlocks); + const alldayRow = new St.BoxLayout({style_class: 'week-row allday'}); + alldayRow.add_child(new St.Label({text: 'ganz', style_class: 'week-hour'})); + const alldayContainer = new St.Widget({layout_manager: new Clutter.BinLayout(), x_expand: true}); + const alldayDays = new St.BoxLayout({style_class: 'week-days', x_expand: true}); + alldayDays.layout_manager.homogeneous = true; + days.forEach((day, i) => { + const slot = new St.BoxLayout({vertical: true, style_class: 'week-slot allday', reactive: true, track_hover: true, x_expand: true}); + slot.set_style(`padding-top: ${2 + lanes * ALLDAY_LANE_HEIGHT}px`); + for (const todo of this._store.todosOn(day).filter(t => !t.time)) + slot.add_child(this._todoEntry(todo, 'week-entry')); + this._connectDrag(slot, i, null, day); + this._alldaySlots[i] = slot; + alldayDays.add_child(slot); + }); + const alldayOverlay = new St.Widget({x_expand: true, y_expand: true, reactive: false}); + for (const {block} of this._alldayBlocks) + alldayOverlay.add_child(block); + alldayContainer.add_child(alldayDays); + alldayContainer.add_child(alldayOverlay); + alldayRow.add_child(alldayContainer); + alldayDays.connect('notify::allocation', () => this._placeAlldayBlocks(alldayDays)); + box.add_child(alldayRow); + + const [scroll, wrap] = scrollBox('week-scroll'); + // Scrollbalken über dem Inhalt, sonst sind die Stundenzellen schmaler als Kopf- und Ganztags-Zeile + scroll.overlay_scrollbars = true; + // Stundenraster und darüber eine Ebene mit den Terminblöcken + const container = new St.Widget({layout_manager: new Clutter.BinLayout(), x_expand: true}); + const rows = new St.BoxLayout({vertical: true, x_expand: true}); + const overlay = new St.Widget({x_expand: true, y_expand: true, reactive: false}); + container.add_child(rows); + container.add_child(overlay); + wrap.add_child(container); + this._weekRows = rows; + for (let hour = 0; hour < 24; hour++) + rows.add_child(this._weekRow(`${pad(hour)}:00`, days.map((day, i) => this._slot(i, day, hour)))); + this._blocks = []; + days.forEach((day, dayIndex) => { + const items = this._store.eventsOn(day).filter(e => e.start).map(event => { + const [from, to] = minuteSpan(event, day); + return {event, dayIndex, from, to, block: this._block(event, day)}; + }); + assignColumns(items); + for (const item of items) { + overlay.add_child(item.block); + this._blocks.push(item); + } + }); + rows.connect('notify::allocation', () => this._placeBlocks()); + box.add_child(scroll); + + // Nach dem ersten Layout auf den Morgen scrollen. + const adjustment = scroll.vadjustment; + const id = adjustment.connect('changed', () => { + if (adjustment.upper <= adjustment.page_size) + return; + adjustment.value = FIRST_VISIBLE_HOUR * this._rowPitch(); + adjustment.disconnect(id); + }); + return box; + } + + // Ein Terminblock: Zeit und Titel, Klick bearbeitet, Rechtsklick öffnet das Menü. + _block(event, day) { + const inner = new St.BoxLayout({vertical: true, x_expand: true, y_expand: true, y_align: Clutter.ActorAlign.START}); + inner.add_child(new St.Label({text: day === event.date ? event.start : '…', style_class: 'week-block-time'})); + const title = new St.Label({text: event.title, style_class: 'week-block-title', x_expand: true}); + title.clutter_text.ellipsize = Pango.EllipsizeMode.END; + inner.add_child(title); + const block = new St.Button({ + style_class: 'week-block', + child: inner, + can_focus: true, + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + }); + block.connect('clicked', (_b, mouseButton) => { + if (mouseButton === 3) + eventMenu(this._store, event); + else + editEvent(this._store, event, block); + }); + return block; + } + + // Ganztägiger Block: Titel, Klick bearbeitet, Rechtsklick öffnet das Menü. + _alldayBlock(event) { + const label = new St.Label({text: event.title, x_expand: true, y_align: Clutter.ActorAlign.CENTER}); + label.clutter_text.ellipsize = Pango.EllipsizeMode.END; + const block = new St.Button({ + style_class: 'week-allday-block', + child: label, + can_focus: true, + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + }); + block.connect('clicked', (_b, mouseButton) => { + if (mouseButton === 3) + eventMenu(this._store, event); + else + editEvent(this._store, event, block); + }); + return block; + } + + // Ganztags-Blöcke bündig über ihre Tagesspalten legen. + _placeAlldayBlocks(cells) { + const cellWidth = cells.get_child_at_index(0).width; + const cellStep = cells.get_child_at_index(1).x - cells.get_child_at_index(0).x; + for (const {block, from, to, lane} of this._alldayBlocks) { + block.set_position(Math.round(cells.x + from * cellStep), lane * ALLDAY_LANE_HEIGHT); + block.set_size(Math.round((to - from) * cellStep + cellWidth), ALLDAY_LANE_HEIGHT - 2); + } + } + + // Blöcke nach Tag, Uhrzeit und Spalte über das Raster legen. + _placeBlocks() { + const rows = this._weekRows; + const pitch = this._rowPitch(); + const dayCells = rows.get_child_at_index(0).get_child_at_index(1); + const cellWidth = dayCells.get_child_at_index(0).width; + const cellStep = dayCells.get_child_at_index(1).x - dayCells.get_child_at_index(0).x; + for (const {block, dayIndex, from, to, col, cols} of this._blocks) { + const width = cellWidth / cols; + block.set_position(Math.round(dayCells.x + dayIndex * cellStep + col * width), Math.round(from / 60 * pitch)); + block.set_size(Math.round(width), Math.round((to - from) / 60 * pitch) - 2); + } + } + + // Zeile mit Stundenlabel links und sieben gleich breiten Tageszellen. + _weekRow(text, cells, extraClass = '') { + const row = new St.BoxLayout({style_class: `week-row ${extraClass}`}); + row.add_child(new St.Label({text, style_class: 'week-hour'})); + const days = new St.BoxLayout({style_class: 'week-days', x_expand: true}); + days.layout_manager.homogeneous = true; + for (const cell of cells) + days.add_child(cell); + row.add_child(days); + return row; + } + + // Stundenzelle. Drücken und Ziehen wählt den Zeitraum für einen neuen Termin. + _slot(dayIndex, day, hour) { + const slot = new St.BoxLayout({vertical: true, style_class: 'week-slot', reactive: true, track_hover: true, x_expand: true}); + for (const todo of this._store.todosOn(day).filter(t => t.time && D.hourOf(t.time) === hour)) + slot.add_child(this._todoEntry(todo, 'week-entry')); + this._connectDrag(slot, dayIndex, hour, day); + this._slots[dayIndex][hour] = slot; + return slot; + } + + // Links drücken startet die Auswahl, rechts öffnet das Menü. hour null = Ganztags-Zeile. + _connectDrag(slot, dayIndex, hour, day) { + slot.connect('button-press-event', (_actor, event) => { + if (event.get_button() === Clutter.BUTTON_SECONDARY) + createMenu(this._store, day, hour === null ? null : `${pad(hour)}:00`); + else if (event.get_button() === Clutter.BUTTON_PRIMARY) + this._startDrag(dayIndex, hour); + else + return Clutter.EVENT_PROPAGATE; + return Clutter.EVENT_STOP; + }); + } + + _rowPitch() { + const rows = this._weekRows; + return rows.get_child_at_index(1).y - rows.get_child_at_index(0).y; + } + + _hourAt(y) { + const [, top] = this._weekRows.get_transformed_position(); + return Math.max(0, Math.min(23, Math.floor((y - top) / this._rowPitch()))); + } + + _dayAt(x) { + const cells = this._weekRows.get_child_at_index(0).get_child_at_index(1); + const [left] = cells.get_transformed_position(); + const step = cells.get_child_at_index(1).x - cells.get_child_at_index(0).x; + return Math.max(0, Math.min(6, Math.floor((x - left) / step))); + } + + // Auswahl läuft von einer Zelle bis zur anderen, auch über Tage hinweg. hour null = ganztägig. + _startDrag(dayIndex, hour) { + this._drag = {allDay: hour === null, from: {day: dayIndex, hour}, to: {day: dayIndex, hour}}; + this._highlightDrag(); + this._dragId = global.stage.connect('captured-event', (_stage, event) => { + const type = event.type(); + if (type === Clutter.EventType.MOTION) { + const [x, y] = event.get_coords(); + this._drag.to = {day: this._dayAt(x), hour: this._drag.allDay ? null : this._hourAt(y)}; + this._highlightDrag(); + } else if (type === Clutter.EventType.BUTTON_RELEASE) { + this._finishDrag(); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + }); + } + + // Anfang und Ende der Auswahl in zeitlicher Reihenfolge. + _dragRange() { + const {from, to} = this._drag; + const key = p => p.day * 24 + (p.hour ?? 0); + return key(from) <= key(to) ? [from, to] : [to, from]; + } + + _highlightDrag() { + const [first, last] = this._dragRange(); + const {allDay} = this._drag; + const mark = (slot, on) => slot[on ? 'add_style_class_name' : 'remove_style_class_name']('selecting'); + this._alldaySlots.forEach((slot, day) => mark(slot, allDay && day >= first.day && day <= last.day)); + this._slots.forEach((hours, day) => hours.forEach((slot, hour) => { + const startHour = day === first.day ? first.hour : 0; + const endHour = day === last.day ? last.hour : 23; + mark(slot, !allDay && day >= first.day && day <= last.day && hour >= startHour && hour <= endHour); + })); + } + + _clearHighlight() { + for (const slot of [...this._alldaySlots, ...this._slots.flat()]) + slot.remove_style_class_name('selecting'); + } + + _finishDrag() { + global.stage.disconnect(this._dragId); + const [first, last] = this._dragRange(); + const {allDay} = this._drag; + this._drag = null; + const preset = {date: this._weekDays[first.day]}; + if (last.day !== first.day) + preset.endDate = this._weekDays[last.day]; + let anchor = this._alldaySlots[last.day]; + if (!allDay) { + preset.start = `${pad(first.hour)}:00`; + preset.end = last.hour === 23 ? '23:59' : `${pad(last.hour + 1)}:00`; + anchor = this._slots[last.day][last.hour]; + } + const popover = editEvent(this._store, preset, anchor); + popover.onClose = () => this._clearHighlight(); + } + + // ---- Tag ---- + + _dayView(day) { + const [scroll, list] = scrollBox('day-scroll'); + scroll.connect('button-press-event', (_actor, event) => { + if (event.get_button() !== Clutter.BUTTON_SECONDARY) + return Clutter.EVENT_PROPAGATE; + createMenu(this._store, day); + return Clutter.EVENT_STOP; + }); + + const events = this._store.eventsOn(day); + list.add_child(new St.Label({text: 'Termine', style_class: 'section-title'})); + if (!events.length) + list.add_child(new St.Label({text: 'Keine Termine', style_class: 'empty-hint'})); + for (const event of events) { + const row = new St.BoxLayout({style_class: 'day-entry-box', x_expand: true}); + row.add_child(new St.Label({text: timeRange(event), style_class: 'day-entry-time', y_align: Clutter.ActorAlign.START})); + row.add_child(new St.Label({text: event.title, style_class: 'day-entry-title', x_expand: true})); + const b = new St.Button({ + style_class: 'day-entry', + child: row, + can_focus: true, + x_expand: true, + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + }); + b.connect('clicked', (_b, mouseButton) => { + if (mouseButton === 3) + eventMenu(this._store, event); + else + editEvent(this._store, event, b); + }); + list.add_child(b); + } + + const todos = this._store.todosOn(day); + list.add_child(new St.Label({text: 'Todos', style_class: 'section-title'})); + if (!todos.length) + list.add_child(new St.Label({text: 'Keine Todos', style_class: 'empty-hint'})); + for (const todo of todos) + list.add_child(todoRow(this._store, todo)); + + return scroll; + } +}); diff --git a/desktop@marha.de/dates.js b/desktop@marha.de/dates.js new file mode 100644 index 0000000..099aef1 --- /dev/null +++ b/desktop@marha.de/dates.js @@ -0,0 +1,128 @@ +// Datumshilfen. Kalendertage sind Strings 'YYYY-MM-DD', Uhrzeiten 'HH:MM'. +// Monate sind 0-basiert wie bei JS Date. + +export const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; +export const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + +function pad(n) { + return String(n).padStart(2, '0'); +} + +export function toDateString(date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +export function fromDateString(s) { + const [y, m, d] = s.split('-').map(Number); + return new Date(y, m - 1, d); +} + +export function today() { + return toDateString(new Date()); +} + +export function addDays(s, n) { + const date = fromDateString(s); + date.setDate(date.getDate() + n); + return toDateString(date); +} + +// Montag der Woche, in der s liegt. +export function startOfWeek(s) { + const date = fromDateString(s); + const offset = (date.getDay() + 6) % 7; + return addDays(s, -offset); +} + +export function weekDays(s) { + const monday = startOfWeek(s); + return Array.from({length: 7}, (_, i) => addDays(monday, i)); +} + +// 42 Tage (6 Wochen) ab dem Montag der Woche des Monatsersten. +export function monthGrid(year, month) { + const first = toDateString(new Date(year, month, 1)); + const start = startOfWeek(first); + return Array.from({length: 42}, (_, i) => addDays(start, i)); +} + +export function isoWeek(s) { + const date = fromDateString(s); + // Der Donnerstag derselben Woche bestimmt das ISO-Jahr. + const thursday = new Date(date); + thursday.setDate(date.getDate() - ((date.getDay() + 6) % 7) + 3); + const firstThursday = new Date(thursday.getFullYear(), 0, 4); + firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3); + return 1 + Math.round((thursday - firstThursday) / (7 * 24 * 3600 * 1000)); +} + +export function formatMonth(year, month) { + return `${MONTHS[month]} ${year}`; +} + +// 'Do, 17.09.2026' +export function formatDate(s) { + const date = fromDateString(s); + return `${WEEKDAYS[(date.getDay() + 6) % 7]}, ${formatDateInput(s)}`; +} + +// '17.09.2026' +export function formatDateInput(s) { + const [y, m, d] = s.split('-'); + return `${d}.${m}.${y}`; +} + +// '17.09.' +export function formatDayMonth(s) { + const [, m, d] = s.split('-'); + return `${d}.${m}.`; +} + +// Akzeptiert '17.09.2026', '17.9.26', '17.9.' (aktuelles Jahr) und '2026-09-17'. +export function parseDateInput(text) { + const t = text.trim(); + let y, m, d; + let match = t.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})?$/); + if (match) { + d = Number(match[1]); + m = Number(match[2]); + y = match[3] === undefined ? new Date().getFullYear() : Number(match[3]); + if (y < 100) + y += 2000; + } else { + match = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if (!match) + return null; + [y, m, d] = [Number(match[1]), Number(match[2]), Number(match[3])]; + } + const date = new Date(y, m - 1, d); + if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) + return null; + return toDateString(date); +} + +// Akzeptiert '9:00', '09:00', '9.30', '14' → 'HH:MM'. +export function parseTimeInput(text) { + const match = text.trim().match(/^(\d{1,2})(?:[:.](\d{2}))?$/); + if (!match) + return null; + const h = Number(match[1]); + const min = match[2] === undefined ? 0 : Number(match[2]); + if (h > 23 || min > 59) + return null; + return `${pad(h)}:${pad(min)}`; +} + +export function timeToMinutes(t) { + const [h, m] = t.split(':').map(Number); + return h * 60 + m; +} + +export function minutesToTime(minutes) { + return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; +} + +export function hourOf(t) { + return Number(t.split(':')[0]); +} diff --git a/desktop@marha.de/extension.js b/desktop@marha.de/extension.js new file mode 100644 index 0000000..547b601 --- /dev/null +++ b/desktop@marha.de/extension.js @@ -0,0 +1,68 @@ +import Meta from 'gi://Meta'; +import St from 'gi://St'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import {CalendarPanel} from './calendar.js'; +import {NotesPanel} from './notes.js'; +import {Store} from './store.js'; +import {TodoPanel} from './todos.js'; + +const MARGIN = 24; + +export default class DesktopExtension extends Extension { + enable() { + this._store = new Store(); + + // Nur die Panels sind reaktiv. Der Rest lässt Klicks zum Desktop durch. + this._root = new St.BoxLayout({style_class: 'desktop-root', reactive: false}); + this._notes = new NotesPanel(this._store); + this._root.add_child(this._notes); + // Rechte Spalte füllt den Rest, der Abstand dazwischen entspricht dem Außenrand. + const right = new St.BoxLayout({vertical: true, style_class: 'desktop-right', reactive: false, x_expand: true}); + right.add_child(new CalendarPanel(this._store)); + right.add_child(new TodoPanel(this._store)); + this._root.add_child(right); + + global.window_group.add_child(this._root); + this._restackId = global.display.connect('restacked', () => this._restack()); + this._mapId = global.window_manager.connect('map', () => this._restack()); + this._workareaId = global.display.connect('workareas-changed', () => this._layout()); + this._layout(); + this._restack(); + } + + disable() { + global.display.disconnect(this._restackId); + global.window_manager.disconnect(this._mapId); + global.display.disconnect(this._workareaId); + this._root.destroy(); + this._root = null; + this._notes = null; + this._store = null; + } + + // Füllt den Arbeitsbereich des Hauptmonitors (ohne Topbar und Dock). Notizen nehmen die halbe Bildschirmbreite. + _layout() { + const area = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + this._root.set_position(area.x + MARGIN, area.y + MARGIN); + this._root.set_size(area.width - 2 * MARGIN, area.height - 2 * MARGIN); + this._notes.set_width(Math.round(Main.layoutManager.primaryMonitor.width / 2)); + } + + // Liegt direkt über Desktop-Fenstern (z.B. Desktop Icons), sonst direkt über dem Wallpaper. + // Mutter sortiert die Fensterebene bei jedem Restack neu, daher hier nachziehen. + _restack() { + let below = Main.layoutManager._backgroundGroup; + for (const actor of global.window_group.get_children()) { + if (!(actor instanceof Meta.WindowActor)) + continue; + const window = actor.meta_window; + const isDing = window.customJS_ding || window.title?.startsWith('Desktop Icons'); + if (isDing || window.window_type === Meta.WindowType.DESKTOP) + below = actor; + } + global.window_group.set_child_above_sibling(this._root, below); + } +} diff --git a/desktop@marha.de/markdown.js b/desktop@marha.de/markdown.js new file mode 100644 index 0000000..b811729 --- /dev/null +++ b/desktop@marha.de/markdown.js @@ -0,0 +1,79 @@ +// Wandelt Markdown in Pango-Markup für St.Label um. +// Unterstützt: Überschriften, Fett, Kursiv, Durchgestrichen, Code (inline und Block), +// Listen, Checkboxen, Zitate, Links, Trennlinien. + +const CODE_BG = '#2b2b2b'; +const LINK_COLOR = '#8ab4f8'; +const QUOTE_COLOR = '#b0b0b0'; +// Platzhalter für herausgenommene Code-Spans (Private-Use-Zeichen, kommt in Text nicht vor). +const HOLE = ''; + +export function escapeMarkup(text) { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +function inline(text) { + // Inline-Code zuerst herausnehmen, damit Sternchen darin unberührt bleiben. + const codes = []; + let out = escapeMarkup(text).replace(/`([^`]+)`/g, (_, code) => { + codes.push(`${code}`); + return `${HOLE}${codes.length - 1}${HOLE}`; + }); + out = out + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '[Bild: $1]') + .replace(/\[([^\]]+)\]\([^)]*\)/g, `$1`) + .replace(/\[\[([^\]]+)\]\]/g, `$1`) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/(^|[^*\w])\*([^*\s](?:[^*]*[^*\s])?)\*(?!\w)/g, '$1$2') + .replace(/(^|[^_\w])_([^_\s](?:[^_]*[^_\s])?)_(?!\w)/g, '$1$2'); + return out.replace(new RegExp(`${HOLE}(\\d+)${HOLE}`, 'g'), (_, i) => codes[Number(i)]); +} + +// Absolute Punktgrößen: Schlüsselwörter wie xx-large skalieren im ClutterText den ganzen Text. +const HEADING_SIZE = ['18pt', '15pt', '12.5pt', '11pt', '11pt', '11pt']; + +export function renderMarkdown(text) { + const lines = text.replace(/\r\n/g, '\n').split('\n'); + const out = []; + let inCode = false; + + for (const line of lines) { + if (/^\s*```/.test(line)) { + inCode = !inCode; + continue; + } + if (inCode) { + out.push(`${escapeMarkup(line) || ' '}`); + continue; + } + + let match; + if ((match = line.match(/^(#{1,6})\s+(.*)$/))) { + const level = match[1].length; + out.push(`${inline(match[2])}`); + } else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { + out.push('────────────────────────────'); + } else if ((match = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/))) { + const box = match[2] === ' ' ? '☐' : '☑'; + const body = match[2] === ' ' ? inline(match[3]) : `${inline(match[3])}`; + out.push(`${indent(match[1])}${box} ${body}`); + } else if ((match = line.match(/^(\s*)[-*+]\s+(.*)$/))) { + out.push(`${indent(match[1])}• ${inline(match[2])}`); + } else if ((match = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/))) { + out.push(`${indent(match[1])}${match[2]}. ${inline(match[3])}`); + } else if ((match = line.match(/^>\s?(.*)$/))) { + out.push(`▎ ${inline(match[1])}`); + } else { + out.push(inline(line)); + } + } + return out.join('\n'); +} + +// Verschachtelte Listen: je 2 Leerzeichen oder 1 Tab eine Ebene tiefer. +function indent(spaces) { + const level = spaces.replace(/\t/g, ' ').length / 2; + return ' '.repeat(Math.floor(level) + 1); +} diff --git a/desktop@marha.de/metadata.json b/desktop@marha.de/metadata.json new file mode 100644 index 0000000..063d9d9 --- /dev/null +++ b/desktop@marha.de/metadata.json @@ -0,0 +1,6 @@ +{ + "uuid": "desktop@marha.de", + "name": "Desktop", + "description": "Kalender, Todos und Notizen direkt auf dem Desktop", + "shell-version": ["46"] +} diff --git a/desktop@marha.de/notes.js b/desktop@marha.de/notes.js new file mode 100644 index 0000000..d3bdb8a --- /dev/null +++ b/desktop@marha.de/notes.js @@ -0,0 +1,277 @@ +import Clutter from 'gi://Clutter'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; + +import {renderMarkdown} from './markdown.js'; +import {contextMenu, multilineEntry, Popover, scrollBox, textButton} from './widgets.js'; + +const AUTOSAVE_SECONDS = 3; + +export const NotesPanel = GObject.registerClass( +class NotesPanel extends St.BoxLayout { + _init(store) { + super._init({style_class: 'desktop-panel notes', reactive: true, x_expand: false, y_expand: true}); + this._store = store; + this._expanded = new Set(); + this._selected = null; // {type: 'note' | 'folder', path, name} + this._editing = false; + + // Links: Ordnerbaum + const left = new St.BoxLayout({vertical: true, style_class: 'notes-tree-column', x_expand: false}); + const [treeScroll, tree] = scrollBox('tree-scroll'); + this._tree = tree; + left.add_child(treeScroll); + this.add_child(left); + + // Rechts: Anzeige ohne Fokus, Editor mit Fokus. Klick in die Fläche startet den Editor. + const right = new St.BoxLayout({vertical: true, style_class: 'notes-content-column', x_expand: true}); + this._noteTitle = new St.Label({style_class: 'panel-title'}); + this._noteTitle.clutter_text.ellipsize = Pango.EllipsizeMode.END; + right.add_child(this._noteTitle); + + const [viewScroll, viewBox] = scrollBox('note-scroll'); + this._viewScroll = viewScroll; + this._content = new St.Label({style_class: 'note-content', x_expand: true}); + this._content.clutter_text.line_wrap = true; + this._content.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + this._content.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._content.clutter_text.use_markup = true; + viewBox.add_child(this._content); + viewScroll.connect('button-press-event', (_actor, event) => { + if (event.get_button() !== Clutter.BUTTON_PRIMARY) + return Clutter.EVENT_PROPAGATE; + this._startEdit(); + return Clutter.EVENT_STOP; + }); + right.add_child(viewScroll); + + [this._editScroll, this._editor] = multilineEntry('', 'note-editor', 'note-editor-scroll'); + this._editor.clutter_text.connect('text-changed', () => (this._dirty = true)); + this._editor.clutter_text.connect('key-focus-out', () => this._stopEdit()); + right.add_child(this._editScroll); + this.add_child(right); + + // Rechtsklick irgendwo im Panel legt im Wurzelordner an. Zeilen und Popover fangen ihren Klick selbst ab. + this.connect('button-press-event', (_actor, event) => { + if (event.get_button() !== Clutter.BUTTON_SECONDARY) + return Clutter.EVENT_PROPAGATE; + this._createMenu(store.notesDir); + return Clutter.EVENT_STOP; + }); + this.connect('destroy', () => this._stopAutosave()); + + this._refresh(); + } + + _refresh() { + this._tree.destroy_all_children(); + this._addChildren(this._store.tree(), 0); + this._showSelected(); + } + + _addChildren(folder, depth) { + for (const sub of folder.folders) { + const open = this._expanded.has(sub.path); + this._treeRow(`${open ? '▾' : '▸'} ${sub.name}`, depth, 'folder', sub, () => { + if (open) + this._expanded.delete(sub.path); + else + this._expanded.add(sub.path); + this._select({type: 'folder', path: sub.path, name: sub.name}); + }); + if (open) + this._addChildren(sub, depth + 1); + } + // Dünne Linie trennt Ordner von Notizen + if (folder.folders.length && folder.notes.length) { + const separator = new St.Widget({style_class: 'tree-separator'}); + separator.set_style(`margin-left: ${8 + depth * 16}px`); + this._tree.add_child(separator); + } + for (const note of folder.notes) + this._treeRow(note.name, depth, 'note', note, () => this._select({type: 'note', path: note.path, name: note.name})); + } + + _treeRow(text, depth, type, node, onClick) { + const row = textButton(text, (b, mouseButton) => { + if (mouseButton === 3) + this._rowMenu(type, node, b); + else + onClick(); + }, `tree-row ${type}`, type === 'folder' ? 'folder-symbolic' : 'text-x-generic-symbolic'); + row.set_style(`padding-left: ${8 + depth * 16}px`); + if (this._selected?.path === node.path) + row.add_style_class_name('selected'); + this._tree.add_child(row); + } + + // Auswahl wechseln. Ein offener Editor wird vorher gespeichert. + _select(selected) { + this._stopEdit(); + this._selected = selected; + this._refresh(); + } + + _showSelected() { + const selected = this._selected; + this._editScroll.hide(); + this._viewScroll.show(); + if (selected?.type === 'note') { + let text; + try { + text = this._store.readNote(selected.path); + } catch { + // Datei wurde außerhalb gelöscht + this._selected = null; + this._showSelected(); + return; + } + this._noteTitle.text = selected.name; + this._content.clutter_text.set_markup(renderMarkdown(text)); + } else { + this._noteTitle.text = ''; + this._content.text = ''; + } + } + + // ---- Editor direkt in der Fläche ---- + // Mit Fokus steht der rohe Text im Editor, alle paar Sekunden und beim Fokusverlust wird gespeichert. + + _startEdit() { + if (this._editing || this._selected?.type !== 'note') + return; + this._editing = true; + this._dirty = false; + this._editor.set_text(this._store.readNote(this._selected.path)); + this._viewScroll.hide(); + this._editScroll.show(); + this._editor.clutter_text.grab_key_focus(); + this._editor.clutter_text.cursor_position = -1; + this._autosaveId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTOSAVE_SECONDS, () => { + this._flush(); + return GLib.SOURCE_CONTINUE; + }); + // Ein Klick außerhalb des Editors beendet das Bearbeiten + this._clickOutsideId = global.stage.connect('captured-event', (_stage, event) => { + if (event.type() === Clutter.EventType.BUTTON_PRESS && !this._editScroll.contains(global.stage.get_event_actor(event))) + this._stopEdit(); + return Clutter.EVENT_PROPAGATE; + }); + } + + _flush() { + if (this._editing && this._dirty) { + this._store.writeNote(this._selected.path, this._editor.get_text()); + this._dirty = false; + } + } + + _stopAutosave() { + if (this._autosaveId) { + GLib.source_remove(this._autosaveId); + this._autosaveId = null; + } + if (this._clickOutsideId) { + global.stage.disconnect(this._clickOutsideId); + this._clickOutsideId = null; + } + } + + _stopEdit() { + if (!this._editing) + return; + this._flush(); + this._editing = false; + this._stopAutosave(); + this._showSelected(); + } + + // ---- Anlegen, Umbenennen, Löschen ---- + + _namePopover(anchor, title, value, onSave) { + const popover = new Popover(anchor); + popover.addLabel(title, 'popover-title'); + const entry = popover.addEntry(value, 'Name'); + popover.setSave(() => onSave(entry.get_text().trim())); + popover.open(entry.clutter_text); + entry.clutter_text.set_selection(0, -1); + return popover; + } + + _newNote(anchor, folder) { + let created = false; + const popover = this._namePopover(anchor, 'Neue Notiz', '', name => { + const path = this._store.createNote(folder, name); + this._expanded.add(folder); + this._select({type: 'note', path, name}); + created = true; + }); + // Erst nach dem Schließen, sonst holt sich das Popover den Tastaturfokus zurück. + popover.onClose = () => { + if (created) + this._startEdit(); + }; + } + + _newFolder(anchor, parent) { + this._namePopover(anchor, 'Neuer Ordner', '', name => { + const path = this._store.createFolder(parent, name); + this._expanded.add(parent); + this._expanded.add(path); + this._select({type: 'folder', path, name}); + }); + } + + _createItems(folder) { + const [x, y] = global.get_pointer(); + return [ + {label: 'Notiz erstellen', onSelect: () => this._newNote({x, y}, folder)}, + {label: 'Ordner erstellen', onSelect: () => this._newFolder({x, y}, folder)}, + ]; + } + + _createMenu(folder) { + contextMenu(this._createItems(folder)); + } + + _rowMenu(type, node, anchor) { + const isFolder = type === 'folder'; + const empty = isFolder ? this._store.folderEmpty(node.path) : true; + contextMenu([ + ...isFolder ? this._createItems(node.path) : [], + {label: 'Umbenennen', onSelect: () => this._rename(type, node, anchor)}, + {label: empty ? 'Löschen' : 'Löschen (Ordner ist nicht leer)', sensitive: empty, onSelect: () => this._delete(type, node)}, + ]); + } + + _rename(type, node, anchor) { + this._namePopover(anchor, 'Umbenennen', node.name, name => { + if (name === node.name) + return; + const path = type === 'note' ? this._store.renameNote(node.path, name) : this._store.renameFolder(node.path, name); + if (type === 'folder' && this._expanded.has(node.path)) { + this._expanded.delete(node.path); + this._expanded.add(path); + } + if (this._selected?.path === node.path) + this._select({type, path, name}); + else + this._refresh(); + }); + } + + _delete(type, node) { + if (this._selected?.path === node.path) { + this._editing = false; + this._stopAutosave(); + this._selected = null; + } + if (type === 'note') + this._store.deleteNote(node.path); + else + this._store.deleteFolder(node.path); + this._refresh(); + } +}); diff --git a/desktop@marha.de/store.js b/desktop@marha.de/store.js new file mode 100644 index 0000000..94499b8 --- /dev/null +++ b/desktop@marha.de/store.js @@ -0,0 +1,213 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +// Ablage: ~/.local/share/desktop-app/ +// events.json Termine +// todos.json Todos +// notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt + +const decoder = new TextDecoder(); + +function readJson(path) { + try { + const [ok, bytes] = GLib.file_get_contents(path); + return ok ? JSON.parse(decoder.decode(bytes)) : []; + } catch { + // Datei gibt es beim ersten Start noch nicht + return []; + } +} + +function writeJson(path, data) { + GLib.file_set_contents(path, JSON.stringify(data, null, 2)); +} + +function compareArrays(a, b) { + for (let i = 0; i < a.length; i++) { + if (a[i] < b[i]) + return -1; + if (a[i] > b[i]) + return 1; + } + return 0; +} + +function listFolder(path) { + const node = {name: GLib.path_get_basename(path), path, folders: [], notes: []}; + const dir = Gio.File.new_for_path(path); + const children = dir.enumerate_children('standard::name,standard::type', Gio.FileQueryInfoFlags.NONE, null); + let info; + while ((info = children.next_file(null))) { + const name = info.get_name(); + if (name.startsWith('.')) + continue; + const child = GLib.build_filenamev([path, name]); + if (info.get_file_type() === Gio.FileType.DIRECTORY) + node.folders.push(listFolder(child)); + else if (name.endsWith('.md')) + node.notes.push({name: name.slice(0, -3), path: child}); + } + children.close(null); + node.folders.sort((a, b) => a.name.localeCompare(b.name)); + node.notes.sort((a, b) => a.name.localeCompare(b.name)); + return node; +} + +function checkName(name) { + if (!name) + throw new Error('Name fehlt'); + if (name.includes('/') || name.startsWith('.')) + throw new Error('Name darf kein "/" enthalten und nicht mit "." beginnen'); +} + +export class Store { + constructor() { + this.dir = GLib.build_filenamev([GLib.get_user_data_dir(), 'desktop-app']); + this.notesDir = GLib.build_filenamev([this.dir, 'notes']); + GLib.mkdir_with_parents(this.notesDir, 0o755); + this._eventsPath = GLib.build_filenamev([this.dir, 'events.json']); + this._todosPath = GLib.build_filenamev([this.dir, 'todos.json']); + this.events = readJson(this._eventsPath); + this.todos = readJson(this._todosPath); + this._listeners = []; + } + + onChange(callback) { + this._listeners.push(callback); + } + + _changed() { + for (const callback of this._listeners) + callback(); + } + + // ---- Termine ---- + + // Termine, die den Tag berühren. Mehrtägige laufen von date bis endDate. + eventsOn(date) { + return this.events + .filter(e => e.date <= date && date <= (e.endDate ?? e.date)) + .sort((a, b) => compareArrays([a.start ?? '', a.title], [b.start ?? '', b.title])); + } + + saveEvent(event) { + const now = new Date().toISOString(); + if (!event.id) { + this.events.push({...event, id: GLib.uuid_string_random(), created: now, updated: now}); + } else { + const index = this.events.findIndex(e => e.id === event.id); + this.events[index] = {...event, updated: now}; + } + writeJson(this._eventsPath, this.events); + this._changed(); + } + + removeEvent(id) { + this.events = this.events.filter(e => e.id !== id); + writeJson(this._eventsPath, this.events); + this._changed(); + } + + // ---- Todos ---- + + todosOn(date) { + return this.todos + .filter(t => t.date === date) + .sort((a, b) => compareArrays([a.time ?? '', a.title], [b.time ?? '', b.title])); + } + + // Offene zuerst (terminierte nach Datum, dann allgemeine), erledigte zuletzt. + sortedTodos() { + const key = t => [t.done ? 1 : 0, t.date ? 0 : 1, t.date ?? '', t.time ?? '', t.created ?? '']; + return [...this.todos].sort((a, b) => compareArrays(key(a), key(b))); + } + + saveTodo(todo) { + const now = new Date().toISOString(); + if (!todo.id) { + this.todos.push({...todo, done: false, id: GLib.uuid_string_random(), created: now, updated: now}); + } else { + const index = this.todos.findIndex(t => t.id === todo.id); + this.todos[index] = {...todo, updated: now}; + } + writeJson(this._todosPath, this.todos); + this._changed(); + } + + toggleTodo(id) { + const todo = this.todos.find(t => t.id === id); + this.saveTodo({...todo, done: !todo.done}); + } + + removeTodo(id) { + this.todos = this.todos.filter(t => t.id !== id); + writeJson(this._todosPath, this.todos); + this._changed(); + } + + // ---- Notizen ---- + + tree() { + return listFolder(this.notesDir); + } + + readNote(path) { + const [, bytes] = GLib.file_get_contents(path); + return decoder.decode(bytes); + } + + writeNote(path, text) { + GLib.file_set_contents(path, text); + } + + createNote(folder, name) { + checkName(name); + const path = GLib.build_filenamev([folder, `${name}.md`]); + if (GLib.file_test(path, GLib.FileTest.EXISTS)) + throw new Error('Diese Notiz gibt es schon'); + GLib.file_set_contents(path, ''); + return path; + } + + createFolder(parent, name) { + checkName(name); + const path = GLib.build_filenamev([parent, name]); + if (GLib.file_test(path, GLib.FileTest.EXISTS)) + throw new Error('Diesen Ordner gibt es schon'); + Gio.File.new_for_path(path).make_directory(null); + return path; + } + + renameNote(path, name) { + checkName(name); + const target = GLib.build_filenamev([GLib.path_get_dirname(path), `${name}.md`]); + if (GLib.file_test(target, GLib.FileTest.EXISTS)) + throw new Error('Diese Notiz gibt es schon'); + Gio.File.new_for_path(path).move(Gio.File.new_for_path(target), Gio.FileCopyFlags.NONE, null, null); + return target; + } + + renameFolder(path, name) { + checkName(name); + const target = GLib.build_filenamev([GLib.path_get_dirname(path), name]); + if (GLib.file_test(target, GLib.FileTest.EXISTS)) + throw new Error('Diesen Ordner gibt es schon'); + Gio.File.new_for_path(path).move(Gio.File.new_for_path(target), Gio.FileCopyFlags.NONE, null, null); + return target; + } + + deleteNote(path) { + Gio.File.new_for_path(path).delete(null); + } + + folderEmpty(path) { + const node = listFolder(path); + return !node.folders.length && !node.notes.length; + } + + deleteFolder(path) { + if (!this.folderEmpty(path)) + throw new Error('Der Ordner ist nicht leer'); + Gio.File.new_for_path(path).delete(null); + } +} diff --git a/desktop@marha.de/stylesheet.css b/desktop@marha.de/stylesheet.css new file mode 100644 index 0000000..b904421 --- /dev/null +++ b/desktop@marha.de/stylesheet.css @@ -0,0 +1,195 @@ +/* Grundgerüst */ +.desktop-root { spacing: 24px; } +.desktop-right { spacing: 24px; } + +.desktop-panel { + background-color: rgba(18, 18, 18, 0.78); + border-radius: 14px; + padding: 14px; + color: #e6e6e6; + font-size: 10.5pt; + spacing: 10px; +} +.notes { spacing: 12px; } +.calendar { height: 540px; } + +.panel-header { spacing: 6px; } +.panel-title { font-weight: bold; font-size: 12pt; } +.empty-hint { color: #888; font-style: italic; } + +/* Buttons */ +.desktop-button { + background-color: rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 4px 10px; + color: #e6e6e6; +} +.desktop-button:hover, .desktop-button:focus { background-color: rgba(255, 255, 255, 0.18); } +.desktop-button:checked, .desktop-button.primary { background-color: #e9672e; color: white; } +.desktop-button.primary:hover, .desktop-button.primary:focus { background-color: #f07a45; } +.nav-button { padding: 4px 8px; font-weight: bold; } +.add-button { font-weight: bold; padding: 4px 12px; } + +/* Monat */ +.weekday-row { spacing: 2px; } +.weekday { color: #999; font-size: 9pt; text-align: center; padding: 2px 0; } +.month-day { background-color: rgba(255, 255, 255, 0.04); border-radius: 6px; padding: 4px; } +.month-day:hover, .month-day:focus { background-color: rgba(255, 255, 255, 0.12); } +.month-day.other-month { color: #777; } +.month-day.today { border: 1px solid #e9672e; } +.month-day-number { font-weight: bold; } +.month-entry { + font-size: 8pt; + background-color: rgba(233, 103, 46, 0.4); + border-radius: 3px; + padding: 0 3px; + margin-top: 2px; +} +.month-entry.todo { background-color: rgba(138, 180, 248, 0.3); } +.month-entry:hover { background-color: rgba(233, 103, 46, 0.9); } +.month-more { font-size: 8pt; color: #aaa; } + +/* Woche */ +.week-row { spacing: 2px; margin-bottom: 2px; } +.week-row.head { margin-bottom: 4px; } +.week-days { spacing: 2px; } +.week-hour { width: 40px; color: #999; font-size: 8.5pt; } +.week-day-head { color: #ccc; font-size: 9pt; text-align: center; } +.week-day-head.today { color: #e9672e; font-weight: bold; } +.week-slot { background-color: rgba(255, 255, 255, 0.04); border-radius: 4px; padding: 2px 0; min-height: 40px; } +.week-row.allday .week-slot { min-height: 18px; } +.week-slot:hover, .week-slot:focus { background-color: rgba(255, 255, 255, 0.12); } +.week-slot.selecting { background-color: rgba(233, 103, 46, 0.35); } +.week-entry { + font-size: 8pt; + background-color: rgba(233, 103, 46, 0.55); + border-radius: 3px; + padding: 1px 3px; + margin-bottom: 1px; +} +.week-entry.todo { background-color: rgba(138, 180, 248, 0.45); } +.week-entry:hover { background-color: rgba(233, 103, 46, 1); } +.week-allday-block { + background-color: rgba(233, 103, 46, 0.75); + border-radius: 3px; + padding: 0 4px; + font-size: 8pt; + color: white; +} +.week-allday-block:hover, .week-allday-block:focus { background-color: rgba(233, 103, 46, 1); } +.week-block { + background-color: rgba(233, 103, 46, 0.75); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 4px; + padding: 2px 4px; +} +.week-block:hover, .week-block:focus { background-color: rgba(233, 103, 46, 1); } +.week-block-time { font-size: 7.5pt; color: rgba(255, 255, 255, 0.85); } +.week-block-title { font-size: 8.5pt; font-weight: bold; color: white; } + +/* Tag */ +.section-title { color: #999; font-size: 9pt; font-weight: bold; margin-top: 6px; margin-bottom: 4px; } +.day-entry { background-color: rgba(255, 255, 255, 0.06); border-radius: 6px; padding: 6px 10px; margin-bottom: 4px; } +.day-entry:hover, .day-entry:focus { background-color: rgba(255, 255, 255, 0.14); } +.day-entry-box { spacing: 10px; } +.day-entry-time { color: #e9672e; width: 100px; } +.day-entry-title { font-weight: bold; } + +/* Todos */ +.todo-row { spacing: 6px; } +.todo-check { padding: 4px; border-radius: 4px; } +.todo-check:hover, .todo-check:focus { background-color: rgba(255, 255, 255, 0.14); } +.todo-title { padding: 4px 6px; border-radius: 4px; } +.todo-title:hover, .todo-title:focus { background-color: rgba(255, 255, 255, 0.1); } +.todo-title.done { color: #888; } +.todo-when { color: #aaa; font-size: 9pt; padding-right: 4px; } +.todo-when.overdue { color: #ff6b6b; } + +/* Notizen */ +.notes-tree-column { width: 230px; spacing: 8px; } +.tree-row { padding: 4px 8px; border-radius: 4px; } +.tree-row:hover, .tree-row:focus { background-color: rgba(255, 255, 255, 0.1); } +.tree-row.selected { background-color: rgba(233, 103, 46, 0.5); } +.tree-row.folder { font-weight: bold; } +.icon-row { spacing: 6px; } +.tree-row StIcon { color: #aaa; } +.tree-separator { height: 1px; background-color: rgba(255, 255, 255, 0.15); margin: 4px 8px; } +.notes-content-column { spacing: 8px; padding-left: 12px; } +.note-content { font-size: 10.5pt; } +/* Der Scrollbereich ist der sichtbare Rahmen, das Feld darin bleibt unsichtbar. */ +.note-editor-scroll { + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + padding: 8px; +} +.note-editor { + font-family: monospace; + background-color: transparent; + border: none; + padding: 0; + color: #e6e6e6; + caret-color: #e6e6e6; + selection-background-color: #e9672e; + selected-color: white; +} + +/* Popover */ +.desktop-popover { + background-color: rgba(28, 28, 28, 0.97); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 10px; + padding: 12px; + spacing: 6px; + color: #e6e6e6; + font-size: 10.5pt; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); +} +.desktop-popover.form { width: 320px; } +.desktop-popover.menu { padding: 4px; spacing: 0; } +.popover-title { font-weight: bold; font-size: 11pt; } +.popover-subtitle { color: #aaa; font-size: 9pt; } +.popover-label { font-weight: bold; } +.popover-entry { + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 6px 8px; + color: #e6e6e6; + caret-color: #e6e6e6; + selection-background-color: #e9672e; + selected-color: white; +} +.popover-entry:focus { border-color: #e9672e; } +.popover-entry.multiline { background-color: transparent; border: none; padding: 0; } +.popover-scroll { + height: 90px; + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 6px 8px; +} +.popover-row { spacing: 6px; } +.popover-row-label { color: #aaa; font-size: 9pt; width: 28px; } +.popover-error { color: #ff6b6b; } +.menu-item { padding: 6px 14px; border-radius: 6px; min-width: 120px; } +.menu-item:hover, .menu-item:focus { background-color: rgba(255, 255, 255, 0.12); } +.menu-item.selected { color: #e9672e; font-weight: bold; } +.menu-item.insensitive { color: #777; } +.desktop-dropdown { + background-color: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 4px 10px; + color: #e6e6e6; +} +.desktop-dropdown:hover, .desktop-dropdown:focus { background-color: rgba(255, 255, 255, 0.18); } +.datepicker { padding: 10px; spacing: 4px; } +.datepicker-row { spacing: 2px; } +.datepicker-weekday { width: 32px; color: #999; font-size: 8.5pt; text-align: center; } +.datepicker-day { width: 32px; height: 26px; border-radius: 4px; font-size: 9pt; } +.datepicker-day:hover, .datepicker-day:focus { background-color: rgba(255, 255, 255, 0.15); } +.datepicker-day.other-month { color: #666; } +.datepicker-day.today { border: 1px solid #e9672e; } +.datepicker-day.selected { background-color: #e9672e; color: white; } +.timepicker-scroll { height: 240px; } diff --git a/desktop@marha.de/todos.js b/desktop@marha.de/todos.js new file mode 100644 index 0000000..fb1a7e2 --- /dev/null +++ b/desktop@marha.de/todos.js @@ -0,0 +1,129 @@ +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; + +import * as D from './dates.js'; +import {escapeMarkup} from './markdown.js'; +import {contextMenu, dropdown, openDatePicker, openTimePicker, Popover, scrollBox, textButton} from './widgets.js'; + +// '17.09. 14:00', '17.09.' oder '' bei allgemeinen Todos. +export function todoWhen(todo) { + if (!todo.date) + return ''; + return todo.time ? `${D.formatDayMonth(todo.date)} ${todo.time}` : D.formatDayMonth(todo.date); +} + +// Erste Zeile des Textes für Listen. +export function todoTitle(todo) { + return todo.title.split('\n')[0]; +} + +// Popover mit Textfeld sowie Auswahl für Datum und Uhrzeit. Enter speichert. +export function editTodo(store, todo, anchor) { + const popover = new Popover(anchor); + popover.addLabel(todo.id ? 'Todo bearbeiten' : 'Neues Todo', 'popover-title'); + const text = popover.addEntry(todo.title ?? '', 'Was ist zu tun?'); + + let date = todo.date ?? null; + let time = todo.time ?? null; + const row = new St.BoxLayout({style_class: 'popover-row'}); + const dateButton = dropdown('', b => openDatePicker(b, date, picked => { + date = picked; + if (!date) + time = null; + updateLabels(); + }), text.clutter_text); + const timeButton = dropdown('', b => openTimePicker(b, time, picked => { + time = picked; + // Eine Uhrzeit ohne Datum meint heute. + if (time && !date) + date = D.today(); + updateLabels(); + }), text.clutter_text); + const updateLabels = () => { + dateButton.setLabel(date ? D.formatDate(date) : 'Kein Datum'); + timeButton.setLabel(time ?? 'Keine Uhrzeit'); + }; + updateLabels(); + row.add_child(dateButton); + row.add_child(timeButton); + popover.add(row); + + popover.setSave(() => { + const title = text.get_text().trim(); + if (!title) + return 'Text fehlt'; + store.saveTodo({...todo, title, date, time}); + }); + popover.open(text.clutter_text); + return popover; +} + +export function todoMenu(store, todo) { + contextMenu([{label: 'Löschen', onSelect: () => store.removeTodo(todo.id)}]); +} + +// Zeile mit Häkchen, Titel und Termin. Klick bearbeitet, Rechtsklick öffnet das Menü. +export function todoRow(store, todo) { + const row = new St.BoxLayout({style_class: 'todo-row', x_expand: true}); + + const check = new St.Button({ + style_class: 'todo-check', + can_focus: true, + child: new St.Icon({icon_name: todo.done ? 'checkbox-checked-symbolic' : 'checkbox-symbolic', icon_size: 16}), + }); + check.connect('clicked', () => store.toggleTodo(todo.id)); + row.add_child(check); + + const title = textButton(todoTitle(todo), (b, mouseButton) => { + if (mouseButton === 3) + todoMenu(store, todo); + else + editTodo(store, todo, b); + }, todo.done ? 'todo-title done' : 'todo-title'); + if (todo.done) + title.child.clutter_text.set_markup(`${escapeMarkup(todoTitle(todo))}`); + row.add_child(title); + + const when = todoWhen(todo); + if (when) { + const label = new St.Label({text: when, style_class: 'todo-when', y_align: Clutter.ActorAlign.CENTER}); + if (!todo.done && todo.date < D.today()) + label.add_style_class_name('overdue'); + row.add_child(label); + } + return row; +} + +export const TodoPanel = GObject.registerClass( +class TodoPanel extends St.BoxLayout { + _init(store) { + super._init({vertical: true, style_class: 'desktop-panel todos', reactive: true, x_expand: true, y_expand: true}); + this._store = store; + + const [scroll, list] = scrollBox('todo-scroll'); + this._list = list; + this.add_child(scroll); + + // Rechtsklick auf die freie Fläche + this.connect('button-press-event', (_actor, event) => { + if (event.get_button() !== Clutter.BUTTON_SECONDARY) + return Clutter.EVENT_PROPAGATE; + const [x, y] = global.get_pointer(); + contextMenu([{label: 'Erstellen', onSelect: () => editTodo(store, {}, {x, y})}]); + return Clutter.EVENT_STOP; + }); + + store.onChange(() => this._render()); + this._render(); + } + + _render() { + this._list.destroy_all_children(); + const todos = this._store.sortedTodos(); + if (!todos.length) + this._list.add_child(new St.Label({text: 'Keine Todos', style_class: 'empty-hint'})); + for (const todo of todos) + this._list.add_child(todoRow(this._store, todo)); + } +}); diff --git a/desktop@marha.de/widgets.js b/desktop@marha.de/widgets.js new file mode 100644 index 0000000..cf6a807 --- /dev/null +++ b/desktop@marha.de/widgets.js @@ -0,0 +1,330 @@ +import Clutter from 'gi://Clutter'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import * as D from './dates.js'; + +export function button(label, onClick, styleClass = 'desktop-button') { + const b = new St.Button({label, style_class: styleClass, can_focus: true}); + b.connect('clicked', onClick); + return b; +} + +// Button mit linksbündigem, gekürztem Text und optionalem Icon. onClick(button, mausTaste): 1 = links, 3 = rechts. +export function textButton(text, onClick, styleClass, iconName = null) { + const label = new St.Label({text, x_expand: true, x_align: Clutter.ActorAlign.START, y_align: Clutter.ActorAlign.CENTER}); + label.clutter_text.ellipsize = Pango.EllipsizeMode.END; + let child = label; + if (iconName) { + child = new St.BoxLayout({style_class: 'icon-row', x_expand: true}); + child.add_child(new St.Icon({icon_name: iconName, icon_size: 14, y_align: Clutter.ActorAlign.CENTER})); + child.add_child(label); + } + const b = new St.Button({ + style_class: styleClass, + child, + can_focus: true, + x_expand: true, + x_align: Clutter.ActorAlign.FILL, + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + }); + b.connect('clicked', onClick); + b.label_actor = label; + return b; +} + +export function scrollBox(styleClass) { + const scroll = new St.ScrollView({ + style_class: styleClass, + hscrollbar_policy: St.PolicyType.NEVER, + vscrollbar_policy: St.PolicyType.AUTOMATIC, + x_expand: true, + y_expand: true, + }); + const box = new St.BoxLayout({vertical: true, x_expand: true}); + scroll.set_child(box); + return [scroll, box]; +} + +// Mehrzeiliges Eingabefeld in einem Scrollbereich. Der Scrollbereich ist der sichtbare Rahmen, +// ein Klick in die freie Fläche darunter setzt den Cursor ans Ende. Der Cursor bleibt beim Tippen sichtbar. +export function multilineEntry(text, styleClass, scrollClass, hint = '') { + const entry = new St.Entry({can_focus: true, text, hint_text: hint, style_class: `${styleClass} multiline`, x_expand: true}); + const clutterText = entry.clutter_text; + clutterText.single_line_mode = false; + clutterText.line_wrap = true; + clutterText.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + clutterText.activatable = false; + const [scroll, box] = scrollBox(scrollClass); + box.add_child(entry); + scroll.connect('button-press-event', (_actor, event) => { + if (event.get_button() !== Clutter.BUTTON_PRIMARY) + return Clutter.EVENT_PROPAGATE; + clutterText.grab_key_focus(); + clutterText.cursor_position = -1; + return Clutter.EVENT_STOP; + }); + clutterText.connect('cursor-changed', () => { + const [, , y, height] = clutterText.position_to_coords(clutterText.cursor_position); + const adjustment = scroll.vadjustment; + if (y < adjustment.value) + adjustment.value = y; + else if (y + height > adjustment.value + adjustment.page_size) + adjustment.value = y + height - adjustment.page_size; + }); + return [scroll, entry]; +} + +// Kleines Fenster neben einem Anker (Actor oder {x, y}). Hält Maus und Tastatur. +// Enter in einem Feld speichert, Klick daneben oder Esc bricht ab. Keine Buttons. +// Mehrere Popover stapeln sich, das oberste hält den Grab. +// Bei aktivem Grab laufen Events nur durch den Grab-Actor, daher hängt captured-event an der Box. +export class Popover { + // below: unter dem Anker statt daneben (für Auswahllisten unter einem Button) + constructor(anchor, styleClass = 'form', {below = false} = {}) { + this._anchor = anchor; + this._below = below; + this.box = new St.BoxLayout({vertical: true, style_class: `desktop-popover ${styleClass}`, reactive: true}); + this._grab = null; + this._onSave = null; + this.onClose = null; + } + + add(actor) { + this.box.add_child(actor); + return actor; + } + + addLabel(text, styleClass = 'popover-label') { + return this.add(new St.Label({text, style_class: styleClass})); + } + + // Einzeiliges Feld. Enter speichert. + addEntry(text = '', hint = '') { + const entry = new St.Entry({can_focus: true, text, hint_text: hint, style_class: 'popover-entry', x_expand: true}); + entry.clutter_text.connect('activate', () => this.save()); + return this.add(entry); + } + + // Zeile mit Beschriftung und Auswahl-Buttons. + addRow(labelText, ...actors) { + const row = new St.BoxLayout({style_class: 'popover-row'}); + row.add_child(new St.Label({text: labelText, style_class: 'popover-row-label', y_align: Clutter.ActorAlign.CENTER})); + for (const actor of actors) + row.add_child(actor); + return this.add(row); + } + + // onSave() gibt bei einem Problem einen Fehlertext zurück, dann bleibt das Popover offen. + setSave(onSave) { + this._onSave = onSave; + this._error = new St.Label({style_class: 'popover-error', visible: false}); + this.add(this._error); + } + + save() { + let error; + try { + error = this._onSave?.(); + } catch (e) { + error = e.message; + } + if (error) { + this._error.text = error; + this._error.show(); + return; + } + this.close(); + } + + open(focusActor = null) { + Main.uiGroup.add_child(this.box); + this._place(); + this._grab = Main.pushModal(this.box); + this.box.connect('captured-event', (_box, event) => this._onEvent(event)); + if (focusActor) + global.stage.set_key_focus(focusActor); + } + + close() { + if (!this._grab) + return; + Main.popModal(this._grab); + this._grab = null; + this.box.destroy(); + this.onClose?.(); + } + + _place() { + let ax, ay, aw, ah; + if (this._anchor.get_transformed_position) { + [ax, ay] = this._anchor.get_transformed_position(); + [aw, ah] = this._anchor.get_transformed_size(); + } else { + ({x: ax, y: ay} = this._anchor); + aw = ah = 0; + } + const [, , width, height] = this.box.get_preferred_size(); + const monitor = Main.layoutManager.primaryMonitor; + const gap = 6; + let x, y; + if (this._below) { + x = ax; + y = ay + ah + gap; + if (y + height > monitor.y + monitor.height) + y = ay - height - gap; + } else { + x = ax + aw + gap; + if (x + width > monitor.x + monitor.width) + x = ax - width - gap; + y = ay; + } + x = Math.max(monitor.x + gap, Math.min(x, monitor.x + monitor.width - width - gap)); + y = Math.max(monitor.y + gap, Math.min(y, monitor.y + monitor.height - height - gap)); + this.box.set_position(Math.round(x), Math.round(y)); + } + + _onEvent(event) { + const type = event.type(); + if (type === Clutter.EventType.KEY_PRESS) { + const symbol = event.get_key_symbol(); + if (symbol === Clutter.KEY_Escape) { + this.close(); + return Clutter.EVENT_STOP; + } + if (symbol === Clutter.KEY_Tab || symbol === Clutter.KEY_ISO_Left_Tab) { + const backward = symbol === Clutter.KEY_ISO_Left_Tab || event.has_shift_modifier(); + this.box.navigate_focus(global.stage.get_key_focus(), + backward ? St.DirectionType.TAB_BACKWARD : St.DirectionType.TAB_FORWARD, true); + return Clutter.EVENT_STOP; + } + } + if (type === Clutter.EventType.BUTTON_PRESS || type === Clutter.EventType.TOUCH_BEGIN) { + const target = global.stage.get_event_actor(event); + if (!target || !this.box.contains(target)) { + this.close(); + return Clutter.EVENT_STOP; + } + } + return Clutter.EVENT_PROPAGATE; + } +} + +// Kontextmenü am Mauszeiger. items: [{label, onSelect, sensitive = true}] +export function contextMenu(items) { + const [x, y] = global.get_pointer(); + const menu = new Popover({x, y}, 'menu'); + for (const item of items) { + const row = textButton(item.label, () => { + menu.close(); + item.onSelect(); + }, 'menu-item'); + if (item.sensitive === false) { + row.reactive = false; + row.add_style_class_name('insensitive'); + } + menu.add(row); + } + menu.open(); + return menu; +} + +// Auswahl-Button. openPicker(button) öffnet die Auswahl und gibt das Popover zurück, +// setLabel(text) aktualisiert die Anzeige. Nach der Auswahl geht der Fokus zurück auf focusAfter, +// damit Enter danach speichert statt die Auswahl erneut zu öffnen. +export function dropdown(labelText, openPicker, focusAfter = null) { + const b = new St.Button({style_class: 'desktop-dropdown', label: `${labelText} ▾`, can_focus: true}); + b.setLabel = text => b.set_label(`${text} ▾`); + b.connect('clicked', () => { + const picker = openPicker(b); + if (focusAfter) + picker.onClose = () => global.stage.set_key_focus(focusAfter); + }); + return b; +} + +// Datumsauswahl: Monatsraster plus „Kein Datum“. onPick('YYYY-MM-DD' | null) +export function openDatePicker(anchor, value, onPick) { + const picker = new Popover(anchor, 'menu datepicker', {below: true}); + let cursor = D.fromDateString(value ?? D.today()); + const title = new St.Label({style_class: 'popover-label', x_expand: true, x_align: Clutter.ActorAlign.CENTER}); + const header = new St.BoxLayout({style_class: 'panel-header'}); + header.add_child(button('‹', () => shift(-1), 'desktop-button nav-button')); + header.add_child(title); + header.add_child(button('›', () => shift(1), 'desktop-button nav-button')); + picker.add(header); + + const weekdays = new St.BoxLayout({style_class: 'datepicker-row'}); + for (const name of D.WEEKDAYS) + weekdays.add_child(new St.Label({text: name, style_class: 'datepicker-weekday'})); + picker.add(weekdays); + + const grid = new St.BoxLayout({vertical: true}); + picker.add(grid); + picker.add(button('Kein Datum', () => { + picker.close(); + onPick(null); + }, 'desktop-button')); + + const render = () => { + title.text = D.formatMonth(cursor.getFullYear(), cursor.getMonth()); + grid.destroy_all_children(); + const days = D.monthGrid(cursor.getFullYear(), cursor.getMonth()); + for (let week = 0; week < 6; week++) { + const row = new St.BoxLayout({style_class: 'datepicker-row'}); + for (const day of days.slice(week * 7, week * 7 + 7)) { + const b = button(String(Number(day.slice(8))), () => { + picker.close(); + onPick(day); + }, 'datepicker-day'); + if (D.fromDateString(day).getMonth() !== cursor.getMonth()) + b.add_style_class_name('other-month'); + if (day === D.today()) + b.add_style_class_name('today'); + if (day === value) + b.add_style_class_name('selected'); + row.add_child(b); + } + grid.add_child(row); + } + }; + const shift = n => { + cursor = new Date(cursor.getFullYear(), cursor.getMonth() + n, 1); + render(); + }; + render(); + picker.open(); + return picker; +} + +// Uhrzeitauswahl in halben Stunden plus „Keine Uhrzeit“. onPick('HH:MM' | null) +export function openTimePicker(anchor, value, onPick) { + const picker = new Popover(anchor, 'menu timepicker', {below: true}); + const [scroll, list] = scrollBox('timepicker-scroll'); + picker.add(scroll); + const options = [null]; + for (let minutes = 0; minutes < 24 * 60; minutes += 30) + options.push(`${String(Math.floor(minutes / 60)).padStart(2, '0')}:${minutes % 60 === 0 ? '00' : '30'}`); + // Liste startet beim gewählten Wert, sonst morgens. + let target = null; + for (const option of options) { + const row = textButton(option ?? 'Keine Uhrzeit', () => { + picker.close(); + onPick(option); + }, 'menu-item'); + if (option === value) + row.add_style_class_name('selected'); + if (option === (value ?? '08:00')) + target = row; + list.add_child(row); + } + picker.open(); + const adjustment = scroll.vadjustment; + const id = adjustment.connect('changed', () => { + adjustment.value = target.y; + adjustment.disconnect(id); + }); + return picker; +} diff --git a/dev/click.sh b/dev/click.sh new file mode 100755 index 0000000..b37f084 --- /dev/null +++ b/dev/click.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Simuliert einen Klick in der nested Shell: dev/click.sh x y +# Ermittelt den Actor an der Position wie bei einem echten Klick (Picking) und löst den +# nächstliegenden St.Button aus. Gibt den getroffenen Actor aus. +DIR=$(cd "$(dirname "$0")" && pwd) +"$DIR/eval.sh" "const Clutter = imports.gi.Clutter, St = imports.gi.St; +let actor = global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, $1, $2); +const hit = actor ? actor.constructor.name + ' [' + (actor.style_class ?? '') + ']' : 'nichts'; +while (actor && !(actor instanceof St.Button)) + actor = actor.get_parent(); +if (actor) + actor.emit('clicked', 1); +hit + (actor ? ' -> clicked ' + actor.constructor.name + ' [' + actor.style_class + ']' : ' -> kein Button'); +" diff --git a/dev/enable.py b/dev/enable.py new file mode 100755 index 0000000..1c87598 --- /dev/null +++ b/dev/enable.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +# Trägt eine Extension-UUID in die aktiven GNOME-Extensions ein, falls sie fehlt: dev/enable.py +import ast +import subprocess +import sys + +uuid = sys.argv[1] +raw = subprocess.check_output(['gsettings', 'get', 'org.gnome.shell', 'enabled-extensions']).decode().strip() +enabled = ast.literal_eval(raw.replace('@as ', '')) +if uuid in enabled: + print(f'{uuid} ist schon aktiviert.') +else: + enabled.append(uuid) + subprocess.check_call(['gsettings', 'set', 'org.gnome.shell', 'enabled-extensions', str(enabled)]) + print(f'{uuid} aktiviert.') diff --git a/dev/eval.sh b/dev/eval.sh new file mode 100755 index 0000000..bd91fed --- /dev/null +++ b/dev/eval.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# JavaScript in der nested Shell ausführen: dev/eval.sh 'code' oder dev/eval.sh datei.js +SCRATCH=${SCRATCH:-/tmp/desktop-app-nested} +export DBUS_SESSION_BUS_ADDRESS=$(cat "$SCRATCH/bus") +if [ -f "$1" ]; then + CODE="eval(new TextDecoder().decode(GLib.file_get_contents('$(realpath "$1")')[1]))" +else + CODE="$1" +fi +gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell --method org.gnome.Shell.Eval "$CODE" \ + | sed -e 's/^(true, /OK /' -e 's/^(false, /FEHLER /' diff --git a/dev/fake-ding.py b/dev/fake-ding.py new file mode 100644 index 0000000..6d74652 --- /dev/null +++ b/dev/fake-ding.py @@ -0,0 +1,18 @@ +# Fenster, das sich wie Desktop Icons (DING) ausgibt: Titel "Desktop Icons 1", bildschirmfüllend. +# Start: WAYLAND_DISPLAY=wayland-1 DBUS_SESSION_BUS_ADDRESS=$(cat /tmp/desktop-app-nested/bus) python3 dev/fake-ding.py +import gi +gi.require_version('Gtk', '4.0') +from gi.repository import Gtk, GLib + +app = Gtk.Application(application_id='com.rastersoft.dingtest') + +def activate(app): + win = Gtk.ApplicationWindow(application=app, title='Desktop Icons 1', default_width=1920, default_height=1080) + label = Gtk.Label(label='FAKE DING') + label.set_valign(Gtk.Align.END) + label.set_halign(Gtk.Align.CENTER) + win.set_child(label) + win.present() + +app.connect('activate', activate) +app.run() diff --git a/dev/input.sh b/dev/input.sh new file mode 100755 index 0000000..49f5cd5 --- /dev/null +++ b/dev/input.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Echte Eingaben in der headless Test-Shell (virtuelle Geräte, nur dort gefahrlos): +# dev/input.sh click X Y Linksklick +# dev/input.sh rclick X Y Rechtsklick +# dev/input.sh drag X1 Y1 X2 Y2 Linke Taste drücken, ziehen, loslassen +# dev/input.sh move X Y Zeiger bewegen +# dev/input.sh press X Y Linke Taste drücken (halten) +# dev/input.sh release Linke Taste loslassen +# dev/input.sh type "Text" Text tippen (ASCII) +# dev/input.sh key Escape Taste (Clutter.KEY_*-Name ohne Präfix), z.B. Return, Escape, Tab +DIR=$(cd "$(dirname "$0")" && pwd) +PRE='const Clutter = imports.gi.Clutter; +const seat = Clutter.get_default_backend().get_default_seat(); +globalThis._vp ??= seat.create_virtual_device(Clutter.InputDeviceType.POINTER_DEVICE); +globalThis._vk ??= seat.create_virtual_device(Clutter.InputDeviceType.KEYBOARD_DEVICE); +const t = () => GLib.get_monotonic_time(); +const move = (x, y) => globalThis._vp.notify_absolute_motion(t(), x, y); +const button = (b, s) => globalThis._vp.notify_button(t(), b, s ? Clutter.ButtonState.PRESSED : Clutter.ButtonState.RELEASED); +const key = (k, s) => globalThis._vk.notify_keyval(t(), k, s ? Clutter.KeyState.PRESSED : Clutter.KeyState.RELEASED); +' +case "$1" in + click) CODE="move($2, $3); button(Clutter.BUTTON_PRIMARY, 1); button(Clutter.BUTTON_PRIMARY, 0);" ;; + rclick) CODE="move($2, $3); button(Clutter.BUTTON_SECONDARY, 1); button(Clutter.BUTTON_SECONDARY, 0);" ;; + drag) "$0" press "$2" "$3"; sleep 0.15 + for i in 1 2 3 4; do "$0" move $(( $2 + ($4 - $2) * i / 4 )) $(( $3 + ($5 - $3) * i / 4 )); sleep 0.1; done + "$0" release; exit ;; + move) CODE="move($2, $3);" ;; + press) CODE="move($2, $3); button(Clutter.BUTTON_PRIMARY, 1);" ;; + release) CODE="button(Clutter.BUTTON_PRIMARY, 0);" ;; + type) TEXT=$(printf '%s' "$2" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))') + CODE="for (const ch of $TEXT) { const k = ch.charCodeAt(0) === 10 ? Clutter.KEY_Return : Clutter.unicode_to_keysym(ch.codePointAt(0)); key(k, 1); key(k, 0); }" ;; + key) CODE="key(Clutter.KEY_$2, 1); key(Clutter.KEY_$2, 0);" ;; + *) echo "unbekannt: $1"; exit 1 ;; +esac +"$DIR/eval.sh" "$PRE $CODE 'ok'" | grep -v "^OK '\"ok\"')$" diff --git a/dev/nested.sh b/dev/nested.sh new file mode 100755 index 0000000..682703f --- /dev/null +++ b/dev/nested.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Startet eine Test-GNOME-Shell mit der Extension. +# dev/nested.sh Fenster auf dem Desktop (zum Selbst-Ausprobieren) +# dev/nested.sh headless unsichtbar; echte Maus/Tastatur lassen sich gefahrlos simulieren +# Eigene Settings (keyfile) und eigenes Datenverzeichnis, die echte Session bleibt unberührt. +set -e +ROOT=$(cd "$(dirname "$0")/.." && pwd) +SCRATCH=${SCRATCH:-/tmp/desktop-app-nested} +mkdir -p "$SCRATCH/data/gnome-shell/extensions" "$SCRATCH/config/glib-2.0/settings" +ln -sfn "$ROOT/desktop@marha.de" "$SCRATCH/data/gnome-shell/extensions/desktop@marha.de" +ln -sfn "$ROOT/dev/unsafe@dev" "$SCRATCH/data/gnome-shell/extensions/unsafe@dev" +cat > "$SCRATCH/config/glib-2.0/settings/keyfile" < '$SCRATCH/bus'; echo \$\$ > '$SCRATCH/pid'; exec gnome-shell $ARGS" diff --git a/dev/press.sh b/dev/press.sh new file mode 100755 index 0000000..8615196 --- /dev/null +++ b/dev/press.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Drückt den ersten sichtbaren St.Button mit diesem Text (in der Extension oder in Dialogen): dev/press.sh "Text" +DIR=$(cd "$(dirname "$0")" && pwd) +"$DIR/eval.sh" " +const St = imports.gi.St; +const wanted = $(printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'); +const text = a => a.label ?? a.child?.text ?? a.get_child?.()?.text ?? ''; +function find(actor) { + if (!actor.visible) return null; + if (actor instanceof St.Button && text(actor) === wanted) return actor; + for (const c of actor.get_children()) { const r = find(c); if (r) return r; } + return null; +} +const root = Main.extensionManager.lookup('desktop@marha.de').stateObj._root; +const b = find(Main.layoutManager.modalDialogGroup) ?? find(root); +if (!b) throw new Error('kein Button: ' + wanted); +const [x, y] = b.get_transformed_position(); +b.emit('clicked', 1); +'pressed ' + wanted + ' @ ' + Math.round(x + b.width / 2) + ',' + Math.round(y + b.height / 2); +" diff --git a/dev/restart.sh b/dev/restart.sh new file mode 100755 index 0000000..017017e --- /dev/null +++ b/dev/restart.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Test-Shell neu starten und Fehler der Extension zeigen: dev/restart.sh [headless] +DIR=$(cd "$(dirname "$0")" && pwd) +"$DIR/stop.sh" +pgrep -f "^gnome-shell --(nested|headless)" | xargs -r kill 2>/dev/null +rm -rf /tmp/desktop-app-nested/data/desktop-app +sleep 2 +(nohup "$DIR/nested.sh" "$1" > /tmp/desktop-app-nested.log 2>&1 &) +sleep 9 +grep -n "desktop@marha\|JS ERROR" /tmp/desktop-app-nested.log | head -30 +# Headless: Overview weg, virtuelle Eingabegeräte anlegen und vorwärmen (erste Events gehen sonst verloren) +if [ "$1" = headless ]; then + "$DIR/eval.sh" 'Main.overview.hide(); "overview hidden"' >/dev/null + "$DIR/input.sh" move 960 540; sleep 0.5; "$DIR/input.sh" move 961 541; sleep 0.5 +fi +"$DIR/eval.sh" 'JSON.stringify([...Main.extensionManager._extensions].map(([k,v]) => [k, v.state, v.error]))' diff --git a/dev/seed.js b/dev/seed.js new file mode 100644 index 0000000..d60421b --- /dev/null +++ b/dev/seed.js @@ -0,0 +1,43 @@ +const ext = Main.extensionManager.lookup("desktop@marha.de").stateObj; +const s = ext._store; +s.saveEvent({title: "Zahnarzt", date: "2026-09-17", start: "09:30", end: "10:30"}); +s.saveEvent({title: "Team-Meeting", date: "2026-09-17", start: "14:00", end: "15:00"}); +s.saveEvent({title: "Geburtstag Anna", date: "2026-09-19", start: null, end: null}); +s.saveEvent({title: "Sehr langer Termintitel der abgeschnitten wird", date: "2026-09-17", start: "16:00", end: null}); +s.saveEvent({title: "Messe", date: "2026-09-15", start: "14:00", end: "11:00", endDate: "2026-09-16"}); +s.saveTodo({title: "Einkaufen", date: null, time: null}); +s.saveTodo({title: "Steuer abgeben", date: "2026-09-15", time: null}); +s.saveTodo({title: "Anruf Bank", date: "2026-09-17", time: "11:00"}); +s.saveTodo({title: "Schon erledigt", date: null, time: null}); +s.toggleTodo(s.todos[3].id); +GLib.mkdir_with_parents(s.notesDir + "/Projekte/Desktop-App", 0o755); +GLib.mkdir_with_parents(s.notesDir + "/Privat", 0o755); +GLib.file_set_contents(s.notesDir + "/Projekte/Desktop-App/Plan.md", `# Plan + +Eine **fette** und *kursive* Zeile mit \`code\`. + +## Schritte + +- [x] Skelett +- [ ] Kalender + - Monat + - Woche +- [ ] Sync per [FastAPI](http://localhost:8000) + +> Zitat: Alle hassen Chaos + +\`\`\` +curl -X POST /api/events +\`\`\` + +--- + +1. eins +2. zwei + +Siehe [[Ideen]]`); +GLib.file_set_contents(s.notesDir + "/Projekte/Ideen.md", "# Ideen\n\nNoch leer."); +GLib.file_set_contents(s.notesDir + "/Privat/Einkauf.md", "- Milch\n- Brot"); +GLib.file_set_contents(s.notesDir + "/Allgemein.md", "Lose Notiz ohne Ordner."); +ext._root.get_child_at_index(0)._refresh(); +`seeded ${s.events.length} events, ${s.todos.length} todos`; diff --git a/dev/shot.sh b/dev/shot.sh new file mode 100755 index 0000000..9b23d14 --- /dev/null +++ b/dev/shot.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Screenshot der nested Shell: dev/shot.sh [datei.png] +SCRATCH=${SCRATCH:-/tmp/desktop-app-nested} +OUT=${1:-$SCRATCH/shot.png} +export DBUS_SESSION_BUS_ADDRESS=$(cat "$SCRATCH/bus") +gdbus call --session --dest org.gnome.Shell.Screenshot --object-path /org/gnome/Shell/Screenshot \ + --method org.gnome.Shell.Screenshot.Screenshot false false "$OUT" >/dev/null && echo "$OUT" diff --git a/dev/stop.sh b/dev/stop.sh new file mode 100755 index 0000000..e9fb185 --- /dev/null +++ b/dev/stop.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Beendet die Test-Shell. +SCRATCH=${SCRATCH:-/tmp/desktop-app-nested} +[ -f "$SCRATCH/pid" ] && kill "$(cat "$SCRATCH/pid")" 2>/dev/null +rm -f "$SCRATCH/pid" "$SCRATCH/bus" diff --git a/dev/tests/input.sh b/dev/tests/input.sh new file mode 100755 index 0000000..10bd040 --- /dev/null +++ b/dev/tests/input.sh @@ -0,0 +1,262 @@ +#!/bin/bash +# Ablauf-Tests mit echten Maus- und Tastatureingaben in der headless Shell: dev/tests/input.sh +# Startet die Shell neu, spielt Testdaten ein und klickt/tippt sich durch alle Funktionen. +DIR=$(cd "$(dirname "$0")/.." && pwd) +E="$DIR/eval.sh" +IN="$DIR/input.sh" +OUT=/tmp/desktop-app-nested/out.txt +SHOTS=/tmp/desktop-app-nested/shots +mkdir -p "$SHOTS" +FAILED=0 + +PRE='const Clutter = imports.gi.Clutter; const St = imports.gi.St; +const ext = Main.extensionManager.lookup("desktop@marha.de").stateObj; +const notes = ext._root.get_child_at_index(0); +const cal = ext._root.get_child_at_index(1).get_child_at_index(0); +const todos = ext._root.get_child_at_index(1).get_child_at_index(1); +const store = ext._store; +const grabbed = () => global.stage.get_grab_actor(); +const center = a => { const [x, y] = a.get_transformed_position(); const [w, h] = a.get_transformed_size(); return [Math.round(x + w / 2), Math.round(y + h / 2)]; }; +const find = (root, text) => { let found = null; const walk = a => { if (found || !a.visible) return; if (a instanceof St.Button && (a.label ?? a.child?.text ?? a.label_actor?.text) === text) found = a; a.get_children().forEach(walk); }; if (root) walk(root); return found; }; +const entries = a => { const out = []; const walk = n => { if (n instanceof St.Entry) out.push(n.get_text()); n.get_children().forEach(walk); }; if (a) walk(a); return out; }; +const labels = a => { const out = []; const walk = n => { if (n instanceof St.Label && n.text) out.push(n.text); n.get_children().forEach(walk); }; if (a) walk(a); return out; }; +const read = p => new TextDecoder().decode(GLib.file_get_contents(p)[1]); +const bottom = a => { const [x, y] = a.get_transformed_position(); const [w, h] = a.get_transformed_size(); return [Math.round(x + w / 2), Math.round(y + h - 24)]; }; +const today = GLib.DateTime.new_now_local().format("%Y-%m-%d"); +' + +# JS ausführen, Ergebnis als Text zurückgeben +val() { + "$E" "$PRE GLib.file_set_contents('$OUT', String($1)); 'w'" | grep -v "^OK '\"w\"')$" + cat "$OUT" +} +# Mittelpunkt eines Actors: xy 'ausdruck' → "x y" +xy() { + val "center($1).join(' ')" +} +click() { $IN click $(xy "$1"); sleep 0.3; } +rclick() { $IN rclick $(xy "$1"); sleep 0.3; } +rclickb() { $IN rclick $(val "bottom($1).join(' ')"); sleep 0.3; } +type_() { $IN type "$1"; sleep 0.2; } +key() { $IN key "$1"; sleep 0.3; } +shot() { "$DIR/shot.sh" "$SHOTS/$1.png" >/dev/null; } +check() { + local name=$1 actual=$2 expected=$3 + if [ "$actual" == "$expected" ]; then + echo "ok $name" + else + echo "FAIL $name"; echo " ist: $actual"; echo " soll: $expected" + FAILED=$((FAILED + 1)) + fi +} + +echo "=== Start" +"$DIR/restart.sh" headless >/dev/null +"$E" "$DIR/seed.js" >/dev/null +check "Extension aktiv" "$(val 'Main.extensionManager.lookup("desktop@marha.de").state')" "1" +check "Notizen halbe Bildschirmbreite" "$(val 'notes.width + " " + Main.layoutManager.primaryMonitor.width / 2')" "960 960" +check "Rechte Spalte füllt den Rest, Abstand wie außen" "$(val '(r => [r.x - (notes.x + notes.width), ext._root.width - (r.x + r.width)].join(" "))(ext._root.get_child_at_index(1))')" "24 0" + +echo "=== Woche: Termin per Drag" +click 'cal._viewButtons.week' +check "Wochenansicht" "$(val 'cal._view')" "week" +$IN drag $(xy 'cal._slots[4][10]') $(xy 'cal._slots[4][12]'); sleep 0.3 +check "Popover offen" "$(val 'grabbed()?.style_class')" "desktop-popover form" +check "Zeitraum im Popover" "$(val '["Fr, 18.09.2026 ▾", "10:00 ▾", "13:00 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja" +check "Auswahl markiert" "$(val 'cal._slots[4].map((s, h) => s.has_style_class_name("selecting") ? h : null).filter(h => h !== null).join(",")')" "10,11,12" +type_ "Sport" +shot "01-drag-popover" +key Return +check "Termin gespeichert" "$(val 'JSON.stringify((({title, date, start, end, endDate}) => ({title, date, start, end, endDate}))(store.events.find(e => e.title === "Sport")))')" \ + '{"title":"Sport","date":"2026-09-18","start":"10:00","end":"13:00","endDate":null}' +check "Popover zu, Markierung weg" "$(val '[grabbed() ? "grab" : "frei", cal._slots[4].filter(s => s.has_style_class_name("selecting")).length].join(" ")')" "frei 0" +check "Ein Block über drei Stunden" "$(val '(b => b.length + " " + Math.round(b[0].block.height / cal._rowPitch()))(cal._blocks.filter(b => b.event.title === "Sport"))')" "1 3" +shot "02-week" + +echo "=== Woche: Termin bearbeiten, Escape, Löschen per Rechtsklick" +click 'cal._blocks.find(b => b.event.title === "Sport").block' +check "Bearbeiten-Popover zeigt Titel" "$(val 'entries(grabbed())[0]')" "Sport" +key Escape +check "Escape schließt" "$(val 'grabbed() ? "grab" : "frei"')" "frei" +rclick 'cal._blocks.find(b => b.event.title === "Sport").block' +check "Kontextmenü offen" "$(val 'labels(grabbed()).join("|")')" "Löschen" +shot "03-context-menu" +click 'find(grabbed(), "Löschen")' +check "Termin gelöscht" "$(val 'store.events.filter(e => e.title === "Sport").length')" "0" + +echo "=== Woche: ganztägiger Termin, Klick daneben schließt" +click 'cal._alldaySlots[6]' +check "Ganztags-Popover" "$(val '["So, 20.09.2026 ▾", "Keine Uhrzeit ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja" +type_ "Frei"; key Return +check "Ganztags gespeichert" "$(val 'JSON.stringify(store.events.filter(e => e.title === "Frei").map(e => [e.date, e.start]))')" '[["2026-09-20",null]]' +$IN drag $(xy 'cal._slots[1][9]') $(xy 'cal._slots[1][9]'); sleep 0.3 +check "Klick ohne Ziehen = eine Stunde" "$(val '["Di, 15.09.2026 ▾", "09:00 ▾", "10:00 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja" +click 'todos.get_child_at_index(0).get_child_at_index(0)' +check "Klick daneben schließt" "$(val '[grabbed() ? "grab" : "frei", Main.modalCount].join(" ")')" "frei 0" + +echo "=== Todos: Popover mit Datum- und Zeitauswahl" +rclickb 'todos' +click 'find(grabbed(), "Erstellen")' +check "Todo-Popover" "$(val 'labels(grabbed())[0]')" "Neues Todo" +type_ "Milch kaufen" +click 'find(grabbed(), "Kein Datum ▾")' +check "Datumsauswahl offen" "$(val 'grabbed()?.style_class')" "desktop-popover menu datepicker" +shot "04-datepicker" +click 'find(grabbed(), "20")' +check "Datum übernommen" "$(val 'find(grabbed(), "So, 20.09.2026 ▾") ? "ja" : "nein"')" "ja" +click 'find(grabbed(), "Keine Uhrzeit ▾")' +check "Zeitauswahl offen" "$(val 'grabbed()?.style_class')" "desktop-popover menu timepicker" +click 'find(grabbed(), "09:30")' +check "Uhrzeit übernommen" "$(val 'find(grabbed(), "09:30 ▾") ? "ja" : "nein"')" "ja" +shot "05-todo-popover" +check "Fokus nach Auswahl wieder im Textfeld" "$(val 'global.stage.get_key_focus() === entries(grabbed()) && "x" || (grabbed().contains(global.stage.get_key_focus()) && global.stage.get_key_focus().constructor.name)')" "Clutter_Text" +key Return +check "Todo gespeichert" "$(val 'JSON.stringify((({title, date, time, done}) => ({title, date, time, done}))(store.todos.find(t => t.title === "Milch kaufen")))')" \ + '{"title":"Milch kaufen","date":"2026-09-20","time":"09:30","done":false}' +click 'find(todos, "Milch kaufen")' +check "Todo bearbeiten zeigt Text" "$(val 'entries(grabbed())[0]')" "Milch kaufen" +key Escape +rclick 'find(todos, "Milch kaufen")' +click 'find(grabbed(), "Löschen")' +check "Todo gelöscht" "$(val 'store.todos.filter(t => t.title === "Milch kaufen").length')" "0" +rclickb 'todos' +check "Rechtsklick Todo-Fläche" "$(val 'labels(grabbed()).join("|")')" "Erstellen" +click 'find(grabbed(), "Erstellen")' +key Return +check "Leeres Todo abgelehnt" "$(val 'labels(grabbed()).includes("Text fehlt") ? "Fehler gezeigt" : "kein Fehler"')" "Fehler gezeigt" +key Escape + +echo "=== Notizen: anlegen, direkt bearbeiten, umbenennen, löschen" +rclickb 'notes.get_child_at_index(0)' +check "Rechtsklick im Baum" "$(val 'labels(grabbed()).join("|")')" "Notiz erstellen|Ordner erstellen" +click 'find(grabbed(), "Notiz erstellen")' +type_ "Testnotiz"; key Return +check "Notiz-Datei angelegt" "$(val 'GLib.file_test(store.notesDir + "/Testnotiz.md", GLib.FileTest.EXISTS)')" "true" +check "Editor offen mit Fokus" "$(val '[notes._editing, notes._editScroll.visible, global.stage.get_key_focus() === notes._editor.clutter_text].join(" ")')" "true true true" +type_ "# Hallo"; key Return; key Return; type_ "Erste **Notiz**" +shot "06-inline-editor" +sleep 4 +check "Autosave nach ein paar Sekunden" "$(val '[notes._editing, JSON.stringify(read(store.notesDir + "/Testnotiz.md"))].join(" ")')" 'true "# Hallo\n\nErste **Notiz**"' +click 'cal._viewButtons.month' +check "Fokusverlust beendet den Editor, Anzeige gerendert" "$(val '[notes._editing, notes._viewScroll.visible, notes._content.text].join("|")')" "false|true|Hallo + +Erste Notiz" +click 'notes._viewScroll' +check "Klick in die Fläche startet den Editor" "$(val '[notes._editing, global.stage.get_key_focus() === notes._editor.clutter_text].join(" ")')" "true true" +type_ " mehr" +click 'find(notes, "Allgemein")' +check "Wechsel speichert sofort" "$(val 'read(store.notesDir + "/Testnotiz.md").endsWith("**Notiz** mehr") + " " + notes._selected.name')" "true Allgemein" +rclick 'find(notes, "Testnotiz")' +check "Kontextmenü Notiz" "$(val 'labels(grabbed()).join("|")')" "Umbenennen|Löschen" +click 'find(grabbed(), "Umbenennen")' +type_ "Umbenannt"; key Return +check "Notiz umbenannt" "$(val '[GLib.file_test(store.notesDir + "/Testnotiz.md", GLib.FileTest.EXISTS), GLib.file_test(store.notesDir + "/Umbenannt.md", GLib.FileTest.EXISTS)].join(" ")')" "false true" +rclick 'find(notes, "▸ Projekte")' +check "Voller Ordner nicht löschbar" "$(val '(b => b ? String(b.reactive) : "fehlt")(find(grabbed(), "Löschen (Ordner ist nicht leer)"))')" "false" +key Escape +rclickb 'notes.get_child_at_index(0)' +click 'find(grabbed(), "Ordner erstellen")' +type_ "Leer"; key Return +check "Ordner angelegt und gewählt" "$(val '[GLib.file_test(store.notesDir + "/Leer", GLib.FileTest.IS_DIR), notes._selected?.name].join(" ")')" "true Leer" +rclick 'find(notes, "▾ Leer")' +click 'find(grabbed(), "Löschen")' +check "Leerer Ordner gelöscht" "$(val 'GLib.file_test(store.notesDir + "/Leer", GLib.FileTest.EXISTS)')" "false" +rclick 'find(notes, "Umbenannt")' +click 'find(grabbed(), "Löschen")' +check "Notiz gelöscht" "$(val '[GLib.file_test(store.notesDir + "/Umbenannt.md", GLib.FileTest.EXISTS), JSON.stringify(notes._noteTitle.text)].join(" ")')" 'false ""' +check "Trennlinie zwischen Ordnern und Notizen" "$(val 'notes._tree.get_children().map(c => c.style_class).join("|")')" "tree-row folder|tree-row folder|tree-separator|tree-row note" + +rclickb 'notes.get_child_at_index(0)' +check "Rechtsklick Notiz-Fläche" "$(val 'labels(grabbed()).join("|")')" "Notiz erstellen|Ordner erstellen" +click 'find(grabbed(), "Notiz erstellen")' +type_ "Wurzel"; key Return +check "Notiz im Wurzelordner, Editor offen" "$(val '[GLib.file_test(store.notesDir + "/Wurzel.md", GLib.FileTest.EXISTS), notes._editing].join(" ")')" "true true" +click 'cal._viewButtons.month' +rclickb 'notes' +check "Rechtsklick im Inhaltsbereich" "$(val 'labels(grabbed()).join("|")')" "Notiz erstellen|Ordner erstellen" +key Escape +rclick 'find(notes, "▸ Privat")' +check "Ordnermenü" "$(val 'labels(grabbed()).join("|")')" "Notiz erstellen|Ordner erstellen|Umbenennen|Löschen (Ordner ist nicht leer)" +click 'find(grabbed(), "Ordner erstellen")' +type_ "Sub"; key Return +check "Unterordner im Ordner" "$(val '[GLib.file_test(store.notesDir + "/Privat/Sub", GLib.FileTest.IS_DIR), notes._selected?.name].join(" ")')" "true Sub" +rclick 'find(notes, "▾ Privat")' +click 'find(grabbed(), "Notiz erstellen")' +type_ "InPrivat"; key Return +check "Notiz im Ordner" "$(val 'GLib.file_test(store.notesDir + "/Privat/InPrivat.md", GLib.FileTest.EXISTS)')" "true" +click 'find(notes, "▾ Privat")' +check "Ordner gewählt zeigt nichts" "$(val '[notes._selected.type, JSON.stringify(notes._noteTitle.text), JSON.stringify(notes._content.text)].join(" ")')" 'folder "" ""' + +echo "=== Woche: Rechtsklick, Termin ohne Ende, mehrtägig" +click 'cal._viewButtons.week' +rclick 'cal._slots[1][9]' +check "Rechtsklick Stundenzelle" "$(val 'labels(grabbed()).join("|")')" "Termin erstellen|Todo erstellen" +click 'find(grabbed(), "Todo erstellen")' +check "Todo mit Tag und Uhrzeit vorbelegt" "$(val '["Di, 15.09.2026 ▾", "09:00 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja" +key Escape +rclick 'cal._slots[1][9]' +click 'find(grabbed(), "Termin erstellen")' +check "Termin mit Tag und Startzeit vorbelegt" "$(val '["Di, 15.09.2026 ▾", "09:00 ▾", "Keine Uhrzeit ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja" +type_ "Kurz"; key Return +check "Ohne Ende gilt eine Stunde" "$(val 'JSON.stringify((({start, end}) => [start, end])(store.events.find(e => e.title === "Kurz")))')" '["09:00","10:00"]' +check "Mehrtägiger Termin als zwei Blöcke" "$(val 'JSON.stringify(cal._blocks.filter(b => b.event.title === "Messe").map(b => [b.dayIndex, b.from, b.to]))')" "[[1,840,1440],[2,0,660]]" +$IN drag $(xy 'cal._slots[5][12]') $(xy 'cal._slots[6][12]'); sleep 0.3 +check "Ziehen über zwei Tage markiert" "$(val '[cal._slots[5].filter(s => s.has_style_class_name("selecting")).length, cal._slots[6].filter(s => s.has_style_class_name("selecting")).length].join(" ")')" "12 13" +check "Mehrtägig vorbelegt" "$(val '["Sa, 19.09.2026 ▾", "12:00 ▾", "So, 20.09.2026 ▾", "13:00 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja ja" +shot "08-multiday-drag" +type_ "Lang"; key Return +check "Mehrtägig gespeichert" "$(val 'JSON.stringify((({date, start, endDate, end}) => [date, start, endDate, end])(store.events.find(e => e.title === "Lang")))')" '["2026-09-19","12:00","2026-09-20","13:00"]' +check "Mehrtägig als zwei Blöcke" "$(val 'JSON.stringify(cal._blocks.filter(b => b.event.title === "Lang").map(b => [b.dayIndex, b.from, b.to]))')" "[[5,720,1440],[6,0,780]]" +$IN drag $(xy 'cal._alldaySlots[2]') $(xy 'cal._alldaySlots[4]'); sleep 0.3 +check "Ganztags über drei Tage markiert" "$(val 'cal._alldaySlots.map((s, d) => s.has_style_class_name("selecting") ? d : null).filter(d => d !== null).join(",")')" "2,3,4" +check "Ganztags mehrtägig vorbelegt" "$(val '["Mi, 16.09.2026 ▾", "Keine Uhrzeit ▾", "Fr, 18.09.2026 ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja ja" +type_ "Urlaub"; key Return +check "Ganztags mehrtägig gespeichert" "$(val 'JSON.stringify((({date, start, endDate}) => [date, start, endDate])(store.events.find(e => e.title === "Urlaub")))')" '["2026-09-16",null,"2026-09-18"]' +check "Ganztags als ein Block über drei Tage" "$(val '(b => b.length + " " + b[0].from + "-" + b[0].to + " " + Math.round(b[0].block.width / (cal._alldaySlots[1].x - cal._alldaySlots[0].x)))(cal._alldayBlocks.filter(b => b.event.title === "Urlaub"))')" "1 2-4 3" +check "Ganztags-Zellen bündig mit Stundenzellen" "$(val '[0, 3, 6].map(d => Math.round(cal._alldaySlots[d].get_transformed_position()[0] - cal._slots[d][9].get_transformed_position()[0]) + "/" + Math.round(cal._alldaySlots[d].width - cal._slots[d][9].width)).join(" ")')" "0/0 0/0 0/0" +check "Ganztags-Block bündig mit der Spalte" "$(val '(b => Math.abs(b.block.x - cal._alldaySlots[2].x) <= 1)(cal._alldayBlocks.find(b => b.event.title === "Urlaub"))')" "true" +check "Zwei Ganztags-Termine untereinander" "$(val 'JSON.stringify(cal._alldayBlocks.filter(b => ["Urlaub", "Frei"].includes(b.event.title)).map(b => b.event.title + ":" + b.lane))')" '["Urlaub:0","Frei:0"]' +shot "09-allday-blocks" +click 'cal._blocks.find(b => b.event.title === "Kurz").block' +click 'find(grabbed(), "09:00 ▾")' +click 'find(grabbed(), "11:00")' +check "Fokus nach Zeitauswahl im Titelfeld" "$(val 'global.stage.get_key_focus()?.constructor.name + " " + grabbed().contains(global.stage.get_key_focus())')" "Clutter_Text true" +key Return +check "Ende vor Anfang abgelehnt" "$(val 'labels(grabbed()).includes("Ende liegt vor dem Anfang") ? "Fehler gezeigt" : "kein Fehler"')" "Fehler gezeigt" +key Escape + +echo "=== Monat und Tag" +click 'cal._viewButtons.month' +check "Monat: Uhrzeit nur bei eintägigen Terminen" "$(val '(c => [c.get_child_at_index(17), c.get_child_at_index(19)].map(cell => cell.get_child().get_child_at_index(2).label_actor.text).join("|"))(cal._body.get_child().get_child_at_index(1))')" "09:30 Zahnarzt|Lang" +rclick 'cal._body.get_child().get_child_at_index(1).get_child_at_index(21)' +check "Rechtsklick Monatszelle" "$(val 'labels(grabbed()).join("|")')" "Termin erstellen|Todo erstellen" +click 'find(grabbed(), "Termin erstellen")' +check "Tag vorbelegt" "$(val '["Mo, 21.09.2026 ▾", "Keine Uhrzeit ▾"].map(t => find(grabbed(), t) ? "ja" : "nein").join(" ")')" "ja ja" +key Escape +click 'cal._viewButtons.month' +click 'cal._body.get_child().get_child_at_index(1).get_child_at_index(17).get_child().get_child_at_index(2)' +check "Monatseintrag öffnet Popover" "$(val 'entries(grabbed())[0]')" "Zahnarzt" +key Escape +click 'cal._body.get_child().get_child_at_index(1).get_child_at_index(17).get_child().get_child_at_index(0)' +check "Tagesansicht" "$(val 'cal._view + " " + cal._cursor')" "day 2026-09-17" +check "Keine Buttons in der Tagesansicht" "$(val '[find(cal, "+ Todo"), find(cal, "+ Termin"), find(cal, "Heute")].filter(Boolean).length')" "0" +rclickb 'cal._body.get_child()' +click 'find(grabbed(), "Todo erstellen")' +check "Todo aus Tagesansicht hat Datum" "$(val 'find(grabbed(), "Do, 17.09.2026 ▾") ? "ja" : "nein"')" "ja" +key Escape +rclickb 'cal._body.get_child()' +check "Rechtsklick Tag-Fläche" "$(val 'labels(grabbed()).join("|")')" "Termin erstellen|Todo erstellen" +click 'find(grabbed(), "Termin erstellen")' +check "Termin erstellen für den Tag" "$(val 'labels(grabbed())[0] + " " + (find(grabbed(), "Do, 17.09.2026 ▾") ? "ja" : "nein")')" "Neuer Termin ja" +key Escape +shot "07-day" +click 'cal._viewButtons.month' +click 'cal._title.get_parent().get_child_at_index(2)' +check "Pfeil rechts blättert" "$(val 'cal._title.text')" "Oktober 2026" +check "Pfeil rechts direkt am Titel" "$(val '(n => n.x - (cal._title.x + cal._title.width) < 20)(cal._title.get_parent().get_child_at_index(2))')" "true" +click 'cal._viewButtons.day' +check "Tag-Button nutzt Cursor" "$(val 'cal._view')" "day" + +check "Keine JS-Fehler" "$(grep -c 'JS ERROR' /tmp/desktop-app-nested.log)" "0" +echo +if [ $FAILED -eq 0 ]; then echo "alle ok"; else echo "$FAILED FEHLER"; grep -n "JS ERROR" -A5 /tmp/desktop-app-nested.log | head -40; fi diff --git a/dev/tests/run.js b/dev/tests/run.js new file mode 100644 index 0000000..79e99b1 --- /dev/null +++ b/dev/tests/run.js @@ -0,0 +1,66 @@ +// Tests der reinen Module: gjs -m dev/tests/run.js +import * as D from '../../desktop@marha.de/dates.js'; +import {renderMarkdown} from '../../desktop@marha.de/markdown.js'; + +let failed = 0; +function eq(actual, expected, name) { + const a = JSON.stringify(actual), e = JSON.stringify(expected); + if (a === e) + print(`ok ${name}`); + else { + failed++; + print(`FAIL ${name}\n got ${a}\n expected ${e}`); + } +} + +eq(D.toDateString(new Date(2026, 8, 17)), '2026-09-17', 'toDateString'); +eq(D.addDays('2026-09-30', 1), '2026-10-01', 'addDays über Monatsgrenze'); +eq(D.addDays('2026-01-01', -1), '2025-12-31', 'addDays rückwärts über Jahr'); +eq(D.startOfWeek('2026-09-17'), '2026-09-14', 'startOfWeek Do → Mo'); +eq(D.startOfWeek('2026-09-20'), '2026-09-14', 'startOfWeek So → Mo'); +eq(D.startOfWeek('2026-09-14'), '2026-09-14', 'startOfWeek Mo bleibt'); +eq(D.weekDays('2026-09-17').length, 7, 'weekDays 7 Tage'); +const grid = D.monthGrid(2026, 8); +eq(grid[0], '2026-08-31', 'monthGrid startet Montag davor'); +eq(grid.length, 42, 'monthGrid 42 Zellen'); +eq(D.monthGrid(2026, 5)[0], '2026-06-01', 'monthGrid Monat beginnt Montag'); +eq(D.isoWeek('2026-09-17'), 38, 'isoWeek'); +eq(D.isoWeek('2027-01-01'), 53, 'isoWeek Jahreswechsel'); +eq(D.formatMonth(2026, 8), 'September 2026', 'formatMonth'); +eq(D.formatDate('2026-09-17'), 'Do, 17.09.2026', 'formatDate'); +eq(D.formatDayMonth('2026-09-17'), '17.09.', 'formatDayMonth'); +eq(D.parseDateInput('17.09.2026'), '2026-09-17', 'parseDateInput dd.mm.yyyy'); +eq(D.parseDateInput('1.9.26'), '2026-09-01', 'parseDateInput kurz'); +eq(D.parseDateInput('2026-09-17'), '2026-09-17', 'parseDateInput iso'); +eq(D.parseDateInput('31.02.2026'), null, 'parseDateInput ungültig'); +eq(D.parseDateInput('abc'), null, 'parseDateInput Müll'); +eq(D.parseTimeInput('9:05'), '09:05', 'parseTimeInput h:mm'); +eq(D.parseTimeInput('14'), '14:00', 'parseTimeInput nur Stunde'); +eq(D.parseTimeInput('9.30'), '09:30', 'parseTimeInput Punkt'); +eq(D.parseTimeInput('25:00'), null, 'parseTimeInput ungültig'); +eq(D.timeToMinutes('01:30'), 90, 'timeToMinutes'); +eq(D.minutesToTime(90), '01:30', 'minutesToTime'); + +eq(renderMarkdown('# Titel'), 'Titel', 'md h1'); +eq(renderMarkdown('## A **b** c'), 'A b c', 'md h2 bold'); +eq(renderMarkdown('a *kursiv* b'), 'a kursiv b', 'md italic'); +eq(renderMarkdown('snake_case_name'), 'snake_case_name', 'md underscore im Wort bleibt'); +eq(renderMarkdown('2 * 3 * 4'), '2 * 3 * 4', 'md einzelne Sterne bleiben'); +eq(renderMarkdown('- eins\n - zwei'), ' • eins\n • zwei', 'md verschachtelte Liste'); +eq(renderMarkdown('1. a\n2. b'), ' 1. a\n 2. b', 'md nummeriert'); +eq(renderMarkdown('- [ ] offen\n- [x] fertig'), + ' ☐ offen\n ☑ fertig', 'md checkbox'); +eq(renderMarkdown('a `x < y` b'), 'a x < y b', 'md code escaped'); +eq(renderMarkdown('`**nicht fett**`'), '**nicht fett**', 'md code schützt Sterne'); +eq(renderMarkdown('```\nlet a = 1 & 2;\n```'), 'let a = 1 & 2;', 'md codeblock'); +eq(renderMarkdown('[Link](http://x)'), 'Link', 'md link'); +eq(renderMarkdown('[[Wiki]]'), 'Wiki', 'md wikilink'); +eq(renderMarkdown('![Bild](a.png)'), '[Bild: Bild]', 'md image'); +eq(renderMarkdown('> zitat'), '▎ zitat', 'md quote'); +eq(renderMarkdown('---'), '────────────────────────────', 'md hr'); +eq(renderMarkdown('~~weg~~'), 'weg', 'md strike'); +eq(renderMarkdown('a\n\nb'), 'a\n\nb', 'md leere Zeile bleibt'); + +print(failed ? `\n${failed} FEHLER` : '\nalle Tests ok'); +if (failed) + imports.system.exit(1); diff --git a/dev/unsafe@dev/extension.js b/dev/unsafe@dev/extension.js new file mode 100644 index 0000000..25c0fff --- /dev/null +++ b/dev/unsafe@dev/extension.js @@ -0,0 +1,11 @@ +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +export default class UnsafeExtension extends Extension { + enable() { + global.context.unsafe_mode = true; + } + + disable() { + global.context.unsafe_mode = false; + } +} diff --git a/dev/unsafe@dev/metadata.json b/dev/unsafe@dev/metadata.json new file mode 100644 index 0000000..cb7f8ca --- /dev/null +++ b/dev/unsafe@dev/metadata.json @@ -0,0 +1,6 @@ +{ + "uuid": "unsafe@dev", + "name": "Dev Unsafe Mode", + "description": "Nur für die nested Test-Shell: erlaubt Eval und Screenshots per D-Bus", + "shell-version": ["45", "46", "47", "48", "49"] +}