import { ScrollView } from "std-widgets.slint"; import { AppTheme, LayoutMetrics, ThemeUtilities } from "../design-system.slint"; import { FocusVisibleScope } from "focus-visible-scope.slint"; import { TypeScale } from "../typography.slint"; import { WindowChrome } from "../window-chrome.slint"; export component TextEdit inherits Rectangle { in-out property text; in property placeholder-text; in property enabled: true; in property read-only: false; in property text-color: AppTheme.heading; in property background-color: AppTheme.input-bg; in property font-size: TypeScale.body; callback edited(string); callback select-all; callback copy; forward-focus: editor; min-height: 80px; border-radius: LayoutMetrics.corner-radius - 2px; border-width: 1px; border-color: editor.has-focus ? AppTheme.accent : AppTheme.input-border; background: root.background-color; opacity: root.enabled ? 1 : 0.5; Text { x: 12px; y: 10px; width: parent.width - 24px; visible: root.text == ""; text: root.placeholder-text; color: AppTheme.placeholder; font-size: root.font-size; wrap: word-wrap; } // Longer text scrolls inside the field, and the caret stays in view. editor-scroll := ScrollView { x: 12px; y: 10px; width: parent.width - 24px; height: parent.height - 20px; content-width: self.visible-width; content-height: editor.height; editor := TextInput { width: editor-scroll.visible-width; height: max(editor-scroll.visible-height, self.preferred-height); text <=> root.text; color: root.text-color; selection-background-color: AppTheme.accent; selection-foreground-color: white; font-size: root.font-size; single-line: false; wrap: word-wrap; enabled: root.enabled; read-only: root.read-only; page-height: editor-scroll.visible-height; edited => { root.edited(self.text); } cursor-position-changed(cursor) => { let line = self.font-metrics.ascent - self.font-metrics.descent; if cursor.y + editor-scroll.content-y < 0px { editor-scroll.content-y = -cursor.y; } else if cursor.y + line + editor-scroll.content-y > editor-scroll.visible-height { editor-scroll.content-y = min(0px, editor-scroll.visible-height - cursor.y - line); } } } } select-all => { editor.select-all(); } copy => { editor.copy(); } } export component Switch inherits Rectangle { in-out property checked: false; in property enabled: true; in property control-label: ""; callback toggled; width: 38px; height: 22px; border-radius: 11px; background: root.checked ? AppTheme.accent : AppTheme.control-border; border-width: switch-focus.focus-visible ? 2px : 0px; border-color: AppTheme.heading; opacity: root.enabled ? 1 : 0.45; animate background { duration: 140ms; easing: ease-out; } Rectangle { x: root.checked ? parent.width - self.width - 3px : 3px; y: 3px; width: 16px; height: 16px; border-radius: 8px; background: white; animate x { duration: 180ms; easing: spring(0.18); } } switch-focus := FocusVisibleScope { width: parent.width; height: parent.height; enabled: root.enabled; focus-on-click: true; focus-on-tab-navigation: true; accessible-role: switch; accessible-label: root.control-label == "" ? (root.checked ? @tr("On") : @tr("Off")) : root.control-label; accessible-checked: root.checked; accessible-action-default => { if root.enabled { root.checked = !root.checked; root.toggled(); } } key-pressed(event) => { if root.enabled && (event.text == Key.Return || event.text == Key.Space) { root.checked = !root.checked; root.toggled(); return accept; } reject } TouchArea { x: (parent.width - max(parent.width, 44px)) / 2; y: (parent.height - 44px) / 2; width: max(parent.width, 44px); height: 44px; accessible-role: none; enabled: root.enabled; clicked => { switch-focus.focus(); switch-focus.pointer-activated(); root.checked = !root.checked; root.toggled(); } mouse-cursor: pointer; } } } // Shared tri-state control for list selection. The component owns its hit // target and accessibility contract while callers decide when it is visible. export component SelectionCheckBox inherits FocusVisibleScope { in property checked: false; in property indeterminate: false; in property avatar-overlay: false; callback toggled(bool); width: 32px; height: 32px; opacity: root.enabled ? 1 : 0.45; focus-on-click: true; focus-on-tab-navigation: true; accessible-role: checkbox; accessible-label: root.checked ? @tr("Clear selection") : @tr("Select"); accessible-checked: root.checked; accessible-action-default => { if root.enabled { root.toggled(!root.checked); } } key-pressed(event) => { if event.text == Key.Return || event.text == Key.Space { root.toggled(!root.checked); return accept; } reject } Rectangle { x: (parent.width - 24px) / 2; y: (parent.height - 24px) / 2; width: 24px; height: 24px; border-radius: root.avatar-overlay ? self.width / 2 : 6px; border-width: root.focus-visible ? 2px : 0px; border-color: AppTheme.accent; background: root.avatar-overlay ? AppTheme.surface.with-alpha(0.72) : transparent; drop-shadow-blur: root.avatar-overlay ? 7px : 0px; drop-shadow-offset-y: root.avatar-overlay ? 1px : 0px; drop-shadow-color: root.avatar-overlay ? #00000038 : transparent; Rectangle { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 18px; height: 18px; border-radius: 4px; border-width: root.checked || root.indeterminate ? 0px : 1px; border-color: AppTheme.secondary; background: root.checked || root.indeterminate ? AppTheme.accent : AppTheme.input-bg; Image { visible: root.checked || root.indeterminate; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: 12px; height: 12px; source: root.indeterminate ? @image-url("../icons/lucide/outline/dash.svg") : @image-url("../icons/lucide/outline/check.svg"); colorize: white; image-fit: contain; accessible-role: none; } } } TouchArea { x: (parent.width - max(parent.width, 44px)) / 2; y: (parent.height - max(parent.height, 44px)) / 2; width: max(parent.width, 44px); height: max(parent.height, 44px); enabled: root.enabled; clicked => { root.pointer-activated(); root.toggled(!root.checked); } mouse-cursor: pointer; } } export component RadioButton inherits Rectangle { in property text; in property checked: false; callback clicked; height: 28px; min-width: label.preferred-width + 28px; background: transparent; Rectangle { x: 1px; y: (parent.height - 16px) / 2; width: 16px; height: 16px; border-radius: 8px; border-width: 1px; border-color: root.checked ? AppTheme.accent : AppTheme.secondary; background: transparent; Rectangle { visible: root.checked; x: 4px; y: 4px; width: 8px; height: 8px; border-radius: 4px; background: AppTheme.accent; } } label := Text { x: 24px; width: parent.width - 24px; height: parent.height; text: root.text; color: AppTheme.body; font-size: TypeScale.body; vertical-alignment: center; } TouchArea { y: (parent.height - 44px) / 2; height: 44px; accessible-role: radio-button; accessible-label: root.text; accessible-checked: root.checked; clicked => { root.clicked(); } mouse-cursor: pointer; } } // A single geometry for compact toolbar actions keeps every SVG optically // centered regardless of the host platform's native Button padding. // Show help for one input modality at a time, so keyboard focus does not // overlap the tooltip beneath a stationary pointer on another button. global IconButtonInput { in-out property keyboard: false; } export component IconButton inherits Rectangle { in property icon; in property icon-color; in property idle-background: #00000000; in property hover-background: idle-background; in property active-background: idle-background; in property primary-background: AppTheme.primary; // Match Button's primary palette so icon-only and labeled actions have // the same visual weight in every color scheme. in property primary-hover-background: AppTheme.primary-hover; in property primary-pressed-background: AppTheme.primary-pressed; in property ghost: false; in property primary: false; in property active: false; in property interactive: true; out property focus-visible: keyboard.focus-visible; in property icon-size: LayoutMetrics.icon-size; in property tooltip-text: ""; in property tooltip-above: false; in property tooltip-align-left: false; callback clicked; public function pointer-activated() { keyboard.pointer-activated(); IconButtonInput.keyboard = false; } width: 32px; height: 32px; border-radius: LayoutMetrics.corner-radius - 2px; private property fill-color: root.primary ? (hit-area.pressed ? root.primary-pressed-background : hit-area.has-hover ? root.primary-hover-background : root.primary-background) : root.ghost ? (hit-area.pressed || root.active ? root.active-background : hit-area.has-hover ? root.hover-background : transparent) : root.active ? root.active-background : hit-area.has-hover ? root.hover-background : root.idle-background; background: root.fill-color; forward-focus: keyboard; border-width: keyboard.focus-visible ? 2px : 0px; border-color: AppTheme.accent; opacity: root.interactive ? 1 : 0.42; animate background { duration: 120ms; easing: ease-out; } accessible-role: button; accessible-label: root.tooltip-text; accessible-action-default => { if root.interactive { root.clicked(); } } Image { x: (parent.width - root.icon-size) / 2; y: (parent.height - root.icon-size) / 2 + (hit-area.pressed ? 1px : 0px); width: root.icon-size; height: root.icon-size; source: root.icon; colorize: root.primary ? ThemeUtilities.contrasting-foreground(root.fill-color) : root.icon-color; image-fit: contain; accessible-role: none; animate y { duration: 140ms; easing: spring(0); } } keyboard := FocusVisibleScope { enabled: root.interactive; changed has-focus => { if self.has-focus { IconButtonInput.keyboard = true; } } focus-on-tab-navigation: true; accessible-role: none; key-pressed(event) => { IconButtonInput.keyboard = true; if root.interactive && (event.text == Key.Return || event.text == Key.Space) { root.clicked(); return accept; } reject } } hit-area := TouchArea { enabled: root.interactive; moved => { IconButtonInput.keyboard = false; } clicked => { keyboard.focus(); root.pointer-activated(); root.clicked(); } mouse-cursor: pointer; } // Keep action help inside the main scene. Native Tooltip uses a popup // surface, which is not consistently presented by every AppImage/Wayland // compositor combination. Rectangle { visible: root.tooltip-text != "" && (IconButtonInput.keyboard ? keyboard.has-focus : hit-area.has-hover); x: root.tooltip-align-left ? 0px : parent.width - self.width; y: root.tooltip-above ? -self.height - 6px : parent.height + 6px; width: tooltip-label.preferred-width + 16px; height: 28px; border-radius: 5px; background: #292929; z: 10000; tooltip-label := Text { text: root.tooltip-text; color: white; font-size: TypeScale.secondary; horizontal-alignment: center; vertical-alignment: center; } } } // Primary actions that replace a collapsed workspace sidebar float above the // list surface. Keeping the geometry here makes mail and contacts identical. export component LineEdit inherits Rectangle { in-out property text; in property placeholder-text; in property input-label; in property input-type: InputType.text; in property enabled: true; in property read-only: false; in property invalid: false; in property show-leading-icon: false; in property leading-icon; in property leading-icon-color: AppTheme.secondary; in property trailing-content-width: 0px; private property text-inset: root.show-leading-icon ? 40px : 12px; // Password fields can be shown while typing (NIST SP 800-63B-4 // 3.1.1.2), on desktop only: a shown password is plain text to the input // method, and phone keyboards learn what is typed as plain text. private property can-reveal: root.input-type == InputType.password && WindowChrome.platform != ""; private property revealed: false; private property text-end-inset: root.can-reveal ? 36px : 12px; callback edited(string); callback accepted(string); callback select-all; callback copy; out property has-focus: editor.has-focus; forward-focus: editor; // Hide the password again once the field is cleared, as when a form // resets. changed text => { if root.text == "" { root.revealed = false; } } height: 36px; min-width: 80px; border-radius: LayoutMetrics.corner-radius - 2px; border-width: 1px; border-color: root.invalid ? AppTheme.destructive : editor.has-focus ? AppTheme.accent : AppTheme.input-border; background: AppTheme.input-bg; opacity: root.enabled ? 1 : 0.5; Text { x: root.text-inset; width: max(0px, parent.width - root.text-inset - root.text-end-inset - root.trailing-content-width); height: parent.height; visible: root.text == ""; text: root.placeholder-text; color: AppTheme.placeholder; font-size: TypeScale.body; vertical-alignment: center; overflow: elide; } editor := TextInput { x: root.text-inset; width: max(0px, parent.width - root.text-inset - root.text-end-inset - root.trailing-content-width); height: parent.height; text <=> root.text; color: AppTheme.heading; selection-background-color: AppTheme.accent; selection-foreground-color: white; font-size: TypeScale.body; single-line: true; vertical-alignment: center; input-type: root.revealed ? InputType.text : root.input-type; accessible-label: root.input-label; enabled: root.enabled; read-only: root.read-only; edited => { root.text = self.text; root.edited(self.text); } accepted => { root.accepted(self.text); } } if root.show-leading-icon : Image { x: 20px - self.width / 2; y: (parent.height - self.height) / 2; width: LayoutMetrics.icon-size; height: LayoutMetrics.icon-size; source: root.leading-icon; colorize: root.leading-icon-color; image-fit: contain; accessible-role: none; } if root.can-reveal : IconButton { x: parent.width - root.trailing-content-width - self.width - 2px; y: (parent.height - self.height) / 2; icon: root.revealed ? @image-url("../icons/lucide/outline/eye-off.svg") : @image-url("../icons/lucide/outline/eye.svg"); icon-size: 16px; icon-color: AppTheme.secondary; ghost: true; hover-background: AppTheme.muted-control-bg; active-background: AppTheme.muted-control-bg; interactive: root.enabled; tooltip-text: root.revealed ? @tr("Hide password") : @tr("Show password"); clicked => { root.revealed = !root.revealed; // A click goes back to typing; from the keyboard, focus stays // so the new state is announced. if !self.focus-visible { editor.focus(); } } } select-all => { editor.select-all(); } copy => { editor.copy(); } } export component FloatingActionButton inherits IconButton { width: LayoutMetrics.touch-target + 8px; height: LayoutMetrics.touch-target + 8px; border-radius: self.width / 2; icon-size: 22px; primary: true; tooltip-above: true; drop-shadow-blur: 12px; drop-shadow-offset-y: 4px; drop-shadow-color: #00000040; } // A short link to a web page, such as a footer link. Links open in the // browser, so the label ends in the external link icon SettingsLinkRow uses. // The link keeps the shared touch target; only the pill around it is drawn. export component TextLink inherits Rectangle { in property text; out property focus-visible: keyboard.focus-visible; callback clicked; width: pill.width; height: max(LayoutMetrics.touch-target, pill.height); forward-focus: keyboard; accessible-role: button; accessible-label: root.text; accessible-action-default => { root.clicked(); } pill := Rectangle { y: (parent.height - self.height) / 2; width: content.preferred-width; height: content.preferred-height; border-radius: LayoutMetrics.corner-radius - 2px; border-width: keyboard.focus-visible ? 2px : 0px; border-color: AppTheme.accent; background: link-area.has-hover ? AppTheme.accent-soft : transparent; animate background { duration: 120ms; easing: ease-out; } content := HorizontalLayout { padding-left: 8px; padding-right: 8px; padding-top: 4px; padding-bottom: 4px; spacing: 4px; Text { text: root.text; color: AppTheme.accent; font-size: TypeScale.caption; font-weight: 500; vertical-alignment: center; accessible-role: none; } Image { width: 12px; height: 12px; source: @image-url("../icons/lucide/outline/external-link.svg"); colorize: AppTheme.accent; image-fit: contain; accessible-role: none; } } } keyboard := FocusVisibleScope { accessible-role: none; key-pressed(event) => { if event.text == Key.Return || event.text == Key.Space { root.clicked(); return accept; } reject } } link-area := TouchArea { clicked => { keyboard.focus(); keyboard.pointer-activated(); root.clicked(); } mouse-cursor: pointer; } } // Canonical app-bar action geometry. Toolbars opt into this component instead // of choosing 32/40/44px independently on every screen. export component ToolbarIconButton inherits IconButton { in property compact: false; width: root.compact ? LayoutMetrics.touch-target : LayoutMetrics.desktop-icon-button-size; height: root.compact ? LayoutMetrics.touch-target : LayoutMetrics.desktop-icon-button-size; icon-size: root.compact ? LayoutMetrics.compact-toolbar-icon-size : LayoutMetrics.desktop-toolbar-icon-size; // App-bar actions are visually quiet until hovered, pressed, or active. // Primary actions still use IconButton's primary branch. ghost: true; }