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

925 lines
38 KiB
Plaintext

import { HorizontalBox, VerticalBox } from "std-widgets.slint";
import { TypeScale } from "../typography.slint";
import { LayoutMetrics, MotionSettings } from "../design-system.slint";
import { WindowChrome, WindowDragArea } from "../window-chrome.slint";
import { MailProfileRow } from "../models.slint";
import { ProfileSwitcher } from "profile-switcher.slint";
import { PaneDivider } from "workspace-layout.slint";
import { TabRow, TabsUi, WorkspaceIcon } from "tabs-ui.slint";
export { TabRow, TabsUi }
component ProductRailItem inherits Rectangle {
in property <string> label;
in property <image> outline-icon;
in property <image> filled-icon;
in property <bool> active: false;
in property <color> accent-color;
in property <color> accent-background;
in property <color> muted-color;
out property <bool> hovered: item-hit.has-hover;
callback clicked;
// Desktop navigation is icon-only. The target and selected surface remain
// square so each destination has an even, predictable hit area.
height: 48px;
background: transparent;
Rectangle {
x: (parent.width - self.width) / 2;
y: 4px;
width: 40px;
height: 40px;
border-radius: 10px;
background: root.active || item-hit.has-hover ? root.accent-background : transparent;
animate background { duration: 140ms; easing: ease-out; }
Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: root.active ? LayoutMetrics.icon-size + 2px : LayoutMetrics.icon-size;
height: root.active ? LayoutMetrics.icon-size + 2px : LayoutMetrics.icon-size;
source: root.active ? root.filled-icon : root.outline-icon;
colorize: root.active ? root.accent-color : root.muted-color;
animate colorize { duration: 140ms; easing: ease-out; }
animate width, height { duration: 180ms; easing: spring(0.12); }
image-fit: contain;
accessible-role: none;
}
item-hit := TouchArea {
accessible-role: button;
accessible-label: root.label;
accessible-checked: root.active;
accessible-action-default => { root.clicked(); }
clicked => { root.clicked(); }
mouse-cursor: pointer;
}
}
}
// The product rail is deliberately narrow: it switches workspaces without
// competing with the context-specific sidebar immediately to its right.
export component ProductRail inherits Rectangle {
in-out property <string> active-view: "mail";
in property <bool> settings-active: false;
in property <color> background-color;
in property <color> divider-color;
in property <color> accent-color;
in property <color> accent-background;
in property <color> title-color;
in property <color> muted-color;
out property <string> tooltip-text: mail-item.hovered ? mail-item.label
: calendar-item.hovered ? calendar-item.label
: files-item.hovered ? files-item.label
: contacts-item.hovered ? contacts-item.label
: settings-item.hovered ? settings-item.label
: "";
out property <length> tooltip-y: mail-item.hovered ? mail-item.y + 10px
: calendar-item.hovered ? calendar-item.y + 10px
: files-item.hovered ? files-item.y + 10px
: contacts-item.hovered ? contacts-item.y + 10px
: settings-item.hovered ? settings-item.y + 10px
: 0px;
callback open-settings;
callback select-view(string);
width: LayoutMetrics.product-rail-width;
background: root.background-color;
PaneDivider {
x: parent.width - self.width;
height: parent.height;
divider-color: root.divider-color;
}
VerticalBox {
x: 0px;
y: 0px;
width: parent.width;
height: parent.height;
// 8px outer padding and 48px items preserve a compact 8px rhythm.
padding-top: 8px;
padding-right: 0px;
padding-bottom: 10px;
padding-left: 0px;
spacing: 8px;
mail-item := ProductRailItem {
label: @tr("Mail");
outline-icon: @image-url("../icons/lucide/outline/mail.svg");
filled-icon: @image-url("../icons/lucide/filled/mail.svg");
active: !root.settings-active && root.active-view == "mail";
accent-color: root.accent-color;
accent-background: root.accent-background;
muted-color: root.muted-color;
clicked => { root.select-view("mail"); }
}
calendar-item := ProductRailItem {
label: @tr("Calendar");
outline-icon: @image-url("../icons/lucide/outline/calendar.svg");
filled-icon: @image-url("../icons/lucide/filled/calendar.svg");
active: !root.settings-active && root.active-view == "calendar";
accent-color: root.accent-color;
accent-background: root.accent-background;
muted-color: root.muted-color;
clicked => { root.select-view("calendar"); }
}
files-item := ProductRailItem {
label: @tr("Files");
outline-icon: @image-url("../icons/lucide/outline/files.svg");
filled-icon: @image-url("../icons/lucide/filled/files.svg");
active: !root.settings-active && root.active-view == "files";
accent-color: root.accent-color;
accent-background: root.accent-background;
muted-color: root.muted-color;
clicked => { root.select-view("files"); }
}
contacts-item := ProductRailItem {
label: @tr("Contacts");
outline-icon: @image-url("../icons/lucide/outline/contacts.svg");
filled-icon: @image-url("../icons/lucide/filled/contacts.svg");
active: !root.settings-active && root.active-view == "contacts";
accent-color: root.accent-color;
accent-background: root.accent-background;
muted-color: root.muted-color;
clicked => { root.select-view("contacts"); }
}
Rectangle { vertical-stretch: 1; }
settings-item := ProductRailItem {
label: @tr("Settings");
outline-icon: @image-url("../icons/lucide/outline/gear.svg");
filled-icon: @image-url("../icons/lucide/filled/gear.svg");
active: root.settings-active;
accent-color: root.accent-color;
accent-background: root.accent-background;
muted-color: root.muted-color;
clicked => { root.open-settings(); }
}
}
}
// Compact navigation stays at the bottom, inside Window.safe-area-insets. This
// keeps the primary destinations in thumb reach without duplicating any
// application state or business logic.
component CompactProductNavItem inherits Rectangle {
in property <string> label;
in property <image> outline-icon;
in property <image> filled-icon;
in property <bool> active: false;
in property <color> accent-color;
in property <color> active-background;
in property <color> muted-color;
in property <bool> interactive: true;
callback clicked;
min-width: 64px;
height: 64px;
background: transparent;
// The selected destination uses a compact indicator behind the icon.
// Keeping the label outside it prevents the active item from looking like
// an oversized card at narrow window widths.
Rectangle {
x: (parent.width - 56px) / 2;
y: 6px;
width: 56px;
height: 32px;
border-radius: 16px;
background: root.active || nav-hit.has-hover ? root.active-background : transparent;
animate background { duration: 140ms; easing: ease-out; }
Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: root.active ? 24px : 22px;
height: root.active ? 24px : 22px;
source: root.active ? root.filled-icon : root.outline-icon;
colorize: root.active ? root.accent-color : root.muted-color;
animate colorize { duration: 140ms; easing: ease-out; }
animate width, height { duration: 180ms; easing: spring(0.12); }
image-fit: contain;
accessible-role: none;
}
}
// The label may use the item's full width and bottom inset as the
// platform font grows.
Text {
x: 0px;
y: 40px;
width: parent.width;
height: min(parent.height - self.y, max(18px, self.preferred-height));
text: root.label;
color: root.active ? root.accent-color : root.muted-color;
font-size: TypeScale.caption;
font-weight: root.active ? 700 : 600;
animate color { duration: 140ms; easing: ease-out; }
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
// Make the complete navigation item interactive, including the space
// around its indicator and label.
nav-hit := TouchArea {
enabled: root.interactive;
mouse-cursor: pointer;
accessible-role: button;
accessible-enabled: root.interactive;
accessible-label: root.label;
accessible-id: "mobile-nav-" + root.label.to-lowercase();
accessible-checked: root.active;
accessible-action-default => { root.clicked(); }
clicked => { root.clicked(); }
}
}
export component CompactProductNav inherits Rectangle {
in-out property <string> active-view: "mail";
in property <bool> settings-active: false;
in property <color> background-color;
in property <color> divider-color;
in property <color> accent-color;
in property <color> accent-background;
in property <color> muted-color;
in property <bool> interactive: true;
callback open-settings;
callback select-view(string);
height: LayoutMetrics.compact-navigation-height;
background: root.background-color;
accessible-role: navigation;
accessible-label: @tr("Primary navigation");
Rectangle {
x: 0px;
y: 0px;
width: parent.width;
height: 1px;
background: root.divider-color;
}
HorizontalBox {
x: 4px;
y: 1px;
width: parent.width - 8px;
height: 79px;
padding-top: 7px;
padding-bottom: 8px;
spacing: 2px;
CompactProductNavItem {
horizontal-stretch: 1;
label: @tr("Mail");
outline-icon: @image-url("../icons/lucide/outline/mail.svg");
filled-icon: @image-url("../icons/lucide/filled/mail.svg");
active: !root.settings-active && root.active-view == "mail";
accent-color: root.accent-color;
active-background: root.accent-background;
muted-color: root.muted-color;
interactive: root.interactive;
clicked => { root.select-view("mail"); }
}
CompactProductNavItem {
horizontal-stretch: 1;
label: @tr("Calendar");
outline-icon: @image-url("../icons/lucide/outline/calendar.svg");
filled-icon: @image-url("../icons/lucide/filled/calendar.svg");
active: !root.settings-active && root.active-view == "calendar";
accent-color: root.accent-color;
active-background: root.accent-background;
muted-color: root.muted-color;
interactive: root.interactive;
clicked => { root.select-view("calendar"); }
}
CompactProductNavItem {
horizontal-stretch: 1;
label: @tr("Files");
outline-icon: @image-url("../icons/lucide/outline/files.svg");
filled-icon: @image-url("../icons/lucide/filled/files.svg");
active: !root.settings-active && root.active-view == "files";
accent-color: root.accent-color;
active-background: root.accent-background;
muted-color: root.muted-color;
interactive: root.interactive;
clicked => { root.select-view("files"); }
}
CompactProductNavItem {
horizontal-stretch: 1;
label: @tr("Contacts");
outline-icon: @image-url("../icons/lucide/outline/contacts.svg");
filled-icon: @image-url("../icons/lucide/filled/contacts.svg");
active: !root.settings-active && root.active-view == "contacts";
accent-color: root.accent-color;
active-background: root.accent-background;
muted-color: root.muted-color;
interactive: root.interactive;
clicked => { root.select-view("contacts"); }
}
CompactProductNavItem {
horizontal-stretch: 1;
label: @tr("Settings");
outline-icon: @image-url("../icons/lucide/outline/gear.svg");
filled-icon: @image-url("../icons/lucide/filled/gear.svg");
active: root.settings-active;
accent-color: root.accent-color;
active-background: root.accent-background;
muted-color: root.muted-color;
interactive: root.interactive;
clicked => { root.open-settings(); }
}
}
}
// Tabs share the strip's height below a small top margin.
global TabMetrics {
out property <length> top: 6px;
// The active tab's top corner radius.
out property <length> radius: 8px;
// How far the active tab's foot curves out on each side to meet the
// content below.
out property <length> foot: 12px;
// The line tab labels, the profile pill and "+" are centred on: a touch
// above the tab's middle, as its foot is open, and in line with the
// window controls.
public pure function middle(height: length) -> length {
return (height + root.top) / 2 - 2px;
}
}
// The active tab: rounded top corners, and a foot that curves out on both
// sides into the strip's bottom border. Its border rises out of that line,
// runs round the tab and drops back into it; the open bottom joins the
// content below. The border's centre line runs half a pixel inside the edge,
// so the 1px line covers whole pixels at 1x and 2x.
component ActiveTabShape inherits Rectangle {
in property <brush> fill;
in property <brush> stroke;
private property <float> w: self.width / 1px;
private property <float> h: self.height / 1px;
private property <float> r: TabMetrics.radius / 1px;
private property <float> f: TabMetrics.foot / 1px;
// The border's centre line, half its width in from the edge.
private property <float> s: 0.5;
// Both paths are drawn in place, as fitting would shrink the outline
// inside its stroke. The fill reaches down through the strip's border,
// hiding it under the tab.
Path {
width: parent.width;
height: parent.height;
fit: preserve;
fill: root.fill;
accessible-role: none;
MoveTo { x: root.s; y: root.h; }
LineTo { x: root.s; y: root.h - root.s; }
ArcTo { x: root.f + root.s; y: root.h - root.s - root.f; radius-x: root.f; radius-y: root.f; sweep: false; }
LineTo { x: root.f + root.s; y: root.r + root.s; }
ArcTo { x: root.f + root.s + root.r; y: root.s; radius-x: root.r; radius-y: root.r; sweep: true; }
LineTo { x: root.w - root.f - root.s - root.r; y: root.s; }
ArcTo { x: root.w - root.f - root.s; y: root.r + root.s; radius-x: root.r; radius-y: root.r; sweep: true; }
LineTo { x: root.w - root.f - root.s; y: root.h - root.s - root.f; }
ArcTo { x: root.w - root.s; y: root.h - root.s; radius-x: root.f; radius-y: root.f; sweep: false; }
LineTo { x: root.w - root.s; y: root.h; }
Close {}
}
// The border is open at the bottom, where the tab meets the content.
Path {
width: parent.width;
height: parent.height;
fit: preserve;
stroke: root.stroke;
stroke-width: 1px;
accessible-role: none;
MoveTo { x: root.s; y: root.h - root.s; }
ArcTo { x: root.f + root.s; y: root.h - root.s - root.f; radius-x: root.f; radius-y: root.f; sweep: false; }
LineTo { x: root.f + root.s; y: root.r + root.s; }
ArcTo { x: root.f + root.s + root.r; y: root.s; radius-x: root.r; radius-y: root.r; sweep: true; }
LineTo { x: root.w - root.f - root.s - root.r; y: root.s; }
ArcTo { x: root.w - root.f - root.s; y: root.r + root.s; radius-x: root.r; radius-y: root.r; sweep: true; }
LineTo { x: root.w - root.f - root.s; y: root.h - root.s - root.f; }
ArcTo { x: root.w - root.s; y: root.h - root.s; radius-x: root.f; radius-y: root.f; sweep: false; }
}
}
// A tab: icon, title and close button. The active tab is the content's
// color and curves into it; the others sit on the strip, divided by short
// rules.
component TabItem inherits Rectangle {
in property <TabRow> entry;
in property <int> index;
in property <int> count;
// Where the active tab is now; the saved row only describes inactive tabs.
in property <string> live-workspace;
in property <string> live-title;
// Room this tab makes while another tab is dragged past it.
in property <length> shift;
in property <bool> reordering;
in property <color> title-color;
in property <color> text-color;
in property <color> accent-color;
// The color of the content the active tab opens into.
in property <color> active-background;
in property <color> hover-background;
in property <color> divider-color;
// The rule on the right; hidden next to the active or a hovered tab.
in property <bool> divided;
out property <bool> hovered: hit.has-hover && !hit.dragging;
// Horizontal travel while this tab is dragged, and where it was dropped.
callback dragged(length);
callback dropped(length);
// The pointer is closing this tab.
callback closing();
private property <string> workspace: root.entry.active && root.live-workspace != ""
? root.live-workspace : root.entry.workspace;
private property <string> workspace-name: root.workspace == "calendar" ? @tr("Calendar")
: root.workspace == "files" ? @tr("Files")
: root.workspace == "contacts" ? @tr("Contacts")
: root.workspace == "settings" ? @tr("Settings")
: @tr("Mail");
private property <string> title: root.entry.active
? (root.live-title != "" ? root.live-title : root.workspace-name)
: (root.entry.title != "" ? root.entry.title : root.workspace-name);
// Narrow tabs keep only their icon, as in a browser.
private property <bool> labelled: root.width >= 72px;
private property <bool> truncated: !root.labelled || label.preferred-width > label.width;
private property <bool> tooltip-due;
// A click hides the tooltip until the pointer leaves.
private property <bool> tooltip-dismissed;
private property <length> offset: hit.dragging ? hit.travel : root.shift;
function close() {
root.closing();
TabsUi.close(root.entry.id);
}
function end-drag() {
if hit.dragging {
root.dragged(0px);
}
hit.dragging = false;
hit.travel = 0px;
}
// The active tab's foot overlaps its neighbours.
z: hit.dragging ? 2 : root.entry.active ? 1 : 0;
changed hovered => {
if !root.hovered {
root.tooltip-due = false;
root.tooltip-dismissed = false;
}
}
// Rows update in place, so this element can come to show another tab,
// which starts without the last one's drag or tooltip.
private property <int> entry-id: root.entry.id;
changed entry-id => {
root.end-drag();
root.tooltip-due = false;
root.tooltip-dismissed = false;
}
face := Rectangle {
x: root.offset;
width: parent.width;
height: parent.height;
animate x {
duration: MotionSettings.enabled && root.reordering && !hit.dragging ? 160ms : 0ms;
easing: ease-out;
}
if root.entry.active : ActiveTabShape {
x: -TabMetrics.foot;
y: TabMetrics.top;
width: parent.width + 2 * TabMetrics.foot;
height: parent.height - TabMetrics.top;
fill: root.active-background;
stroke: root.divider-color;
}
if !root.entry.active : Rectangle {
x: 2px;
y: TabMetrics.middle(parent.height) - self.height / 2;
width: parent.width - 4px;
height: 30px;
border-radius: TabMetrics.radius - 1px;
background: root.hovered ? root.hover-background : transparent;
animate background { duration: MotionSettings.enabled ? 120ms : 0ms; easing: ease-out; }
}
if root.divided : Rectangle {
x: parent.width - 1px;
y: TabMetrics.middle(parent.height) - self.height / 2;
width: 1px;
height: 16px;
background: root.divider-color;
}
WorkspaceIcon {
x: root.labelled ? 12px : (parent.width - self.width) / 2;
y: TabMetrics.middle(parent.height) - self.height / 2;
width: 14px;
height: 14px;
workspace: root.workspace;
filled: root.entry.active;
colorize: root.entry.active ? root.accent-color : root.text-color;
}
label := Text {
visible: root.labelled;
x: 34px;
y: TabMetrics.middle(parent.height) - self.height / 2;
width: max(0px, parent.width - self.x - 34px);
height: 2 * (parent.height - TabMetrics.middle(parent.height));
text: root.title;
color: root.entry.active || root.hovered ? root.title-color : root.text-color;
font-size: TypeScale.secondary;
font-weight: 500;
vertical-alignment: center;
overflow: elide;
accessible-role: none;
}
}
menu-area := ContextMenuArea {
width: parent.width;
height: parent.height;
hit := TouchArea {
width: parent.width;
height: parent.height;
property <bool> dragging;
property <length> travel;
// The tab the left button went down on; this element can come to
// show another while it is held.
property <int> pressed-id: -1;
// A middle press here closes the tab when released over it.
property <bool> middle-pressed;
mouse-cursor: pointer;
accessible-role: tab;
accessible-label: root.title;
accessible-item-selectable: true;
accessible-item-selected: root.entry.active;
accessible-item-index: root.index;
accessible-action-default => { TabsUi.select(root.entry.id); }
pointer-event(event) => {
if event.kind == PointerEventKind.cancel {
self.pressed-id = -1;
self.middle-pressed = false;
root.end-drag();
} else if event.kind == PointerEventKind.down && event.button != PointerEventButton.left
&& (self.pressed || self.dragging) {
// Another button while the tab is held lets it go.
self.pressed-id = -1;
root.end-drag();
} else if event.button == PointerEventButton.left && event.kind == PointerEventKind.down {
root.tooltip-dismissed = true;
self.pressed-id = root.entry.id;
TabsUi.select(root.entry.id);
} else if event.button == PointerEventButton.left && event.kind == PointerEventKind.up {
if self.dragging {
root.dropped(self.travel);
}
self.dragging = false;
self.travel = 0px;
self.pressed-id = -1;
} else if event.button == PointerEventButton.middle && event.kind == PointerEventKind.down {
self.middle-pressed = true;
} else if event.button == PointerEventButton.middle && event.kind == PointerEventKind.up {
// Only when released over the tab it was pressed on.
if self.middle-pressed && self.mouse-x >= 0px && self.mouse-x < self.width
&& self.mouse-y >= 0px && self.mouse-y < self.height {
root.close();
}
self.middle-pressed = false;
} else if event.button == PointerEventButton.right && event.kind == PointerEventKind.down {
menu-area.show({ x: self.mouse-x, y: self.mouse-y });
}
}
// The wheel leaves the tab and the strip hovered.
scroll-event(event) => { return accept; }
moved => {
if self.pressed && self.pressed-id == root.entry.id && !self.dragging
&& abs(self.mouse-x - self.pressed-x) > 6px {
self.dragging = true;
}
if self.dragging {
// The tab stays within the strip.
self.travel = max(-root.index * root.width,
min((root.count - 1 - root.index) * root.width, self.mouse-x - self.pressed-x));
root.dragged(self.travel);
}
}
// Inside the tab's hit area, so pointing at it keeps the tab hovered.
if root.labelled && (root.entry.active || root.hovered) : Rectangle {
x: root.offset + parent.width - 28px;
y: TabMetrics.middle(parent.height) - self.height / 2;
width: 20px;
height: 20px;
border-radius: 4px;
background: close-hit.pressed ? root.title-color.with-alpha(0.18)
: close-hit.has-hover ? root.title-color.with-alpha(0.10) : transparent;
Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 12px;
height: 12px;
source: @image-url("../icons/lucide/outline/x.svg");
colorize: close-hit.has-hover ? root.title-color : root.text-color;
accessible-role: none;
}
close-hit := TouchArea {
mouse-cursor: pointer;
accessible-role: button;
accessible-label: @tr("Close tab");
accessible-action-default => { TabsUi.close(root.entry.id); }
clicked => { root.close(); }
// The button takes every press over it; other buttons act
// as they do on the rest of the tab.
pointer-event(event) => {
if event.button == PointerEventButton.middle && event.kind == PointerEventKind.up
&& self.mouse-x >= 0px && self.mouse-x < self.width
&& self.mouse-y >= 0px && self.mouse-y < self.height {
root.close();
} else if event.button == PointerEventButton.right && event.kind == PointerEventKind.down {
menu-area.show({ x: parent.x + self.mouse-x, y: parent.y + self.mouse-y });
}
}
}
}
}
Menu {
MenuItem {
title: @tr("New tab");
activated => { TabsUi.create(); }
}
MenuItem {
title: @tr("Reopen closed tab");
enabled: TabsUi.can-reopen;
activated => { TabsUi.reopen(); }
}
MenuSeparator {}
MenuItem {
title: @tr("Close tab");
activated => { TabsUi.close(root.entry.id); }
}
MenuItem {
title: @tr("Close other tabs");
enabled: root.count > 1;
activated => { TabsUi.close-others(root.entry.id); }
}
MenuItem {
title: @tr("Close tabs to the right");
enabled: root.index < root.count - 1;
activated => { TabsUi.close-to-right(root.entry.id); }
}
}
}
// The full title of a shortened tab, after a short hover.
Timer {
interval: 500ms;
running: root.hovered && root.truncated && !root.tooltip-due && !root.tooltip-dismissed;
triggered => { root.tooltip-due = true; }
}
if root.tooltip-due && root.hovered && root.truncated && !root.tooltip-dismissed : Rectangle {
// Below the tab, kept inside the window.
x: max(8px - root.absolute-position.x,
min(8px, WindowChrome.window-width - root.absolute-position.x - self.width - 8px));
y: parent.height + 4px;
width: min(tooltip-label.preferred-width + 16px, 480px);
height: 28px;
border-radius: 5px;
background: #292929;
tooltip-label := Text {
x: 8px;
width: parent.width - 16px;
text: root.title;
color: white;
font-size: TypeScale.secondary;
vertical-alignment: center;
overflow: elide;
}
}
}
// The new-tab button. Like the tabs, it leaves keyboard focus where it is.
component NewTabButton inherits Rectangle {
in property <color> hover-background;
in property <color> icon-color;
out property <bool> hovered: hit.has-hover;
width: 32px;
height: 32px;
border-radius: LayoutMetrics.corner-radius - 2px;
background: hit.has-hover ? root.hover-background : transparent;
animate background { duration: MotionSettings.enabled ? 120ms : 0ms; easing: ease-out; }
Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 16px;
height: 16px;
source: @image-url("../icons/lucide/outline/plus.svg");
colorize: root.icon-color;
accessible-role: none;
}
hit := TouchArea {
mouse-cursor: pointer;
accessible-role: button;
accessible-label: @tr("New tab");
accessible-action-default => { TabsUi.create(); }
clicked => { TabsUi.create(); }
}
if hit.has-hover : Rectangle {
// Centred below the button, kept inside the window.
x: max(8px - root.absolute-position.x,
min((root.width - self.width) / 2,
WindowChrome.window-width - root.absolute-position.x - self.width - 8px));
y: parent.height + 6px;
width: tooltip-label.preferred-width + 16px;
height: 28px;
border-radius: 5px;
background: #292929;
tooltip-label := Text {
text: @tr("New tab");
color: white;
font-size: TypeScale.secondary;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// Desktop top bar with the tabs. It spans the window above the rail and the
// sidebar: leading window controls, the profile pill, then the tabs.
// Trailing controls sit in its right corner.
export component WindowTopBar inherits Rectangle {
// The active tab's live place: "settings" or a workspace, and its title.
in property <string> live-workspace;
in property <string> live-title;
in property <color> background-color;
// The color the active tab opens into: the panes' shared surface below.
in property <color> active-tab-background;
in property <color> hover-background;
in property <color> divider-color;
in property <color> title-color;
in property <color> text-color;
in property <color> accent-color;
in property <[MailProfileRow]> profiles;
in property <MailProfileRow> active-profile;
in property <int> account-count;
callback choose-profile(string);
callback create-profile();
callback manage-profiles();
background: root.background-color;
accessible-role: window-title-bar;
accessible-label: @tr("Flectar Mail");
// The pill follows the leading window controls, and the tabs the pill.
private property <length> pill-x: max(8px, WindowChrome.leading-width);
// The first tab stands 8px from the pill, about the pill's own inset from
// the bar's edges; its foot curves out below the pill's end. Tabs start
// and end on whole pixels, so their borders stay sharp.
private property <length> tabs-x: ((profile-pill.x + profile-pill.width) / 1px).ceil() * 1px + 8px;
private property <length> trailing: WindowChrome.trailing-width + 8px;
private property <int> count: TabsUi.rows.length;
// Tabs share the room up to the new-tab button. In the integrated frame
// some of the bar stays free to drag the window by, however many tabs.
// At the narrowest desktop width, 32 tabs still fit as icons.
private property <length> drag-room: WindowChrome.controls-visible ? 48px : 0px;
private property <length> tabs-room: max(0px, root.width - root.tabs-x - 44px - root.drag-room - root.trailing);
private property <length> fitted-slot-width: root.count == 0 ? 0px
: max(22px, min(240px, (root.tabs-room / root.count / 1px).floor() * 1px));
// Tabs closed one after another keep their width, and the strip its
// length, while the pointer stays on it or on "+", so the next close
// button is still under the pointer. Tabs opened meanwhile still fit.
private property <length> held-slot-width: 0px;
private property <length> held-strip-width: 0px;
private property <length> slot-width: root.held-slot-width > 0px
? min(root.held-slot-width, root.fitted-slot-width) : root.fitted-slot-width;
private property <bool> strip-hovered: strip.has-hover || new-tab.hovered;
changed strip-hovered => {
if !root.strip-hovered {
root.held-slot-width = 0px;
root.held-strip-width = 0px;
}
}
// The dragged tab, its travel, and the slot it would land in.
private property <int> drag-index: -1;
private property <length> drag-travel;
private property <int> drop-index: root.drag-index < 0 ? -1
: max(0, min(root.count - 1, round(root.drag-index + root.drag-travel / root.slot-width)));
// A tab closed during a drag may have been the dragged one.
changed count => {
root.drag-index = -1;
root.drag-travel = 0px;
}
// The hovered tab hides its rules like the active one.
private property <int> hovered-index: -1;
if WindowChrome.controls-visible : WindowDragArea {
width: parent.width;
height: parent.height;
}
// The bottom border runs under every tab except the active one.
Rectangle {
y: parent.height - 1px;
height: 1px;
background: root.divider-color;
}
profile-pill := ProfileSwitcher {
x: root.pill-x;
y: TabMetrics.middle(parent.height) - self.height / 2;
profiles: root.profiles;
active: root.active-profile;
account-count: root.account-count;
choose(id) => { root.choose-profile(id); }
create-profile => { root.create-profile(); }
manage-profiles => { root.manage-profiles(); }
}
// With tabs turned off the bar keeps the profile pill and, in the
// integrated frame, the window controls.
strip := TouchArea {
visible: TabsUi.enabled;
x: root.tabs-x;
width: max(root.count * root.slot-width, min(root.held-strip-width, root.tabs-room));
height: parent.height;
accessible-role: tab-list;
accessible-label: @tr("Tabs");
accessible-item-count: root.count;
for tab-row[index] in TabsUi.rows : TabItem {
x: index * root.slot-width;
width: root.slot-width;
height: parent.height;
entry: tab-row;
index: index;
count: root.count;
live-workspace: root.live-workspace;
live-title: root.live-title;
reordering: root.drag-index >= 0;
shift: root.drag-index < 0 || index == root.drag-index ? 0px
: index > root.drag-index && index <= root.drop-index ? -root.slot-width
: index < root.drag-index && index >= root.drop-index ? root.slot-width
: 0px;
// Rules only separate two resting tabs.
divided: index < root.count - 1 && root.drag-index < 0
&& !tab-row.active && !TabsUi.rows[index + 1].active
&& root.hovered-index != index && root.hovered-index != index + 1;
title-color: root.title-color;
text-color: root.text-color;
accent-color: root.accent-color;
active-background: root.active-tab-background;
hover-background: root.hover-background;
divider-color: root.divider-color;
changed hovered => {
if self.hovered {
root.hovered-index = index;
} else if root.hovered-index == index {
root.hovered-index = -1;
}
}
dragged(travel) => {
root.drag-index = travel == 0px ? -1 : index;
root.drag-travel = travel;
}
dropped(travel) => {
root.drag-index = index;
root.drag-travel = travel;
let target = root.drop-index;
root.drag-index = -1;
root.drag-travel = 0px;
if target != index {
TabsUi.move(tab-row.id, target);
}
}
closing => {
root.held-slot-width = root.slot-width;
root.held-strip-width = strip.width;
}
}
}
new-tab := NewTabButton {
visible: TabsUi.enabled;
x: root.tabs-x + strip.width + TabMetrics.foot;
y: TabMetrics.middle(parent.height) - self.height / 2;
hover-background: root.hover-background;
icon-color: root.text-color;
}
}