Files
mail/ui/components/workspace-layout.slint
T

472 lines
19 KiB
Plaintext

import { HorizontalBox } from "std-widgets.slint";
import { AppTheme, LayoutMetrics, MotionSettings } from "../design-system.slint";
import { TypeScale } from "../typography.slint";
import { LineEdit, ToolbarIconButton } from "controls.slint";
import { FocusVisibleScope } from "focus-visible-scope.slint";
import { WindowChrome, WindowControlsClearance } from "../window-chrome.slint";
import { TabSwitcherButton } from "tab-overview.slint";
import { TabsUi } from "tabs-ui.slint";
export struct WorkspaceSearchFilter {
title: string,
value: string,
}
// All top-level workspaces are placed by this component. This prevents a view
// from silently adding the product-rail offset or mobile navigation allowance
// a second time.
export component WorkspaceFrame inherits Rectangle {
in property <bool> compact: false;
in property <bool> reserve-compact-navigation: false;
in property <length> available-width;
in property <length> available-height;
private property <length> base-x: root.compact ? 0px : LayoutMetrics.product-rail-width;
private property <float> animated-reveal-progress: 0;
private property <float> reveal-progress: MotionSettings.enabled ? root.animated-reveal-progress : 1;
private property <bool> reveal-pending: true;
x: root.base-x + (1 - root.reveal-progress) * 14px;
y: 0px;
width: max(0px, root.available-width - root.base-x);
height: max(0px, root.available-height - (root.compact && root.reserve-compact-navigation ? LayoutMetrics.compact-navigation-height : 0px));
background: AppTheme.page-bg;
clip: true;
opacity: 0.72 + root.reveal-progress * 0.28;
// Each conditionally-instantiated workspace gets a short entrance. This
// provides a native view-transition feel while its data model hydrates.
Timer {
interval: 16ms;
running: MotionSettings.enabled && root.reveal-pending;
triggered => {
root.reveal-pending = false;
root.animated-reveal-progress = 1;
}
}
animate x { duration: 240ms; easing: spring(0); enabled: MotionSettings.enabled; }
animate height { duration: 220ms; easing: spring(0); enabled: MotionSettings.enabled; }
animate opacity { duration: 180ms; easing: ease-out; enabled: MotionSettings.enabled; }
@children
}
// A vertical divider between panes. Under the tab strip it fades in from the
// strip's border, so it doesn't run into the open foot of the active tab.
export component PaneDivider inherits Rectangle {
in property <color> divider-color: AppTheme.border;
// What the fade shows through to, for a divider not drawn over a pane.
in property <color> backdrop: transparent;
private property <length> fade: WindowChrome.top-bar ? min(24px, root.height) : 0px;
width: LayoutMetrics.divider-width;
Rectangle {
y: 0px;
height: root.fade;
background: root.backdrop;
}
Rectangle {
y: 0px;
height: root.fade;
background: @linear-gradient(180deg, root.divider-color.with-alpha(0) 0%, root.divider-color 100%);
}
Rectangle {
y: root.fade;
height: root.height - root.fade;
background: root.divider-color;
}
}
// Feature-specific sidebars share their outer geometry and divider while
// retaining their own mailboxes, calendars, contacts, or settings controls.
export component WorkspaceSidebarSurface inherits Rectangle {
in property <color> surface-color: AppTheme.sidebar-bg;
in property <color> divider-color: AppTheme.border;
background: root.surface-color;
clip: true;
@children
PaneDivider {
x: parent.width - self.width;
height: parent.height;
divider-color: root.divider-color;
}
}
// Every feature sidebar uses the same animated extent. Animating a custom
// property keeps the layout reactive for the full transition; snapping
// min/max constraints made the previous mail-only implementation jump.
export component CollapsibleWorkspaceSidebar inherits WorkspaceSidebarSurface {
in-out property <bool> collapsed: false;
out property <length> current-width: animated-width;
private property <length> animated-width: root.collapsed ? 0px : LayoutMetrics.sidebar-width;
width: root.animated-width;
clip: true;
animate animated-width { duration: 220ms; easing: spring(0); }
}
// A one-pixel divider with a larger, invisible interaction target. The
// workspace owns the requested width so mail, contacts, and files can share a
// persisted preference while still clamping it to their current geometry.
export component WorkspacePaneResizeHandle inherits Rectangle {
in property <length> leading-width: LayoutMetrics.list-pane-width;
in property <length> minimum-leading-width: LayoutMetrics.list-pane-width;
in property <length> maximum-leading-width: root.minimum-leading-width;
in property <color> divider-color: AppTheme.border;
in property <color> active-color: AppTheme.accent;
in property <string> accessibility-label;
callback resizing(length);
callback resize-finished(length);
private property <length> effective-maximum: max(root.minimum-leading-width, root.maximum-leading-width);
private property <length> drag-start-width: root.leading-width;
private property <length> drag-start-pointer-x: 0px;
private property <length> last-requested-width: root.leading-width;
private property <bool> dragging: false;
function request-width(value: length) {
let bounded = value.clamp(root.minimum-leading-width, root.effective-maximum);
root.last-requested-width = bounded;
root.resizing(bounded);
}
function commit-width(value: length) {
root.request-width(value);
root.resize-finished(root.last-requested-width);
}
private property <color> line-color: resize-hit.pressed || resize-hit.has-hover || resize-focus.focus-visible
? root.active-color : root.divider-color;
animate line-color { duration: 150ms; easing: ease-out; }
width: LayoutMetrics.divider-width;
horizontal-stretch: 0;
z: 2;
PaneDivider {
height: parent.height;
divider-color: root.line-color;
// The handle sits over the frame's page color; the panes on both
// sides meet the tab strip on the plain surface.
backdrop: AppTheme.surface;
}
resize-focus := FocusVisibleScope {
x: -(LayoutMetrics.pane-resize-hit-width - root.width) / 2;
y: 0px;
width: LayoutMetrics.pane-resize-hit-width;
height: parent.height;
focus-on-click: true;
accessible-role: slider;
accessible-label: root.accessibility-label;
accessible-orientation: Orientation.horizontal;
accessible-value: "\{(root.leading-width / 1px).round()}";
accessible-value-minimum: root.minimum-leading-width / 1px;
accessible-value-maximum: root.effective-maximum / 1px;
accessible-value-step: LayoutMetrics.pane-resize-keyboard-step / 1px;
accessible-action-set-value(value) => {
if value.is-float() {
root.commit-width(value.to-float() * 1px);
}
}
accessible-action-increment => {
root.commit-width(root.leading-width + LayoutMetrics.pane-resize-keyboard-step);
}
accessible-action-decrement => {
root.commit-width(root.leading-width - LayoutMetrics.pane-resize-keyboard-step);
}
key-pressed(event) => {
let step = event.modifiers.shift ? 40px : LayoutMetrics.pane-resize-keyboard-step;
if event.text == Key.LeftArrow {
root.request-width(root.leading-width - step);
} else if event.text == Key.RightArrow {
root.request-width(root.leading-width + step);
} else if event.text == Key.Home {
root.request-width(root.minimum-leading-width);
} else if event.text == Key.End {
root.request-width(root.effective-maximum);
} else {
return reject;
}
return accept;
}
key-released(event) => {
if event.text == Key.LeftArrow || event.text == Key.RightArrow
|| event.text == Key.Home || event.text == Key.End {
root.resize-finished(root.last-requested-width);
return accept;
}
return reject;
}
resize-hit := TouchArea {
mouse-cursor: col-resize;
pointer-event(event) => {
if event.kind == PointerEventKind.down && event.button == PointerEventButton.left {
resize-focus.focus();
resize-focus.pointer-activated();
root.dragging = true;
root.drag-start-width = root.leading-width;
root.last-requested-width = root.leading-width;
root.drag-start-pointer-x = self.absolute-position.x + self.mouse-x;
} else if event.kind == PointerEventKind.move && root.dragging {
let pointer-x = self.absolute-position.x + self.mouse-x;
root.request-width(root.drag-start-width + pointer-x - root.drag-start-pointer-x);
} else if (event.kind == PointerEventKind.up || event.kind == PointerEventKind.cancel)
&& root.dragging {
root.dragging = false;
root.resize-finished(root.last-requested-width);
}
}
double-clicked => { root.commit-width(root.minimum-leading-width); }
}
}
}
// Section labels use one compact typographic treatment across mail folders,
// contact groups, and calendar sources.
export component WorkspaceMiniHeading inherits Text {
in property <string> label;
in property <color> label-color: AppTheme.muted;
height: 18px;
text: root.label;
color: root.label-color;
font-size: TypeScale.micro;
font-weight: 600;
letter-spacing: 0.6px;
vertical-alignment: center;
}
// Shared title row used both inside two-row list headers and standalone page
// headers. Feature actions are injected after the title.
export component WorkspaceTitleRow inherits Rectangle {
in property <string> title;
in property <bool> show-leading: false;
in property <image> leading-icon;
in property <string> leading-tooltip: "";
in property <string> leading-accessible-id: "";
in property <length> horizontal-inset: 0px;
// Where the title starts when there is no leading action.
in property <length> leading-inset: root.horizontal-inset;
in property <length> item-spacing: 6px;
in property <relative-font-size> title-font-size: TypeScale.title-small;
in property <int> title-weight: 700;
in property <bool> leading-ghost: false;
callback leading-clicked;
height: LayoutMetrics.touch-target;
background: transparent;
// Window controls may cover this row's corners.
clearance := WindowControlsClearance { width: parent.width; height: parent.height; }
HorizontalBox {
padding-top: 0px;
padding-left: max(root.leading-inset, clearance.leading);
padding-right: max(root.horizontal-inset, clearance.trailing);
padding-bottom: 0px;
spacing: root.item-spacing;
cross-axis-alignment: center;
// Created only when shown: a hidden button would still take the
// row's spacing and push the title off its content's edge.
if root.show-leading : ToolbarIconButton {
compact: root.height >= LayoutMetrics.touch-target;
width: self.compact ? LayoutMetrics.touch-target : LayoutMetrics.desktop-icon-button-size;
icon: root.leading-icon;
icon-color: AppTheme.secondary;
ghost: root.leading-ghost;
idle-background: AppTheme.control-bg;
hover-background: AppTheme.muted-control-bg;
active-background: AppTheme.accent-soft;
tooltip-text: root.leading-tooltip;
accessible-id: root.leading-accessible-id;
clicked => { root.leading-clicked(); }
}
Text {
horizontal-stretch: 1;
text: root.title;
color: AppTheme.title;
font-size: root.title-font-size;
font-weight: root.title-weight;
vertical-alignment: center;
overflow: elide;
}
@children
}
}
// Mail, contacts, and files share one search, filter, and refresh contract.
// The optional leading action keeps each workspace's sidebar toggle on the
// same line, while feature-specific actions are injected before refresh.
export component WorkspaceSearchToolbar inherits Rectangle {
// Bind with `<=>` so the field follows queries set elsewhere, such as a
// reset when switching account or folder.
in-out property <string> text;
in property <string> placeholder-text;
in property <bool> show-leading: false;
in property <image> leading-icon;
in property <string> leading-tooltip: "";
in property <string> leading-accessible-id: "";
in property <[WorkspaceSearchFilter]> filters: [];
in property <bool> filter-active: false;
in property <string> filter-tooltip;
in property <string> filter-accessible-id;
in property <bool> refresh-enabled: true;
in property <string> refresh-tooltip;
in property <string> refresh-accessible-id;
callback edited(string);
callback submitted(string);
callback filter-selected(string);
callback leading-clicked;
callback refresh;
out property <bool> search-focused: search-edit.has-focus;
height: LayoutMetrics.control-height;
background: transparent;
// Typing replaces the field's binding, so programmatic changes (such as
// switching tabs) are copied in explicitly.
changed text => {
if search-edit.text != root.text {
search-edit.text = root.text;
}
}
public function focus-search() {
search-edit.focus();
search-edit.select-all();
}
clearance := WindowControlsClearance { width: parent.width; height: parent.height; }
HorizontalBox {
padding: 0px;
padding-left: clearance.leading;
padding-right: clearance.trailing;
spacing: 6px;
if root.show-leading : ToolbarIconButton {
width: LayoutMetrics.desktop-icon-button-size;
icon: root.leading-icon;
icon-color: AppTheme.secondary;
ghost: true;
hover-background: AppTheme.muted-control-bg;
active-background: AppTheme.accent-soft;
tooltip-text: root.leading-tooltip;
accessible-id: root.leading-accessible-id;
clicked => { root.leading-clicked(); }
}
search-field := Rectangle {
height: LayoutMetrics.control-height;
horizontal-stretch: 1;
search-edit := LineEdit {
width: parent.width;
height: parent.height;
text <=> root.text;
placeholder-text: root.placeholder-text;
show-leading-icon: true;
leading-icon: @image-url("../icons/lucide/outline/search.svg");
leading-icon-color: AppTheme.secondary;
trailing-content-width: LayoutMetrics.control-height;
edited(value) => { root.edited(value); }
accepted(value) => { root.submitted(value); }
}
filter-menu := ContextMenuArea {
x: parent.width - LayoutMetrics.control-height;
width: LayoutMetrics.control-height;
height: LayoutMetrics.control-height;
ToolbarIconButton {
icon: root.filter-active
? @image-url("../icons/lucide/outline/list-filter-plus.svg")
: @image-url("../icons/lucide/outline/list-filter.svg");
icon-color: root.filter-active ? AppTheme.accent : AppTheme.secondary;
hover-background: AppTheme.muted-control-bg;
active: root.filter-active;
active-background: AppTheme.accent-soft;
tooltip-text: root.filter-tooltip;
accessible-id: root.filter-accessible-id;
clicked => { filter-menu.show({ x: 0px, y: parent.height }); }
}
Menu {
for option in root.filters : MenuItem {
title: option.title;
activated => { root.filter-selected(option.value); }
}
}
}
}
@children
ToolbarIconButton {
interactive: root.refresh-enabled;
icon: @image-url("../icons/lucide/outline/sync.svg");
icon-color: AppTheme.secondary;
hover-background: AppTheme.muted-control-bg;
tooltip-text: root.refresh-tooltip;
accessible-id: root.refresh-accessible-id;
clicked => { root.refresh(); }
}
if TabsUi.enabled && TabsUi.compact : TabSwitcherButton {}
}
}
// Standalone compact header used by calendar and settings. Mail and contacts
// share WorkspaceTitleRow above a feature-specific search field.
export component CompactPageHeader inherits Rectangle {
in property <string> title;
in property <bool> show-leading: false;
in property <image> leading-icon;
in property <string> leading-tooltip: "";
in property <string> leading-accessible-id: "";
in property <color> background-color: AppTheme.surface;
in property <bool> leading-ghost: false;
in property <length> leading-inset: LayoutMetrics.compact-inset;
in property <relative-font-size> title-font-size: TypeScale.title-small;
callback leading-clicked;
height: LayoutMetrics.compact-header-height;
background: root.background-color;
WorkspaceTitleRow {
x: 0px;
y: (parent.height - self.height) / 2;
width: parent.width;
title: root.title;
show-leading: root.show-leading;
leading-icon: root.leading-icon;
leading-tooltip: root.leading-tooltip;
leading-accessible-id: root.leading-accessible-id;
leading-ghost: root.leading-ghost;
horizontal-inset: LayoutMetrics.compact-inset;
leading-inset: root.leading-inset;
title-font-size: root.title-font-size;
leading-clicked => { root.leading-clicked(); }
@children
}
Rectangle {
x: 0px;
y: parent.height - LayoutMetrics.divider-width;
width: parent.width;
height: LayoutMetrics.divider-width;
background: AppTheme.border-subtle;
}
}