347 lines
13 KiB
JavaScript
347 lines
13 KiB
JavaScript
import Clutter from 'gi://Clutter';
|
||
import GObject from 'gi://GObject';
|
||
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, 2 = Mitte, 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.TWO | 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.
|
||
// Ein mehrzeiliges Feld ist so hoch wie sein Text. Themes wie Yaru geben StEntry eine min-height.
|
||
// Die ersetzt die Mindesthöhe, der Scrollbereich richtet sich aber genau danach: er hielte das
|
||
// Feld für schrumpfbar und ließe nichts zu scrollen.
|
||
const MultilineEntry = GObject.registerClass(
|
||
class MultilineEntry extends St.Entry {
|
||
vfunc_get_preferred_height(forWidth) {
|
||
const [, natural] = super.vfunc_get_preferred_height(forWidth);
|
||
return [natural, natural];
|
||
}
|
||
});
|
||
|
||
export function multilineEntry(text, styleClass, scrollClass, hint = '') {
|
||
const entry = new MultilineEntry({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 am Mauszeiger oder 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 {
|
||
// Ohne Anker öffnet das Popover am Mauszeiger.
|
||
// below: unter dem Anker statt daneben (für Auswahllisten unter einem Button)
|
||
constructor(anchor = null, styleClass = 'form', {below = false} = {}) {
|
||
if (!anchor) {
|
||
const [x, y] = global.get_pointer();
|
||
anchor = {x, y};
|
||
}
|
||
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 menu = new Popover(null, '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;
|
||
}
|