Files

589 lines
21 KiB
Plaintext

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 <string> text;
in property <string> placeholder-text;
in property <bool> enabled: true;
in property <bool> read-only: false;
in property <color> text-color: AppTheme.heading;
in property <color> background-color: AppTheme.input-bg;
in property <length> 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 <bool> checked: false;
in property <bool> enabled: true;
in property <string> 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 <bool> checked: false;
in property <bool> indeterminate: false;
in property <bool> 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 <string> text;
in property <bool> 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 <bool> keyboard: false;
}
export component IconButton inherits Rectangle {
in property <image> icon;
in property <color> icon-color;
in property <color> idle-background: #00000000;
in property <color> hover-background: idle-background;
in property <color> active-background: idle-background;
in property <color> 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 <color> primary-hover-background: AppTheme.primary-hover;
in property <color> primary-pressed-background: AppTheme.primary-pressed;
in property <bool> ghost: false;
in property <bool> primary: false;
in property <bool> active: false;
in property <bool> interactive: true;
out property <bool> focus-visible: keyboard.focus-visible;
in property <length> icon-size: LayoutMetrics.icon-size;
in property <string> tooltip-text: "";
in property <bool> tooltip-above: false;
in property <bool> 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 <color> 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 <string> text;
in property <string> placeholder-text;
in property <string> input-label;
in property <InputType> input-type: InputType.text;
in property <bool> enabled: true;
in property <bool> read-only: false;
in property <bool> invalid: false;
in property <bool> show-leading-icon: false;
in property <image> leading-icon;
in property <color> leading-icon-color: AppTheme.secondary;
in property <length> trailing-content-width: 0px;
private property <length> 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 <bool> can-reveal: root.input-type == InputType.password
&& WindowChrome.platform != "";
private property <bool> revealed: false;
private property <length> text-end-inset: root.can-reveal ? 36px : 12px;
callback edited(string);
callback accepted(string);
callback select-all;
callback copy;
out property <bool> 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 <string> text;
out property <bool> 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 <bool> 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;
}