Files
2026-10-09 22:13:54 +02:00

347 lines
13 KiB
JavaScript
Raw Permalink 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 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;
}