init
This commit is contained in:
37
Makefile
Normal file
37
Makefile
Normal file
@@ -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)"
|
||||
72
README.md
Normal file
72
README.md
Normal file
@@ -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.
|
||||
609
desktop@marha.de/calendar.js
Normal file
609
desktop@marha.de/calendar.js
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
128
desktop@marha.de/dates.js
Normal file
128
desktop@marha.de/dates.js
Normal file
@@ -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]);
|
||||
}
|
||||
68
desktop@marha.de/extension.js
Normal file
68
desktop@marha.de/extension.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
79
desktop@marha.de/markdown.js
Normal file
79
desktop@marha.de/markdown.js
Normal file
@@ -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, '<').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(`<span font_family="monospace" background="${CODE_BG}">${code}</span>`);
|
||||
return `${HOLE}${codes.length - 1}${HOLE}`;
|
||||
});
|
||||
out = out
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '[Bild: $1]')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, `<u><span foreground="${LINK_COLOR}">$1</span></u>`)
|
||||
.replace(/\[\[([^\]]+)\]\]/g, `<u><span foreground="${LINK_COLOR}">$1</span></u>`)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
||||
.replace(/__(.+?)__/g, '<b>$1</b>')
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*\w])\*([^*\s](?:[^*]*[^*\s])?)\*(?!\w)/g, '$1<i>$2</i>')
|
||||
.replace(/(^|[^_\w])_([^_\s](?:[^_]*[^_\s])?)_(?!\w)/g, '$1<i>$2</i>');
|
||||
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(`<span font_family="monospace" background="${CODE_BG}">${escapeMarkup(line) || ' '}</span>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let match;
|
||||
if ((match = line.match(/^(#{1,6})\s+(.*)$/))) {
|
||||
const level = match[1].length;
|
||||
out.push(`<span size="${HEADING_SIZE[level - 1]}" weight="bold">${inline(match[2])}</span>`);
|
||||
} else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
||||
out.push('<span foreground="#666666">────────────────────────────</span>');
|
||||
} else if ((match = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/))) {
|
||||
const box = match[2] === ' ' ? '☐' : '☑';
|
||||
const body = match[2] === ' ' ? inline(match[3]) : `<span foreground="#888888"><s>${inline(match[3])}</s></span>`;
|
||||
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(`<span foreground="${QUOTE_COLOR}">▎ <i>${inline(match[1])}</i></span>`);
|
||||
} 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);
|
||||
}
|
||||
6
desktop@marha.de/metadata.json
Normal file
6
desktop@marha.de/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"uuid": "desktop@marha.de",
|
||||
"name": "Desktop",
|
||||
"description": "Kalender, Todos und Notizen direkt auf dem Desktop",
|
||||
"shell-version": ["46"]
|
||||
}
|
||||
277
desktop@marha.de/notes.js
Normal file
277
desktop@marha.de/notes.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
213
desktop@marha.de/store.js
Normal file
213
desktop@marha.de/store.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
195
desktop@marha.de/stylesheet.css
Normal file
195
desktop@marha.de/stylesheet.css
Normal file
@@ -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; }
|
||||
129
desktop@marha.de/todos.js
Normal file
129
desktop@marha.de/todos.js
Normal file
@@ -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(`<s>${escapeMarkup(todoTitle(todo))}</s>`);
|
||||
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));
|
||||
}
|
||||
});
|
||||
330
desktop@marha.de/widgets.js
Normal file
330
desktop@marha.de/widgets.js
Normal file
@@ -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;
|
||||
}
|
||||
14
dev/click.sh
Executable file
14
dev/click.sh
Executable file
@@ -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');
|
||||
"
|
||||
15
dev/enable.py
Executable file
15
dev/enable.py
Executable file
@@ -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 <uuid>
|
||||
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.')
|
||||
11
dev/eval.sh
Executable file
11
dev/eval.sh
Executable file
@@ -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 /'
|
||||
18
dev/fake-ding.py
Normal file
18
dev/fake-ding.py
Normal file
@@ -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()
|
||||
35
dev/input.sh
Executable file
35
dev/input.sh
Executable file
@@ -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\"')$"
|
||||
25
dev/nested.sh
Executable file
25
dev/nested.sh
Executable file
@@ -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" <<KEYS
|
||||
[org/gnome/shell]
|
||||
enabled-extensions=['desktop@marha.de', 'unsafe@dev']
|
||||
disable-user-extensions=false
|
||||
KEYS
|
||||
export XDG_DATA_HOME="$SCRATCH/data" XDG_CONFIG_HOME="$SCRATCH/config" GSETTINGS_BACKEND=keyfile
|
||||
export MUTTER_DEBUG_DUMMY_MODE_SPECS=${MODE:-1920x1080}
|
||||
if [ "$1" = headless ]; then
|
||||
export GNOME_SHELL_SESSION_MODE=user
|
||||
ARGS="--headless --virtual-monitor 1920x1080"
|
||||
else
|
||||
ARGS="--nested --wayland"
|
||||
fi
|
||||
exec dbus-run-session -- bash -c "echo \$DBUS_SESSION_BUS_ADDRESS > '$SCRATCH/bus'; echo \$\$ > '$SCRATCH/pid'; exec gnome-shell $ARGS"
|
||||
20
dev/press.sh
Executable file
20
dev/press.sh
Executable file
@@ -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);
|
||||
"
|
||||
16
dev/restart.sh
Executable file
16
dev/restart.sh
Executable file
@@ -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]))'
|
||||
43
dev/seed.js
Normal file
43
dev/seed.js
Normal file
@@ -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`;
|
||||
7
dev/shot.sh
Executable file
7
dev/shot.sh
Executable file
@@ -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"
|
||||
5
dev/stop.sh
Executable file
5
dev/stop.sh
Executable file
@@ -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"
|
||||
262
dev/tests/input.sh
Executable file
262
dev/tests/input.sh
Executable file
@@ -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
|
||||
66
dev/tests/run.js
Normal file
66
dev/tests/run.js
Normal file
@@ -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'), '<span size="18pt" weight="bold">Titel</span>', 'md h1');
|
||||
eq(renderMarkdown('## A **b** c'), '<span size="15pt" weight="bold">A <b>b</b> c</span>', 'md h2 bold');
|
||||
eq(renderMarkdown('a *kursiv* b'), 'a <i>kursiv</i> 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 ☑ <span foreground="#888888"><s>fertig</s></span>', 'md checkbox');
|
||||
eq(renderMarkdown('a `x < y` b'), 'a <span font_family="monospace" background="#2b2b2b">x < y</span> b', 'md code escaped');
|
||||
eq(renderMarkdown('`**nicht fett**`'), '<span font_family="monospace" background="#2b2b2b">**nicht fett**</span>', 'md code schützt Sterne');
|
||||
eq(renderMarkdown('```\nlet a = 1 & 2;\n```'), '<span font_family="monospace" background="#2b2b2b">let a = 1 & 2;</span>', 'md codeblock');
|
||||
eq(renderMarkdown('[Link](http://x)'), '<u><span foreground="#8ab4f8">Link</span></u>', 'md link');
|
||||
eq(renderMarkdown('[[Wiki]]'), '<u><span foreground="#8ab4f8">Wiki</span></u>', 'md wikilink');
|
||||
eq(renderMarkdown(''), '[Bild: Bild]', 'md image');
|
||||
eq(renderMarkdown('> zitat'), '<span foreground="#b0b0b0">▎ <i>zitat</i></span>', 'md quote');
|
||||
eq(renderMarkdown('---'), '<span foreground="#666666">────────────────────────────</span>', 'md hr');
|
||||
eq(renderMarkdown('~~weg~~'), '<s>weg</s>', '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);
|
||||
11
dev/unsafe@dev/extension.js
Normal file
11
dev/unsafe@dev/extension.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
6
dev/unsafe@dev/metadata.json
Normal file
6
dev/unsafe@dev/metadata.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user