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 compact: false; in property reserve-compact-navigation: false; in property available-width; in property available-height; private property base-x: root.compact ? 0px : LayoutMetrics.product-rail-width; private property animated-reveal-progress: 0; private property reveal-progress: MotionSettings.enabled ? root.animated-reveal-progress : 1; private property 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 divider-color: AppTheme.border; // What the fade shows through to, for a divider not drawn over a pane. in property backdrop: transparent; private property 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 surface-color: AppTheme.sidebar-bg; in property 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 collapsed: false; out property current-width: animated-width; private property 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 leading-width: LayoutMetrics.list-pane-width; in property minimum-leading-width: LayoutMetrics.list-pane-width; in property maximum-leading-width: root.minimum-leading-width; in property divider-color: AppTheme.border; in property active-color: AppTheme.accent; in property accessibility-label; callback resizing(length); callback resize-finished(length); private property effective-maximum: max(root.minimum-leading-width, root.maximum-leading-width); private property drag-start-width: root.leading-width; private property drag-start-pointer-x: 0px; private property last-requested-width: root.leading-width; private property 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 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 label; in property 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 title; in property show-leading: false; in property leading-icon; in property leading-tooltip: ""; in property leading-accessible-id: ""; in property horizontal-inset: 0px; // Where the title starts when there is no leading action. in property leading-inset: root.horizontal-inset; in property item-spacing: 6px; in property title-font-size: TypeScale.title-small; in property title-weight: 700; in property 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 text; in property placeholder-text; in property show-leading: false; in property leading-icon; in property leading-tooltip: ""; in property leading-accessible-id: ""; in property <[WorkspaceSearchFilter]> filters: []; in property filter-active: false; in property filter-tooltip; in property filter-accessible-id; in property refresh-enabled: true; in property refresh-tooltip; in property refresh-accessible-id; callback edited(string); callback submitted(string); callback filter-selected(string); callback leading-clicked; callback refresh; out property 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 title; in property show-leading: false; in property leading-icon; in property leading-tooltip: ""; in property leading-accessible-id: ""; in property background-color: AppTheme.surface; in property leading-ghost: false; in property leading-inset: LayoutMetrics.compact-inset; in property 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; } }