diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 6d9802d6..facc55f0 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -1493,6 +1493,10 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { SyncAnnouncements(); }); main->on_refresh_announcements([this] { announcements_.Refresh(); }); + main->on_read_all_announcements([this] { + announcements_.ReadAll(); + SyncAnnouncements(); + }); main->on_open_announcement_link([this](slint::SharedString link) { const std::string url(link); if (announcement_text::IsWebUrl(url)) OpenUrl(url); @@ -2637,6 +2641,7 @@ void GuiApplication::SyncAnnouncements() { revision == ui_->announcement_selected_revision) { selected_found = true; main->set_announcement_selected_id(row.id); + main->set_announcement_selected_revision(row.revision); main->set_announcement_selected_title(row.title); main->set_announcement_selected_body( announcement_text::Format(item["body"].get())); @@ -2648,6 +2653,7 @@ void GuiApplication::SyncAnnouncements() { if (!selected_found) { ui_->announcement_selected_id.clear(); main->set_announcement_selected_id(""); + main->set_announcement_selected_revision(""); main->set_announcement_selected_body(slint::StyledText{}); } main->set_announcements( diff --git a/apps/desktop/src/gui/assets/fonts/generate_message_spacing_font.py b/apps/desktop/src/gui/assets/fonts/generate_message_spacing_font.py new file mode 100644 index 00000000..f333275e --- /dev/null +++ b/apps/desktop/src/gui/assets/fonts/generate_message_spacing_font.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Regenerate message_spacing.ttf with fontTools (only needed for regeneration). + +Slint 1.17 pins line height to the requested font's ascent, descent and leading, +but StyledText does not expose line-height. This metrics-only font supplies 1.5em +line boxes without replacing any visible glyphs: Slint's normal system fallback +fonts still render the text and links. Use this family for message and release-note +bodies without changing the typography of other controls. +""" + +from pathlib import Path + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen + + +def main(): + font = FontBuilder(1000, isTTF=True) + glyphs = [".notdef", "zeroWidthSpace"] + font.setupGlyphOrder(glyphs) + font.setupCharacterMap({0x200B: "zeroWidthSpace"}) + font.setupGlyf({name: TTGlyphPen(None).glyph() for name in glyphs}) + font.setupHorizontalMetrics({name: (0, 0) for name in glyphs}) + font.setupHorizontalHeader(ascent=1000, descent=-250, lineGap=250) + font.setupOS2( + version=4, + sTypoAscender=1000, + sTypoDescender=-250, + sTypoLineGap=250, + usWinAscent=1000, + usWinDescent=250, + fsSelection=0xC0, # Regular, use typographic metrics. + ) + font.setupNameTable({ + "familyName": "CrossDesk Message Spacing", + "styleName": "Regular", + "uniqueFontIdentifier": "CrossDesk-Message-Spacing-1.0", + "fullName": "CrossDesk Message Spacing", + "psName": "CrossDeskMessageSpacing-Regular", + "version": "Version 1.0", + "copyright": "Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.", + }) + font.setupPost() + # Fixed timestamps keep the generated asset reproducible. + font.font["head"].created = font.font["head"].modified = 3874867200 + font.font.recalcTimestamp = False + font.save(Path(__file__).with_name("message_spacing.ttf")) + + +if __name__ == "__main__": + main() diff --git a/apps/desktop/src/gui/assets/fonts/message_spacing.ttf b/apps/desktop/src/gui/assets/fonts/message_spacing.ttf new file mode 100644 index 00000000..a1716c88 Binary files /dev/null and b/apps/desktop/src/gui/assets/fonts/message_spacing.ttf differ diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 52139d78..657d5e83 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -23,10 +23,11 @@ struct TranslationRow { X(messages, u8"消息", "Messages", u8"Сообщения") \ X(message_unread, u8"未读", "Unread", u8"Не прочитано") \ X(message_read, u8"已读", "Read", u8"Прочитано") \ + X(message_read_all, u8"全部已读", "Mark all as read", u8"Отметить всё прочитанным") \ X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \ X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \ X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \ - X(message_read_save_error, u8"无法保存本地阅读状态,请重新打开公告重试。", "Unable to save reading status locally. Open the announcement again to retry.", u8"Не удалось сохранить статус чтения. Откройте объявление снова.") \ + X(message_read_save_error, u8"无法保存本地阅读状态,请重试。", "Unable to save reading status locally. Please try again.", u8"Не удалось сохранить статус чтения. Повторите попытку.") \ X(message_close, u8"关闭", "Close", u8"Закрыть") \ X(message_delete, u8"删除", "Delete", u8"Удалить") \ X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \ diff --git a/apps/desktop/src/gui/runtime/announcement_inbox.cpp b/apps/desktop/src/gui/runtime/announcement_inbox.cpp index 721246e3..1183e7f9 100644 --- a/apps/desktop/src/gui/runtime/announcement_inbox.cpp +++ b/apps/desktop/src/gui/runtime/announcement_inbox.cpp @@ -154,6 +154,29 @@ bool AnnouncementInbox::Read(int64_t id, int64_t revision) { } return false; } +bool AnnouncementInbox::ReadAll() { + std::lock_guard lock(mutex_); + if (catalog_revision_ < 0) return false; + auto updated = reads_; + // Use the complete catalog, including announcements whose bodies have not + // been paged in. Persist once before changing any visible reading state. + for (const auto& [id, revision] : catalog_) { + if (!IsDismissed(id, revision)) updated[id] = revision; + } + if (updated == reads_) return true; + if (!SaveLocalState(updated, dismissed_)) { + state_.read_save_failed = true; + ++state_.version; + return false; + } + reads_ = std::move(updated); + state_.read_save_failed = false; + state_.delete_failed = false; + UpdateReadingState(); + ++state_.version; + return true; +} + bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const { const auto found = dismissed_.find(id); return found != dismissed_.end() && found->second == revision; diff --git a/apps/desktop/src/gui/runtime/announcement_inbox.h b/apps/desktop/src/gui/runtime/announcement_inbox.h index 3fee9093..7efc01f6 100644 --- a/apps/desktop/src/gui/runtime/announcement_inbox.h +++ b/apps/desktop/src/gui/runtime/announcement_inbox.h @@ -38,6 +38,7 @@ class AnnouncementInbox { void Refresh(); void LoadMore(); bool Read(int64_t id, int64_t revision); + bool ReadAll(); bool Dismiss(int64_t id, int64_t revision); nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now()); diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 564ba9bd..405f54fa 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -5,13 +5,14 @@ import { DeviceCardActions, DeviceCardStyle } from "device_card_actions.slint"; import { DeviceGroupDialog, DeviceGroupOption } from "device_group_dialog.slint"; import { GalleryView, GalleryTile, GalleryRecentDevice, GalleryActionDevice } from "gallery_view.slint"; import { GalleryNetworkDialog, GalleryNetworkDevice } from "gallery_network_dialog.slint"; -import { SettingsButton, SettingsSelect, SettingsGroup, SettingsRow, SettingsToggleRow } from "settings_controls.slint"; +import { SettingsDialog, SettingsButton, SettingsSelect, SettingsGroup, SettingsRow, SettingsToggleRow } from "settings_controls.slint"; import { GallerySettings } from "gallery_settings.slint"; import { GroupFilter } from "group_filter.slint"; import { ScrollView } from "std-widgets.slint"; import { RoundedLineEdit } from "rounded_line_edit.slint"; import { AnnouncementItem, MessagePanel, MessageStrings } from "message_panel.slint"; import { FontAwesomeIcons, IconButton, CompactComboBox, ImGuiCheckBox, MiniToggleSwitch, CompactButton, PrimaryButton, SecondaryButton, ToggleSwitch, SectionTitle, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint"; +import "../assets/fonts/message_spacing.ttf"; export struct RecentConnection { remote-id: string, @@ -344,6 +345,7 @@ export component MainWindow inherits Window { in property announcement-loaded; in-out property messages-open: false; in property announcement-selected-id; + in property announcement-selected-revision; in property announcement-selected-title; in property announcement-selected-body; in property announcement-selected-date; @@ -352,6 +354,7 @@ export component MainWindow inherits Window { callback open-announcement-link(string); callback delete-announcement(string, string); callback refresh-announcements; + callback read-all-announcements; callback load-more-announcements; in property update-available: false; in property update-check-status: idle; @@ -500,6 +503,10 @@ export component MainWindow inherits Window { if root.messages-open { focus-sink.focus(); } } + changed about-open => { + if root.about-open { focus-sink.focus(); } + } + reset-remote-id => { root.remote-id-input = ""; remote-id-editor.focus(); @@ -713,6 +720,10 @@ export component MainWindow inherits Window { } return accept; } + if root.about-open && !root.update-open { + if event.text == Key.Escape { root.about-open = false; } + return accept; + } if root.messages-open { if event.text == Key.Escape { root.messages-open = false; } return accept; @@ -1477,14 +1488,14 @@ export component MainWindow inherits Window { } if root.messages-open: Rectangle { - background: #00000042; + background: #15223850; z: 40; TouchArea { clicked => { focus-sink.focus(); } } MessagePanel { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - width: 520px; - height: 330px; + width: min(592px, parent.width - 32px); + height: min(400px, parent.height - 24px); items: root.announcements; unread-count: root.announcement-unread; total: root.announcement-total; loading: root.announcement-loading; failed: root.announcement-failed; loaded: root.announcement-loaded; @@ -1492,12 +1503,14 @@ export component MainWindow inherits Window { delete-failed: root.announcement-delete-failed; connected: root.signal-connected; selected-id: root.announcement-selected-id; selected-title: root.announcement-selected-title; + selected-revision: root.announcement-selected-revision; selected-body: root.announcement-selected-body; selected-date: root.announcement-selected-date; selected-read: root.announcement-selected-read; open-message(id, revision) => { root.open-announcement(id, revision); } open-link(url) => { root.open-announcement-link(url); } delete-message(id, revision) => { root.delete-announcement(id, revision); } load-more => { root.load-more-announcements(); } + read-all => { root.read-all-announcements(); } close => { root.messages-open = false; } } } @@ -1941,113 +1954,107 @@ export component MainWindow inherits Window { } if root.about-open: Rectangle { - background: transparent; + background: #15223850; z: 46; TouchArea { clicked => { focus-sink.focus(); } } - DialogSurface { + SettingsDialog { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - width: 320px; - height: max(216px, about-content.preferred-height); - border-radius: UiRadius.surface; + width: min(400px, parent.width - 40px); + height: self.content-top + about-content.preferred-height + 24px; + title: UiStrings.about; + close-label: MessageStrings.close; + close => { root.about-open = false; } - Rectangle { - x: 0px; - y: 0px; - width: parent.width; - height: 21px; - background: #d3d3d3; - border-top-left-radius: UiRadius.surface; - border-top-right-radius: UiRadius.surface; - z: 5; - Text { x: 6px; text: UiStrings.about; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } - } - // Grow for update actions and wrapped translations while keeping - // the footer below the version and check result. + // Content-driven height keeps translated update states and actions + // above the footer without reserving an empty update section. about-content := VerticalLayout { - padding: 16px; - padding-top: 35px; - spacing: 12px; - alignment: space-between; - - VerticalLayout { - vertical-stretch: 0; - spacing: 4px; - Text { - text: "CrossDesk " + root.current-version; - color: #202124; - font-size: ImGuiFontStyle.base; - font-weight: 600; - min-height: 18px; - overflow: elide; + x: 24px; y: parent.content-top; width: parent.width - 48px; + height: self.preferred-height; + padding: 0px; spacing: 20px; + Rectangle { + height: max(96px, product-info.preferred-height + 32px); + background: WorkbenchStyle.surface; + border-radius: UiRadius.surface; + border-width: 1px; border-color: WorkbenchStyle.border; + Image { + x: 16px; y: (parent.height - self.height) / 2; + width: 56px; height: 56px; + source: @image-url("../../../resources/linux/crossdesk_128x128.png"); + image-fit: contain; } - if root.update-available: Rectangle { - height: max(18px, version-link.preferred-height); - accessible-role: button; - accessible-label: UiStrings.release-notes + " " + root.latest-version; - accessible-action-default => { version-touch.clicked(); } - version-link := Text { - x: 0px; - y: 0px; - width: parent.width; - height: parent.height; - text: UiStrings.update-available + ": " + root.latest-version; - color: version-touch.has-hover ? #174ea6 : #334fd1; - font-size: ImGuiFontStyle.body; - vertical-alignment: center; - horizontal-alignment: left; - wrap: word-wrap; - } - version-touch := TouchArea { - mouse-cursor: pointer; - clicked => { root.about-open = false; root.update-open = true; } + product-info := VerticalLayout { + x: 88px; y: 16px; width: parent.width - 104px; height: parent.height - 32px; + alignment: center; spacing: 5px; + Text { text: "CrossDesk"; font-size: 22px; font-weight: 600; color: WorkbenchStyle.text; } + Text { + text: UiStrings.version + " " + root.current-version; + color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap; } } - if root.update-available: HorizontalLayout { - alignment: start; - padding-top: 4px; - CompactButton { - width: max(76px, release-notes-button-metrics.preferred-width + 20px); - text: UiStrings.view-release-notes; + } + if root.update-available || root.update-status-text != "": Rectangle { + height: update-summary.preferred-height + 24px; + background: root.update-check-status == UpdateCheckStatus.failed ? #fef2f2 : WorkbenchStyle.field-surface; + border-radius: UiRadius.control; + border-width: 1px; + border-color: root.update-check-status == UpdateCheckStatus.failed ? #f3d4d1 : WorkbenchStyle.separator; + update-summary := VerticalLayout { + x: 12px; y: 12px; width: parent.width - 24px; + height: self.preferred-height; + padding: 0px; spacing: 10px; + if root.update-available: Rectangle { + height: version-link.preferred-height; accessible-role: button; - accessible-label: self.text; - accessible-action-default => { self.clicked(); } - clicked => { root.about-open = false; root.update-open = true; } - release-notes-button-metrics := Text { visible: false; text: UiStrings.view-release-notes; font-size: ImGuiFontStyle.body; } + accessible-label: UiStrings.release-notes + " " + root.latest-version; + accessible-action-default => { version-touch.clicked(); } + version-link := Text { + width: parent.width; + text: UiStrings.update-available + ": " + root.latest-version; + color: version-touch.has-hover ? #254cc0 : WorkbenchStyle.accent; + font-size: 13px; font-weight: 500; wrap: word-wrap; + } + version-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.about-open = false; root.update-open = true; } + } + } + if root.update-status-text != "": Text { + text: root.update-status-text; + color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e + : root.update-check-status == UpdateCheckStatus.up-to-date ? WorkbenchStyle.success : WorkbenchStyle.muted; + font-size: 12px; wrap: word-wrap; + } + if root.update-available: HorizontalLayout { + alignment: start; + SettingsButton { + width: max(116px, release-notes-button-metrics.preferred-width + 24px); + text: UiStrings.view-release-notes; + clicked => { root.about-open = false; root.update-open = true; } + release-notes-button-metrics := Text { visible: false; text: UiStrings.view-release-notes; font-size: 12px; } + } } } - if root.update-status-text != "": Text { - text: root.update-status-text; - color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e : #61656c; - font-size: ImGuiFontStyle.body; - min-height: 18px; - wrap: word-wrap; - } } - VerticalLayout { - vertical-stretch: 0; - spacing: 3px; - Rectangle { height: 1px; background: #e6e8eb; } - Text { height: 16px; text: UiStrings.copyright; color: #61656c; font-size: ImGuiFontStyle.body; vertical-alignment: center; } - Text { height: 16px; text: UiStrings.license; color: #61656c; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + spacing: 5px; + Text { text: UiStrings.copyright; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; } + Text { text: UiStrings.license; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; } } - - HorizontalLayout { - vertical-stretch: 0; - alignment: end; - spacing: 8px; - CompactButton { - width: max(76px, check-button-metrics.preferred-width + 20px); - text: UiStrings.check-for-updates; - enabled: root.update-check-status != UpdateCheckStatus.checking; - accessible-role: button; - accessible-label: self.text; - accessible-action-default => { if self.enabled { root.check-for-updates(); } } - clicked => { root.check-for-updates(); } - check-button-metrics := Text { visible: false; text: UiStrings.check-for-updates; font-size: ImGuiFontStyle.body; } + VerticalLayout { + spacing: 16px; + Rectangle { height: 1px; background: WorkbenchStyle.separator; } + HorizontalLayout { + alignment: end; spacing: 8px; + SettingsButton { + width: max(116px, check-button-metrics.preferred-width + 24px); + text: UiStrings.check-for-updates; + enabled: root.update-check-status != UpdateCheckStatus.checking; + clicked => { root.check-for-updates(); } + check-button-metrics := Text { visible: false; text: UiStrings.check-for-updates; font-size: 12px; } + } + SettingsButton { width: 80px; primary: true; text: UiStrings.ok; clicked => { root.about-open = false; } } } - CompactButton { width: 48px; primary: true; text: UiStrings.ok; clicked => { root.about-open = false; } } } } } @@ -2151,6 +2158,8 @@ export component MainWindow inherits Window { text: block.content; default-color: WorkbenchStyle.text; default-font-size: 13px; + // Match the 1.5em line height of message bodies. + default-font-family: "CrossDesk Message Spacing"; } } } diff --git a/apps/desktop/src/gui/ui/message_panel.slint b/apps/desktop/src/gui/ui/message_panel.slint index 4713dbcb..59c44685 100644 --- a/apps/desktop/src/gui/ui/message_panel.slint +++ b/apps/desktop/src/gui/ui/message_panel.slint @@ -1,6 +1,9 @@ import { UiRadius } from "ui_radius.slint"; import { ScrollView } from "std-widgets.slint"; -import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint"; +import { IconButton, FontAwesomeIcons } from "common.slint"; +import { SettingsDialog, SettingsButton } from "settings_controls.slint"; +import { WorkbenchStyle } from "workbench_controls.slint"; +import "../assets/fonts/message_spacing.ttf"; export struct AnnouncementItem { id: string, @@ -14,10 +17,11 @@ export global MessageStrings { in-out property messages: "Messages"; in-out property unread: "Unread"; in-out property read: "Read"; + in-out property read-all: "Mark all as read"; in-out property empty: "No announcements"; in-out property select-message: "Select an announcement to read"; in-out property loading: "Loading…"; - in-out property read-save-error: "Unable to save reading status locally. Open the announcement again to retry."; + in-out property read-save-error: "Unable to save reading status locally. Please try again."; in-out property close: "Close"; in-out property delete-message: "Delete"; in-out property delete-title: "Delete announcement"; @@ -26,7 +30,7 @@ export global MessageStrings { in-out property cancel: "Cancel"; } -export component MessagePanel inherits DialogSurface { +export component MessagePanel inherits SettingsDialog { in property <[AnnouncementItem]> items; in property unread-count; in property total; @@ -37,6 +41,7 @@ export component MessagePanel inherits DialogSurface { in property loaded; in property connected; in property selected-id; + in property selected-revision; in property selected-title; in property selected-body; in property selected-date; @@ -45,11 +50,9 @@ export component MessagePanel inherits DialogSurface { callback open-link(string); callback delete-message(string, string); callback load-more; - callback close; + callback read-all; private property notice-text: root.delete-failed ? MessageStrings.delete-error : root.read-save-failed ? MessageStrings.read-save-error : ""; - private property content-top: title-bar.height + (root.notice-text == "" ? 14px : 50px); - private property separator-color: #e6e8eb; private property pending-delete-id; private property pending-delete-revision; function request-delete(id: string, revision: string) { @@ -68,136 +71,151 @@ export component MessagePanel inherits DialogSurface { return reject; } } - background: white; - border-radius: UiRadius.surface; - TouchArea { } - title-bar := Rectangle { - x: 0px; y: 0px; width: parent.width; height: 21px; - background: #d3d3d3; border-top-left-radius: UiRadius.surface; border-top-right-radius: UiRadius.surface; - message-heading := Text { - x: 6px; width: parent.width - 100px; height: parent.height; - text: MessageStrings.messages; - font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; - vertical-alignment: center; overflow: elide; - } - if root.unread-count > 0: Rectangle { - x: message-heading.x + message-heading.preferred-width + 8px; - y: (parent.height - self.height) / 2; height: 16px; - width: max(16px, unread-count-label.preferred-width + 8px); - border-radius: 8px; background: #eaf2ff; - accessible-role: text; - accessible-label: MessageStrings.unread + " " + root.unread-count; - unread-count-label := Text { - text: "" + root.unread-count; color: #2563eb; - font-size: ImGuiFontStyle.small; font-weight: 600; - horizontal-alignment: center; vertical-alignment: center; - accessible-role: none; - } - } - if root.loading: Text { - x: parent.width - 150px; width: 110px; height: parent.height; - text: MessageStrings.loading; color: #61656c; font-size: ImGuiFontStyle.small; - horizontal-alignment: right; vertical-alignment: center; - } - IconButton { - x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px; - border-radius: UiRadius.control; - icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base; - icon-color: #202124; - hover-background: #bfc2c7; pressed-background: #b2b2b2; - accessible-role: button; - accessible-label: MessageStrings.close; - accessible-action-default => { root.close(); } - clicked => { root.close(); } + title: MessageStrings.messages; + close-label: MessageStrings.close; + badge-count: root.unread-count; + badge-label: MessageStrings.unread + " " + root.unread-count; + header-status: root.loading ? MessageStrings.loading : ""; + action-text: MessageStrings.read-all; + action-enabled: root.loaded && !root.loading && root.unread-count > 0; + action => { root.read-all(); } + notice := Rectangle { + x: 24px; y: root.content-top; width: parent.width - 48px; + height: root.notice-text == "" ? 0px : notice-label.preferred-height + 20px; + visible: root.notice-text != ""; + border-radius: UiRadius.control; background: #fef2f2; + notice-label := Text { + x: 10px; y: 10px; width: parent.width - 20px; + height: self.preferred-height; + text: root.notice-text; font-size: 12px; wrap: word-wrap; color: #b3261e; } } - if root.notice-text != "": Rectangle { - x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px; - border-radius: UiRadius.surface; - background: #fef2f2; - Text { - x: 8px; width: parent.width - 16px; height: parent.height; - text: root.notice-text; font-size: ImGuiFontStyle.body; overflow: elide; - color: #b3261e; - vertical-alignment: center; + message-content := Rectangle { + x: 24px; y: root.content-top + (root.notice-text == "" ? 0px : notice.height + 12px); + width: parent.width - 48px; height: parent.height - self.y - 24px; + background: WorkbenchStyle.surface; + border-radius: UiRadius.surface; border-width: 1px; border-color: WorkbenchStyle.border; + if root.items.length > 0 || root.selected-id != "": Rectangle { + x: 1px; y: 1px; width: content-divider.x - 1px; height: parent.height - 2px; + background: WorkbenchStyle.field-surface; + border-top-left-radius: UiRadius.surface - 1px; + border-bottom-left-radius: UiRadius.surface - 1px; } - } - announcement-list := Rectangle { - x: 16px; y: root.content-top; width: 168px; height: parent.height - self.y - 16px; - announcements-scroll := ScrollView { - width: parent.width; height: parent.height; - VerticalLayout { - width: announcements-scroll.width; spacing: 0px; padding: 0px; - padding-right: 8px; - alignment: start; - for item[index] in root.items: Rectangle { - height: 54px; - background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent; - border-radius: UiRadius.control; - Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; } - if !item.read: Rectangle { - x: parent.width - 17px; y: 13px; width: 5px; height: 5px; - border-radius: 2.5px; background: #2563eb; - accessible-role: none; - } - Text { x: 8px; y: 28px; width: parent.width - 36px; height: 16px; text: item.date; color: #61656c; font-size: ImGuiFontStyle.small; overflow: elide; vertical-alignment: center; } - accessible-role: button; - accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread); - accessible-action-default => { root.open-message(item.id, item.revision); } - entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } } - IconButton { - x: parent.width - 24px; y: 26px; width: 20px; height: 20px; - icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.small; - icon-color: #61656c; danger: true; + announcement-list := Rectangle { + x: 8px; y: 8px; width: 176px; height: parent.height - 16px; + announcements-scroll := ScrollView { + width: parent.width; height: parent.height; + viewport-width: self.width - 10px; + viewport-height: announcement-entries.preferred-height; + horizontal-scrollbar-policy: always-off; + vertical-scrollbar-policy: as-needed; + announcement-entries := VerticalLayout { + width: announcements-scroll.viewport-width; spacing: 10px; padding: 0px; + alignment: start; + for item in root.items: Rectangle { + height: 64px; + background: root.selected-id == item.id ? #e8efff + : entry-touch.pressed ? WorkbenchStyle.pressed-surface + : entry-touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface; + border-radius: UiRadius.control; + border-width: 1px; + border-color: root.selected-id == item.id ? #b5c9f5 : WorkbenchStyle.border; + Text { + x: 10px; y: 9px; width: parent.width - 30px; height: 20px; + text: item.title; overflow: elide; font-size: 13px; + font-weight: item.read ? 400 : 600; + color: root.selected-id == item.id ? WorkbenchStyle.accent : WorkbenchStyle.text; + vertical-alignment: center; + } + if !item.read: Rectangle { + x: parent.width - 15px; y: 16px; width: 6px; height: 6px; + border-radius: 3px; background: WorkbenchStyle.accent; + accessible-role: none; + } + Text { + x: 10px; y: 35px; width: parent.width - 20px; height: 18px; + text: item.date; color: WorkbenchStyle.muted; font-size: 11px; + overflow: elide; vertical-alignment: center; + } accessible-role: button; - accessible-label: MessageStrings.delete-message + " " + item.title; - accessible-action-default => { root.request-delete(item.id, item.revision); } - clicked => { root.request-delete(item.id, item.revision); } - } - if index < root.items.length - 1: Rectangle { - x: 8px; y: parent.height - 1px; - width: parent.width - 16px; height: 1px; - background: root.separator-color; - accessible-role: none; + accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread); + accessible-action-default => { root.open-message(item.id, item.revision); } + entry-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.open-message(item.id, item.revision); } + } } } } } - } - if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text { - x: 16px; y: root.content-top; width: parent.width - 32px; height: announcement-list.height; - text: MessageStrings.empty; wrap: word-wrap; - color: #61656c; font-size: ImGuiFontStyle.body; - horizontal-alignment: center; vertical-alignment: center; - } - content-divider := Rectangle { - x: announcement-list.x + announcement-list.width + 12px; - y: root.content-top; width: 1px; height: announcement-list.height; - background: root.separator-color; - visible: root.items.length > 0 || root.selected-id != ""; - accessible-role: none; - } - announcement-details := Rectangle { - x: content-divider.x + content-divider.width + 12px; - y: root.content-top; width: parent.width - self.x - 16px; height: announcement-list.height; - if root.selected-id == "" && root.items.length > 0: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.select-message; color: #61656c; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; } - if root.selected-id != "": details-scroll := ScrollView { - width: parent.width; height: parent.height; - VerticalLayout { - width: details-scroll.width; padding: 8px; padding-top: 6px; padding-right: 16px; - spacing: 12px; alignment: start; - VerticalLayout { - vertical-stretch: 0; spacing: 4px; - Text { vertical-stretch: 0; min-height: 18px; text: root.selected-title; wrap: word-wrap; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } - Text { vertical-stretch: 0; min-height: 16px; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #61656c; font-size: ImGuiFontStyle.small; wrap: word-wrap; vertical-alignment: center; } - } - Rectangle { height: 1px; background: root.separator-color; } - StyledText { - vertical-stretch: 0; - text: root.selected-body; - default-color: #202124; default-font-size: ImGuiFontStyle.body; - link-color: #2563eb; - link-clicked(url) => { root.open-link(url); } + if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text { + x: 24px; width: parent.width - 48px; height: parent.height; + text: MessageStrings.empty; wrap: word-wrap; + color: WorkbenchStyle.muted; font-size: 13px; + horizontal-alignment: center; vertical-alignment: center; + } + content-divider := Rectangle { + x: announcement-list.x + announcement-list.width + 8px; + y: 1px; width: 1px; height: parent.height - 2px; + background: WorkbenchStyle.separator; + visible: root.items.length > 0 || root.selected-id != ""; + accessible-role: none; + } + announcement-details := Rectangle { + x: content-divider.x + 17px; y: 16px; + width: parent.width - self.x - 16px; height: parent.height - 32px; + clip: true; + if root.selected-id == "" && root.items.length > 0: Text { + width: parent.width; height: parent.height; + text: MessageStrings.select-message; color: WorkbenchStyle.muted; font-size: 13px; + wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; + } + if root.selected-id != "": details-scroll := ScrollView { + width: parent.width; height: parent.height; + viewport-width: self.width - 12px; + viewport-height: details-content.preferred-height; + horizontal-scrollbar-policy: always-off; + vertical-scrollbar-policy: as-needed; + details-content := VerticalLayout { + width: details-scroll.viewport-width; padding: 0px; padding-bottom: 4px; + spacing: 14px; alignment: start; + Rectangle { + height: max(28px, selected-heading.preferred-height); + selected-heading := VerticalLayout { + x: 0px; y: 0px; + width: parent.width - 40px; height: self.preferred-height; + padding: 0px; spacing: 6px; + Text { + vertical-stretch: 0; text: root.selected-title; wrap: word-wrap; + font-size: 16px; font-weight: 600; color: WorkbenchStyle.text; + } + Text { + vertical-stretch: 0; text: root.selected-date; + accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); + color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; + } + } + IconButton { + x: parent.width - self.width; y: 0px; width: 28px; height: 28px; + icon: FontAwesomeIcons.trash-can; icon-size: 12px; + icon-color: WorkbenchStyle.muted; danger: true; + accessible-role: button; + accessible-label: MessageStrings.delete-message + " " + root.selected-title; + accessible-action-default => { root.request-delete(root.selected-id, root.selected-revision); } + clicked => { root.request-delete(root.selected-id, root.selected-revision); } + } + } + Rectangle { height: 1px; background: WorkbenchStyle.separator; } + StyledText { + vertical-stretch: 0; + text: root.selected-body; + default-color: WorkbenchStyle.text; default-font-size: 13px; + // Metrics-only family provides 1.5em line height; visible + // glyphs continue to use Slint's normal font fallback. + default-font-family: "CrossDesk Message Spacing"; + link-color: WorkbenchStyle.accent; + link-clicked(url) => { root.open-link(url); } + } } } } @@ -211,30 +229,31 @@ export component MessagePanel inherits DialogSurface { triggered => { root.load-more(); } } if root.pending-delete-id != "": Rectangle { - background: #00000042; border-radius: root.border-radius; z: 30; + background: #15223850; border-radius: root.border-radius; z: 30; TouchArea { clicked => { confirmation-keys.focus(); } } - DialogSurface { + SettingsDialog { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; - width: 230px; height: confirmation-content.preferred-height; - border-radius: root.border-radius; - TouchArea { clicked => { confirmation-keys.focus(); } } + width: min(360px, root.width - 48px); + height: self.content-top + confirmation-content.preferred-height + 24px; + title: MessageStrings.delete-title; + close-label: MessageStrings.cancel; + close => { root.pending-delete-id = ""; } confirmation-content := VerticalLayout { - padding: 16px; - padding-top: 26px; - padding-bottom: 26px; - spacing: 20px; - Text { text: MessageStrings.delete-confirmation; font-size: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; } + x: 24px; y: parent.content-top; width: parent.width - 48px; + height: self.preferred-height; + padding: 0px; spacing: 24px; + Text { text: MessageStrings.delete-confirmation; font-size: 13px; color: WorkbenchStyle.text; wrap: word-wrap; } HorizontalLayout { - alignment: center; spacing: 8px; - cancel-delete-button := CompactButton { - width: 72px; text: MessageStrings.cancel; + alignment: end; spacing: 8px; + cancel-delete-button := SettingsButton { + width: 88px; text: MessageStrings.cancel; accessible-role: button; accessible-label: MessageStrings.cancel; accessible-action-default => { cancel-delete-button.clicked(); } clicked => { root.pending-delete-id = ""; } } - confirm-delete-button := CompactButton { - width: 72px; text: MessageStrings.delete-message; primary: true; + confirm-delete-button := SettingsButton { + width: 88px; text: MessageStrings.delete-message; primary: true; accessible-role: button; accessible-label: MessageStrings.delete-title; accessible-action-default => { confirm-delete-button.clicked(); } clicked => { diff --git a/apps/desktop/src/gui/ui/settings_controls.slint b/apps/desktop/src/gui/ui/settings_controls.slint index 5deab0e2..1399ceaa 100644 --- a/apps/desktop/src/gui/ui/settings_controls.slint +++ b/apps/desktop/src/gui/ui/settings_controls.slint @@ -1,5 +1,5 @@ import { UiRadius } from "ui_radius.slint"; -import { CompactComboBox, ToggleSwitch } from "common.slint"; +import { CompactComboBox, ToggleSwitch, DialogSurface, IconButton, FontAwesomeIcons } from "common.slint"; import { WorkbenchStyle } from "workbench_controls.slint"; // Shared visual treatment for the global settings pages. @@ -28,6 +28,66 @@ export component SettingsButton inherits Rectangle { touch := TouchArea { enabled: root.enabled; mouse-cursor: pointer; clicked => { root.clicked(); } } } +export component SettingsDialog inherits DialogSurface { + in property title; + in property close-label; + in property badge-count: 0; + in property badge-label; + in property header-status; + in property action-text; + in property action-enabled: true; + out property content-top: 64px; + callback action(); + callback close(); + background: #f8fafc; + border-color: WorkbenchStyle.separator; + drop-shadow-color: #14294424; + drop-shadow-blur: 24px; + drop-shadow-offset-y: 8px; + TouchArea { } + HorizontalLayout { + x: 24px; y: 18px; width: parent.width - 48px; height: 28px; + spacing: 10px; + Text { + text: root.title; font-size: 20px; font-weight: 600; + color: WorkbenchStyle.text; vertical-alignment: center; + } + if root.badge-count > 0: Rectangle { + width: max(24px, count-label.preferred-width + 14px); height: 22px; + border-radius: 11px; background: #e8efff; + accessible-role: text; accessible-label: root.badge-label; + count-label := Text { + text: "" + root.badge-count; color: WorkbenchStyle.accent; + font-size: 11px; font-weight: 600; + horizontal-alignment: center; vertical-alignment: center; + accessible-role: none; + } + } + Rectangle { horizontal-stretch: 1; } + if root.header-status != "": Text { + text: root.header-status; color: WorkbenchStyle.muted; + font-size: 11px; vertical-alignment: center; + } + if root.action-text != "": SettingsButton { + width: action-label-metrics.preferred-width + 24px; height: 28px; + text: root.action-text; enabled: root.action-enabled; + clicked => { root.action(); } + action-label-metrics := Text { visible: false; text: root.action-text; font-size: 12px; } + } + IconButton { + width: 28px; height: 28px; + icon: FontAwesomeIcons.xmark; icon-size: 14px; + icon-color: WorkbenchStyle.muted; + hover-background: WorkbenchStyle.hover-surface; + pressed-background: WorkbenchStyle.pressed-surface; + accessible-role: button; accessible-label: root.close-label; + accessible-action-default => { root.close(); } + clicked => { root.close(); } + } + } + @children +} + export component SettingsSelect inherits CompactComboBox { subtle: true; show-arrow: false; diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 76d0cfed..59a6e29a 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -46,6 +46,7 @@ inline int ApplyMainWindowStrings( messages.set_messages(Text(localization::messages[language])); messages.set_unread(Text(localization::message_unread[language])); messages.set_read(Text(localization::message_read[language])); + messages.set_read_all(Text(localization::message_read_all[language])); messages.set_empty(Text(localization::message_empty[language])); messages.set_select_message(Text(localization::message_select[language])); messages.set_loading(Text(localization::message_loading[language]));