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