mirror of
https://github.com/flectar/mail.git
synced 2026-11-11 17:57:54 +00:00
343 lines
15 KiB
Plaintext
343 lines
15 KiB
Plaintext
import { ScrollView } from "std-widgets.slint";
|
|
import { AppTheme, LayoutMetrics, MailIconPalette } from "../design-system.slint";
|
|
import { TypeScale } from "../typography.slint";
|
|
import { LineEdit, ToolbarIconButton } from "../components/controls.slint";
|
|
|
|
// One destination in the Move to picker, or a heading above a group of them.
|
|
export struct MoveTargetRow {
|
|
// What `MovePickerUi.choose` takes; -1 on headings.
|
|
key: int,
|
|
// inbox, archive, spam, trash or folder; on headings, recent, folders,
|
|
// or the account of the rows below: account, device or unified.
|
|
kind: string,
|
|
// The name shown, which for standard mailboxes is translated; empty on
|
|
// headings, which their kind names.
|
|
label: string,
|
|
// Where the folder is, where indentation doesn't show it.
|
|
detail: string,
|
|
depth: int,
|
|
label_has_emoji: bool,
|
|
}
|
|
|
|
// The non-drag way to move mail into a folder (WCAG 2.5.7), and to copy it.
|
|
// Rust lists the destinations when the picker opens and as the search
|
|
// changes; choosing one for a move does what dropping the mail on that
|
|
// sidebar row does.
|
|
export global MovePickerUi {
|
|
in-out property <bool> open: false;
|
|
// move or copy.
|
|
in property <string> mode: "move";
|
|
in property <[MoveTargetRow]> rows: [];
|
|
in-out property <string> query: "";
|
|
// Where focus goes back to once the picker closes: list or reader.
|
|
in-out property <string> return-to: "list";
|
|
// Opens the picker to move or copy mail of "row" (a list row, with the
|
|
// rows checked along with it), "checked" (the checked rows) or "open"
|
|
// (the open mail).
|
|
callback request(/* mode */ string, /* source */ string, /* email id */ int);
|
|
callback search(string);
|
|
callback choose(/* key */ int);
|
|
|
|
// What a row shows, which search matches too: a standard mailbox's or a
|
|
// heading's translated name, or the folder's own.
|
|
public pure function display-name(kind: string, label: string) -> string {
|
|
if kind == "inbox" { return @tr("Inbox"); }
|
|
if kind == "archive" { return @tr("Archive"); }
|
|
if kind == "spam" { return @tr("Spam"); }
|
|
if kind == "trash" { return @tr("Trash"); }
|
|
if kind == "recent" { return @tr("Recent"); }
|
|
if kind == "folders" { return @tr("All folders"); }
|
|
if kind == "device" { return @tr("On this device"); }
|
|
if kind == "unified" { return @tr("All accounts"); }
|
|
label
|
|
}
|
|
}
|
|
|
|
export component MoveDialog inherits Rectangle {
|
|
in property <bool> compact: false;
|
|
|
|
private property <[MoveTargetRow]> rows: MovePickerUi.rows;
|
|
private property <length> row-height: root.compact ? LayoutMetrics.touch-target : 34px;
|
|
private property <length> indent: 16px;
|
|
private property <int> highlighted: -1;
|
|
private property <length> scroll-y: 0px;
|
|
private property <length> list-height: 0px;
|
|
// The card keeps the size it opens with, so the search field stays put
|
|
// while the list narrows.
|
|
private property <int> opened-rows: 0;
|
|
// Everything on the card but the rows.
|
|
private property <length> chrome-height: root.compact ? 136px : 124px;
|
|
|
|
background: #00000066;
|
|
forward-focus: search-field;
|
|
accessible-role: groupbox;
|
|
accessible-label: MovePickerUi.mode == "copy" ? @tr("Copy to") : @tr("Move to");
|
|
|
|
// A heading is always followed by a destination, so one step past it
|
|
// reaches one.
|
|
function first-choice() -> int {
|
|
if root.rows.length == 0 { return -1; }
|
|
root.rows[0].key >= 0 ? 0 : 1
|
|
}
|
|
|
|
function reset() {
|
|
root.scroll-y = 0px;
|
|
root.highlighted = root.first-choice();
|
|
}
|
|
|
|
// Moves the highlight `steps` rows, on past a heading it lands on, and
|
|
// stops at either end.
|
|
function move-highlight(steps: int) {
|
|
if root.rows.length == 0 { return; }
|
|
let last = root.rows.length - 1;
|
|
let landed = root.highlighted < 0
|
|
? root.first-choice()
|
|
: max(0, min(last, root.highlighted + steps));
|
|
// A heading at the top leads into its destinations.
|
|
let past = root.rows[landed].key >= 0 ? landed
|
|
: steps < 0 && landed > 0 ? landed - 1
|
|
: landed + 1;
|
|
root.highlighted = past > last ? root.highlighted
|
|
: root.rows[past].key >= 0 ? past
|
|
: root.first-choice();
|
|
root.bring-highlight-into-view();
|
|
}
|
|
|
|
function bring-highlight-into-view() {
|
|
if root.highlighted < 0 { return; }
|
|
// A heading just above the highlight scrolls in with it.
|
|
let top = root.highlighted > 0 && root.rows[root.highlighted - 1].key < 0
|
|
? root.highlighted - 1 : root.highlighted;
|
|
let item-y = 4px + top * root.row-height;
|
|
let item-bottom = 4px + (root.highlighted + 1) * root.row-height;
|
|
if item-y + root.scroll-y < 0px {
|
|
root.scroll-y = -item-y;
|
|
} else if item-bottom + root.scroll-y > root.list-height {
|
|
root.scroll-y = root.list-height - item-bottom;
|
|
}
|
|
}
|
|
|
|
function choose-highlighted() {
|
|
if root.highlighted < 0 || root.highlighted >= root.rows.length { return; }
|
|
let key = root.rows[root.highlighted].key;
|
|
if key < 0 { return; }
|
|
MovePickerUi.open = false;
|
|
MovePickerUi.choose(key);
|
|
}
|
|
|
|
changed rows => { root.reset(); }
|
|
init => {
|
|
root.opened-rows = root.rows.length;
|
|
root.reset();
|
|
search-field.focus();
|
|
}
|
|
|
|
// A click outside the card closes the picker.
|
|
TouchArea {
|
|
clicked => { MovePickerUi.open = false; }
|
|
}
|
|
|
|
card := Rectangle {
|
|
width: root.compact ? root.width - 24px : min(440px, root.width - 32px);
|
|
height: min(root.height - (root.compact ? 48px : 64px),
|
|
root.chrome-height + max(4, root.opened-rows) * root.row-height);
|
|
x: (root.width - self.width) / 2;
|
|
y: (root.height - self.height) / 2;
|
|
border-radius: 12px;
|
|
border-width: 1px;
|
|
border-color: AppTheme.border;
|
|
background: AppTheme.surface-raised;
|
|
drop-shadow-blur: 18px;
|
|
drop-shadow-offset-y: 5px;
|
|
drop-shadow-color: #0000002e;
|
|
clip: true;
|
|
|
|
// Swallows clicks so only the backdrop closes the picker.
|
|
TouchArea { }
|
|
|
|
keys := FocusScope {
|
|
// Arrows, Return and Escape steer the list while the search
|
|
// field keeps every other key.
|
|
capture-key-pressed(event) => {
|
|
if event.text == Key.DownArrow {
|
|
root.move-highlight(1);
|
|
return accept;
|
|
}
|
|
if event.text == Key.UpArrow {
|
|
root.move-highlight(-1);
|
|
return accept;
|
|
}
|
|
if event.text == Key.PageDown {
|
|
root.move-highlight(max(1, floor(root.list-height / root.row-height) - 1));
|
|
return accept;
|
|
}
|
|
if event.text == Key.PageUp {
|
|
root.move-highlight(-max(1, floor(root.list-height / root.row-height) - 1));
|
|
return accept;
|
|
}
|
|
if event.text == Key.Return {
|
|
root.choose-highlighted();
|
|
return accept;
|
|
}
|
|
if event.text == Key.Escape {
|
|
MovePickerUi.open = false;
|
|
return accept;
|
|
}
|
|
reject
|
|
}
|
|
|
|
VerticalLayout {
|
|
padding: 12px;
|
|
padding-bottom: 8px;
|
|
spacing: 10px;
|
|
|
|
HorizontalLayout {
|
|
spacing: 8px;
|
|
|
|
Text {
|
|
text: MovePickerUi.mode == "copy" ? @tr("Copy to") : @tr("Move to");
|
|
color: AppTheme.title;
|
|
font-size: TypeScale.heading;
|
|
font-weight: 700;
|
|
vertical-alignment: center;
|
|
overflow: elide;
|
|
horizontal-stretch: 1;
|
|
}
|
|
ToolbarIconButton {
|
|
compact: root.compact;
|
|
icon: @image-url("../icons/lucide/outline/x.svg");
|
|
icon-color: AppTheme.secondary;
|
|
hover-background: AppTheme.muted-control-bg;
|
|
tooltip-text: @tr("Close");
|
|
clicked => { MovePickerUi.open = false; }
|
|
}
|
|
}
|
|
|
|
search-field := LineEdit {
|
|
height: root.compact ? LayoutMetrics.touch-target : 36px;
|
|
text <=> MovePickerUi.query;
|
|
placeholder-text: @tr("Search folders");
|
|
input-label: @tr("Search folders");
|
|
show-leading-icon: true;
|
|
leading-icon: @image-url("../icons/lucide/outline/search.svg");
|
|
leading-icon-color: AppTheme.muted;
|
|
edited(value) => { MovePickerUi.search(value); }
|
|
}
|
|
|
|
Rectangle {
|
|
vertical-stretch: 1;
|
|
|
|
folder-list := ScrollView {
|
|
accessible-role: list;
|
|
accessible-label: @tr("Folders");
|
|
accessible-item-count: root.rows.length;
|
|
content-y <=> root.scroll-y;
|
|
content-height: max(self.visible-height, root.rows.length * root.row-height + 8px);
|
|
horizontal-scrollbar-policy: always-off;
|
|
changed visible-height => { root.list-height = self.visible-height; }
|
|
init => { root.list-height = self.visible-height; }
|
|
|
|
for row[index] in root.rows : Rectangle {
|
|
x: 0px;
|
|
y: 4px + index * root.row-height;
|
|
width: folder-list.visible-width;
|
|
height: root.row-height;
|
|
border-radius: 6px;
|
|
background: row.key >= 0 && index == root.highlighted
|
|
? AppTheme.control-bg : transparent;
|
|
|
|
if row.key < 0 : Text {
|
|
x: 10px;
|
|
width: parent.width - 20px;
|
|
height: parent.height - 4px;
|
|
text: MovePickerUi.display-name(row.kind, row.label);
|
|
color: AppTheme.muted;
|
|
font-size: TypeScale.caption;
|
|
font-weight: 600;
|
|
font-family: row.label_has_emoji ? "Noto Emoji" : "";
|
|
vertical-alignment: bottom;
|
|
overflow: elide;
|
|
accessible-role: none;
|
|
}
|
|
|
|
if row.key >= 0 : Rectangle {
|
|
property <length> start: 10px + row.depth * root.indent;
|
|
|
|
icon := Image {
|
|
x: parent.start;
|
|
y: (parent.height - self.height) / 2;
|
|
width: LayoutMetrics.icon-size;
|
|
height: LayoutMetrics.icon-size;
|
|
source: row.kind == "inbox" ? @image-url("../icons/lucide/filled/inbox-flectar.svg")
|
|
: row.kind == "archive" ? @image-url("../icons/lucide/filled/archive.svg")
|
|
: row.kind == "spam" ? @image-url("../icons/lucide/filled/alert.svg")
|
|
: row.kind == "trash" ? @image-url("../icons/lucide/filled/trash.svg")
|
|
: @image-url("../icons/lucide/filled/file-directory.svg");
|
|
colorize: row.kind == "inbox" ? MailIconPalette.inbox
|
|
: row.kind == "archive" ? MailIconPalette.archive
|
|
: row.kind == "spam" ? MailIconPalette.spam
|
|
: row.kind == "trash" ? MailIconPalette.trash
|
|
: MailIconPalette.folders;
|
|
image-fit: contain;
|
|
accessible-role: none;
|
|
}
|
|
name := Text {
|
|
x: icon.x + icon.width + 10px;
|
|
width: min(self.preferred-width, parent.width - self.x - 10px);
|
|
height: parent.height;
|
|
text: row.label;
|
|
color: AppTheme.heading;
|
|
font-size: TypeScale.body;
|
|
font-weight: 500;
|
|
font-family: row.label_has_emoji ? "Noto Emoji" : "";
|
|
vertical-alignment: center;
|
|
overflow: elide;
|
|
accessible-role: none;
|
|
}
|
|
Text {
|
|
x: name.x + name.width + 8px;
|
|
width: max(0px, parent.width - self.x - 10px);
|
|
height: parent.height;
|
|
text: row.detail;
|
|
color: AppTheme.muted;
|
|
font-size: TypeScale.secondary;
|
|
vertical-alignment: center;
|
|
overflow: elide;
|
|
accessible-role: none;
|
|
}
|
|
TouchArea {
|
|
accessible-role: button;
|
|
accessible-label: row.detail == "" ? row.label : row.label + ", " + row.detail;
|
|
accessible-item-index: index;
|
|
accessible-action-default => {
|
|
root.highlighted = index;
|
|
root.choose-highlighted();
|
|
}
|
|
changed has-hover => {
|
|
if self.has-hover { root.highlighted = index; }
|
|
}
|
|
clicked => {
|
|
root.highlighted = index;
|
|
root.choose-highlighted();
|
|
}
|
|
mouse-cursor: pointer;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if root.rows.length == 0 : Text {
|
|
width: parent.width;
|
|
height: 64px;
|
|
text: @tr("No matching folders");
|
|
color: AppTheme.muted;
|
|
font-size: TypeScale.body;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|