Files
desktop-planer/desktop@marha.de/calendar.js
2026-10-09 22:51:09 +02:00

650 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 * as D from './dates.js';
import {readJson, writeJson} from './store.js';
import {editTodo, todoClick, todoRow, todoTitle} from './todos.js';
import {button, contextMenu, dropdown, openDatePicker, openTimePicker, Popover, scrollBox, textButton} from './widgets.js';
// Rote Linie für die aktuelle Uhrzeit wandert einmal pro Minute
const NOW_LINE_SECONDS = 60;
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) {
contextMenu([
{label: 'Termin erstellen', onSelect: () => editEvent(store, {date: day, start: time})},
{label: 'Todo erstellen', onSelect: () => editTodo(store, {date: day, time})},
]);
}
// Popover am Mauszeiger mit Titel sowie Von und Bis (Datum und Uhrzeit). Enter speichert.
export function editEvent(store, event) {
const popover = new Popover();
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)}]);
}
// Gilt überall, wo ein Termin steht: links bearbeiten, rechts Menü.
function eventClick(store, event, mouseButton) {
if (mouseButton === 3)
eventMenu(store, event);
else
editEvent(store, event);
}
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;
// Die zuletzt gewählte Ansicht bleibt über Neustarts erhalten
this._settingsPath = GLib.build_filenamev([store.dir, 'settings.json']);
this._view = readJson(this._settingsPath, {}).calendarView ?? 'month';
this._cursor = D.today();
this._drag = null;
this._slots = [];
this._weekScroll = null;
this._nowLineId = null;
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);
this.connect('destroy', () => this._stopNowLine());
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 [['day', 'Tag'], ['week', 'Woche'], ['month', 'Monat']]) {
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;
if (view !== this._view)
writeJson(this._settingsPath, {...readJson(this._settingsPath, {}), calendarView: view});
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;
// Scrollposition der Woche über den Neuaufbau retten, sonst springt die Ansicht nach oben
const keepScroll = this._view === 'week' && this._weekScroll ? this._weekScroll.vadjustment.value : null;
this._stopNowLine();
this._weekScroll = null;
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, keepScroll);
} 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) => eventClick(this._store, event, mouseButton), styleClass);
}
_todoEntry(todo, styleClass) {
const text = `${todo.done ? '☑' : '☐'} ${todo.time ? `${todo.time} ` : ''}${todoTitle(todo)}`;
return textButton(text, (_b, mouseButton) => todoClick(this._store, todo, mouseButton), `${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, keepScroll) {
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;
this._weekScroll = scroll;
this._nowLine = new St.Widget({style_class: 'week-now-line', visible: false});
overlay.add_child(this._nowLine);
this._todayIndex = days.indexOf(D.today());
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);
}
});
// Die rote Linie liegt über den Blöcken
overlay.set_child_above_sibling(this._nowLine, null);
rows.connect('notify::allocation', () => {
this._placeBlocks();
this._placeNowLine();
});
box.add_child(scroll);
this._startNowLine();
// Nach dem ersten Layout: alte Position wiederherstellen, sonst die aktuelle Uhrzeit in die Mitte
const adjustment = scroll.vadjustment;
const id = adjustment.connect('changed', () => {
if (adjustment.upper <= adjustment.page_size)
return;
const now = new Date();
const nowY = (now.getHours() * 60 + now.getMinutes()) / 60 * this._rowPitch();
const target = keepScroll ?? nowY - adjustment.page_size / 2;
adjustment.value = Math.max(0, Math.min(target, adjustment.upper - adjustment.page_size));
adjustment.disconnect(id);
});
return box;
}
// Rote Linie an der aktuellen Uhrzeit in der Spalte von heute.
_placeNowLine() {
if (!this._nowLine || this._todayIndex < 0) {
this._nowLine?.hide();
return;
}
const cells = this._weekRows.get_child_at_index(0).get_child_at_index(1);
const cell = cells.get_child_at_index(this._todayIndex);
const now = new Date();
const y = (now.getHours() * 60 + now.getMinutes()) / 60 * this._rowPitch();
this._nowLine.set_position(Math.round(cells.x + cell.x), Math.round(y) - 1);
this._nowLine.set_size(cell.width, 2);
this._nowLine.show();
}
_startNowLine() {
this._nowLineId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, NOW_LINE_SECONDS, () => {
this._placeNowLine();
return GLib.SOURCE_CONTINUE;
});
}
_stopNowLine() {
if (this._nowLineId) {
GLib.source_remove(this._nowLineId);
this._nowLineId = null;
}
}
// 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'}));
// Titel läuft über mehrere Zeilen, was nicht in den Block passt, wird unten abgeschnitten
const title = new St.Label({text: event.title, style_class: 'week-block-title', x_expand: true});
title.clutter_text.line_wrap = true;
title.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
title.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
inner.add_child(title);
const block = new St.Button({
style_class: 'week-block',
child: inner,
can_focus: true,
clip_to_allocation: true,
button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE,
});
block.connect('clicked', (_b, mouseButton) => eventClick(this._store, event, mouseButton));
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) => eventClick(this._store, event, mouseButton));
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];
if (!allDay) {
preset.start = `${pad(first.hour)}:00`;
preset.end = last.hour === 23 ? '23:59' : `${pad(last.hour + 1)}:00`;
}
const popover = editEvent(this._store, preset);
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) => eventClick(this._store, event, mouseButton));
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;
}
});