This commit is contained in:
team3
2026-09-18 22:02:53 +02:00
commit 45a6ecf654
27 changed files with 2697 additions and 0 deletions

View 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
View 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]);
}

View 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);
}
}

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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);
}

View 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
View 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
View 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);
}
}

View 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
View 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
View 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;
}