mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
472 lines
19 KiB
Plaintext
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;
|
|
}
|
|
}
|