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

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