645 lines
27 KiB
JavaScript
645 lines
27 KiB
JavaScript
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 {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;
|
||
this._view = '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 [['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;
|
||
|
||
// 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;
|
||
}
|
||
});
|