系统图标改成品牌色徽章

图标从 14px 提到 20px,加上该系统品牌色的圆形底。

- 品牌色用各项目公布的官方值,写在 host_os! 表里而不是渲染处;设计
  规范禁止界面写死颜色,但留了「颜色本身即数据」这一档,品牌色属于
  这一类。
- logo 一律白色,只有 Tux 和 OpenBSD 这两个黄色品牌换成黑色,判据是
  WCAG 相对亮度,阈值取得比正文对比度要求高,因为几乎所有品牌都用白
  标画自己的 logo。
- AlmaLinux 和 macOS 没有单一官方色,改用 foreground / background 跟
  随主题,这也是 Apple 官方的画法。
- 徽章沿用 Avatar 的画法:主题圆角加一圈 border 描边。描边是纯黑或
  纯白品牌在明暗两个主题下都还看得见的原因。
- 分组行和其他行用 plain_mark 占同宽槽位,否则标签对不齐。
- 未探测到的会话退回 muted 底色加名称首字,aria_label 也从那个字改成
  「未探测到系统」,屏幕阅读器读一个孤零零的字没有意义。

顺带在文档里记下:GPUI 的 svg() 是单色遮罩,要彩色只能走 img()。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ub6VpmWWAJB15MnYLT9G5
This commit is contained in:
XuzandClaude Opus 5 committed 2026-09-12 13:52:04 +08:00
1 parent 321a65d842
commit d8e41646ec
5 files changed
+188 -70

No files matched your search

+3 -2
View File
@@ -48,7 +48,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。树行前面的标记:探到了用系统 logo,没探到用会话名第一个字(`session_mark`)。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。树行前面的标记是一枚 20px 的圆形徽章(`session_mark`):探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的画法——主题圆角加一圈 `border` 描边——所以纯黑或纯白的品牌在明暗两个主题下都还看得见;`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。
- **id 由内存分配。** `next_session_id` / `next_group_id` 两个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
@@ -57,9 +57,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **焦点规则。** `window.dispatch_action` 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦会话面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 `FocusHandle::dispatch_action` 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效。
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。
- **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如已连接会话的 `Rc<HashSet<SessionId>>`)。
- `px(..)` 只允许出现在 API 要求 `Pixels` 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 `cx.theme()` token。
## 测试约定
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)`(`aria_label` 是系统名或兜底的首字)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
+3 -2
View File
@@ -50,7 +50,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。树行前面的标记:探到了用系统 logo,没探到用会话名第一个字(`session_mark`)。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。树行前面的标记是一枚 20px 的圆形徽章(`session_mark`):探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的画法——主题圆角加一圈 `border` 描边——所以纯黑或纯白的品牌在明暗两个主题下都还看得见;`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。
- **id 由内存分配。** `next_session_id` / `next_group_id` 两个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
@@ -60,9 +60,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。
- **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `SessionPanel` 把 `.context_menu(..)` 挂在 `#session-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc<Cell<Option<SessionNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。
- **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。
- **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。
- **渲染回调不得读取实体。** 树行渲染闭包和 `render_td` 运行在所属实体的 render 过程中,只能传入普通快照(例如已连接会话的 `Rc<HashSet<SessionId>>`)。
- `px(..)` 只允许出现在 API 要求 `Pixels` 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 `cx.theme()` token。
## 测试约定
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)`(`aria_label` 是系统名或兜底的首字)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
+94 -26
View File
@@ -1,4 +1,4 @@
use gpui_kit::SharedString;
use gpui_kit::{Rgba, SharedString, rgb};
use serde::Deserialize;
use crate::secrets::SecretRef;
@@ -57,12 +57,17 @@ impl AuthKind {
/// The operating system running on a host, as reported by the session's own
/// shell after it connects.
///
/// Each variant owns three strings that must stay in step: the spelling kept
/// in the database, the name shown to a person, and the icon embedded in
/// `app/assets.rs`. Adding a distribution means one line here, one icon file,
/// and one arm in `ssh/probe.rs`.
/// Each variant owns four things that must stay in step: the spelling kept in
/// the database, the name shown to a person, the icon embedded in
/// `app/assets.rs`, and the brand's own colour. Adding a distribution means
/// one line here, one icon file, and one arm in `ssh/probe.rs`.
///
/// The colours are the published brand values, which is why they are literals
/// rather than theme tokens: here the colour *is* the data, the one case the
/// design guides allow. Keeping them in this table is what stops them from
/// leaking into render code.
macro_rules! host_os {
($($variant:ident => $stored:literal, $label:literal, $icon:literal;)*) => {
($($variant:ident => $stored:literal, $label:literal, $icon:literal, $brand:expr;)*) => {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostOs {
$($variant,)*
@@ -88,6 +93,30 @@ macro_rules! host_os {
match self { $(Self::$variant => concat!("icons/os/", $icon, ".svg"),)* }
}
/// The brand's published colour, for the badge behind the mark.
///
/// `None` means the mark is monochrome and should follow the
/// theme instead. That is how Apple's is meant to be drawn, and
/// the honest answer where a project publishes no single colour.
pub fn brand_color(self) -> Option<Rgba> {
let hex: Option<u32> = match self { $(Self::$variant => $brand,)* };
hex.map(rgb)
}
/// What to draw the mark itself in, on top of [`Self::brand_color`].
///
/// White, the way nearly every brand draws its own mark, except on
/// the light ones where the brand uses a dark mark instead.
pub fn brand_foreground(self) -> Option<Rgba> {
self.brand_color().map(|color| {
if is_light(color) {
rgb(BRAND_DARK_MARK)
} else {
rgb(BRAND_LIGHT_MARK)
}
})
}
/// Read back a stored spelling. An unknown one means the row was
/// written by a newer build, so it is treated as undetected.
pub fn from_stored(value: &str) -> Option<Self> {
@@ -101,26 +130,48 @@ macro_rules! host_os {
}
host_os! {
Ubuntu => "ubuntu", "Ubuntu", "ubuntu";
Debian => "debian", "Debian", "debian";
Fedora => "fedora", "Fedora", "fedora";
RedHat => "rhel", "Red Hat", "redhat";
CentOs => "centos", "CentOS", "centos";
Rocky => "rocky", "Rocky Linux", "rockylinux";
Alma => "almalinux", "AlmaLinux", "almalinux";
Arch => "arch", "Arch Linux", "archlinux";
Alpine => "alpine", "Alpine Linux", "alpinelinux";
Suse => "suse", "openSUSE", "opensuse";
Gentoo => "gentoo", "Gentoo", "gentoo";
Kali => "kali", "Kali Linux", "kalilinux";
Manjaro => "manjaro", "Manjaro", "manjaro";
Raspbian => "raspbian", "Raspberry Pi OS", "raspberrypi";
Linux => "linux", "Linux", "linux";
MacOs => "macos", "macOS", "apple";
Windows => "windows", "Windows", "windows";
FreeBsd => "freebsd", "FreeBSD", "freebsd";
OpenBsd => "openbsd", "OpenBSD", "openbsd";
NetBsd => "netbsd", "NetBSD", "netbsd";
Ubuntu => "ubuntu", "Ubuntu", "ubuntu", Some(0xE9_5420);
Debian => "debian", "Debian", "debian", Some(0xA8_1D33);
Fedora => "fedora", "Fedora", "fedora", Some(0x51_A2DA);
RedHat => "rhel", "Red Hat", "redhat", Some(0xEE_0000);
CentOs => "centos", "CentOS", "centos", Some(0x26_2577);
Rocky => "rocky", "Rocky Linux", "rockylinux", Some(0x10_B981);
// AlmaLinux publishes no single flat colour, so its mark follows the theme.
Alma => "almalinux", "AlmaLinux", "almalinux", None;
Arch => "arch", "Arch Linux", "archlinux", Some(0x17_93D1);
Alpine => "alpine", "Alpine Linux", "alpinelinux", Some(0x0D_597F);
Suse => "suse", "openSUSE", "opensuse", Some(0x73_BA25);
Gentoo => "gentoo", "Gentoo", "gentoo", Some(0x54_487A);
Kali => "kali", "Kali Linux", "kalilinux", Some(0x55_7C94);
Manjaro => "manjaro", "Manjaro", "manjaro", Some(0x35_BFA4);
Raspbian => "raspbian", "Raspberry Pi OS", "raspberrypi", Some(0xA2_2846);
Linux => "linux", "Linux", "linux", Some(0xFC_C624);
MacOs => "macos", "macOS", "apple", None;
Windows => "windows", "Windows", "windows", Some(0x00_78D4);
FreeBsd => "freebsd", "FreeBSD", "freebsd", Some(0xAB_2B28);
OpenBsd => "openbsd", "OpenBSD", "openbsd", Some(0xF2_CA30);
NetBsd => "netbsd", "NetBSD", "netbsd", Some(0xFF_6600);
}
/// Drawn on top of a light brand colour.
const BRAND_DARK_MARK: u32 = 0x00_0000;
/// Drawn on top of every other brand colour.
const BRAND_LIGHT_MARK: u32 = 0xFF_FFFF;
/// Whether a brand colour is light enough that the brand puts a dark mark on
/// it. The threshold sits well above the point where white text would fail,
/// because a logo silhouette is not body text and nearly every brand draws its
/// own mark in white; only the yellows go the other way.
fn is_light(color: Rgba) -> bool {
fn linear(channel: f32) -> f32 {
if channel <= 0.03928 {
channel / 12.92
} else {
((channel + 0.055) / 1.055).powf(2.4)
}
}
let luminance = 0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b);
luminance > 0.5
}
/// Connection state of a session. Runtime only: it is never persisted, so a
@@ -305,6 +356,23 @@ mod tests {
}
}
#[test]
fn the_light_brands_get_a_dark_mark_and_the_rest_get_white() {
// Tux is black on yellow, and OpenBSD's Puffy likewise.
assert_eq!(HostOs::Linux.brand_foreground(), Some(rgb(0x00_0000)));
assert_eq!(HostOs::OpenBsd.brand_foreground(), Some(rgb(0x00_0000)));
// Everything else follows the usual white-on-brand treatment.
assert_eq!(HostOs::Ubuntu.brand_foreground(), Some(rgb(0xFF_FFFF)));
assert_eq!(HostOs::Debian.brand_foreground(), Some(rgb(0xFF_FFFF)));
assert_eq!(HostOs::Windows.brand_foreground(), Some(rgb(0xFF_FFFF)));
}
#[test]
fn a_monochrome_mark_leaves_the_colour_to_the_theme() {
assert_eq!(HostOs::MacOs.brand_color(), None);
assert_eq!(HostOs::MacOs.brand_foreground(), None);
}
#[test]
fn stored_spellings_and_icons_are_unique() {
let mut spellings: Vec<_> = HostOs::ALL.iter().map(|os| os.as_str()).collect();
+71 -37
View File
@@ -5,13 +5,14 @@ use std::{
};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _,
ActiveTheme as _, Icon, IconName, Sizable as _, ThemeStyled as _,
button::Button,
dock::{BasePanel, Panel, PanelControl, PanelEvent},
h_flex,
input::{Input, InputEvent, InputState},
list::ListItem,
menu::{ContextMenuExt as _, PopupMenu},
tooltip::Tooltip,
tree::{TreeEntry, TreeEvent, TreeState, tree},
v_flex,
};
@@ -389,44 +390,76 @@ impl Render for SessionPanel {
}
}
/// The mark in front of a session: the operating system a probe found on the
/// host, or the first character of the session name until one succeeds.
/// The mark in front of a session: the host's operating system on a badge in
/// that project's own colour, or the first character of the session name until
/// a probe succeeds.
///
/// The badge borrows `Avatar`'s treatment — a bordered circle at the theme's
/// radius — so identity marks look the same wherever the product shows one.
/// The border is what keeps a black or white brand readable against either
/// theme.
fn session_mark(id: SessionId, label: &SharedString, os: Option<HostOs>, cx: &App) -> AnyElement {
let (mark, description): (AnyElement, SharedString) = match os {
Some(os) => (
Icon::default()
.path(os.icon_path())
.small()
.into_any_element(),
os.label().into(),
),
None => {
let initial: SharedString = label
.chars()
.next()
.map(|character| character.to_uppercase().to_string())
.unwrap_or_default()
.into();
let (background, ink, glyph, description) = match os {
Some(os) => {
let (background, ink) = match (os.brand_color(), os.brand_foreground()) {
(Some(background), Some(ink)) => (background.into(), ink.into()),
// A monochrome mark follows the theme, which is also how
// Apple's own guidance draws it.
_ => (cx.theme().foreground, cx.theme().background),
};
(
h_flex()
// Matches `Icon::small`, so a row keeps its alignment
// whichever mark it ends up with.
.size_3p5()
.justify_center()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(initial.clone())
background,
ink,
Icon::default()
.path(os.icon_path())
.xsmall()
.into_any_element(),
initial,
SharedString::from(os.label()),
)
}
None => (
cx.theme().muted,
cx.theme().muted_foreground,
div()
.text_xs()
.child(
label
.chars()
.next()
.map(|character| character.to_uppercase().to_string())
.unwrap_or_default(),
)
.into_any_element(),
SharedString::from("未探测到系统"),
),
};
div()
let tooltip = description.clone();
h_flex()
.id(("session-os", id.0))
.test_support()
.aria_label(description)
.tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
.flex_shrink_0()
.child(mark)
.size_5()
.justify_center()
.rounded_full_style(cx)
.border_1()
.border_color(cx.theme().border)
.bg(background)
.text_color(ink)
.child(glyph)
.into_any_element()
}
/// A row that is not a session still has to reserve the badge's width, or the
/// labels of groups and sessions would not line up.
fn plain_mark(icon: Icon, cx: &App) -> AnyElement {
h_flex()
.flex_shrink_0()
.size_5()
.justify_center()
.text_color(cx.theme().muted_foreground)
.child(icon.small())
.into_any_element()
}
@@ -441,13 +474,14 @@ fn render_row(
let node = SessionNode::parse(&item.id);
let (mark, row_id): (AnyElement, ElementId) = match node {
Some(SessionNode::Group(id)) => (
Icon::new(if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::Folder
})
.small()
.into_any_element(),
plain_mark(
Icon::new(if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::Folder
}),
cx,
),
("group-row", id.0).into(),
),
Some(SessionNode::Session(id)) => (
@@ -455,7 +489,7 @@ fn render_row(
("session-row", id.0).into(),
),
None => (
Icon::new(IconName::File).small().into_any_element(),
plain_mark(Icon::new(IconName::File), cx),
item.id.clone().into(),
),
};
+17 -3
View File
@@ -2048,9 +2048,20 @@ async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut
);
assert_eq!(
window.find(("session-os", fresh.0)).label(),
Some("数"),
"没探测过就用名称第一个字"
Some("未探测到系统"),
"没探测过就退回名称首字的中性徽章"
);
// The badges carry brand colours of their own, so they have to hold up
// in both themes.
window.click("theme-toggle", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("session-os", probed.0)).visible());
assert!(window.find(("session-os", fresh.0)).visible());
})
.unwrap();
}
@@ -2063,7 +2074,10 @@ async fn connecting_marks_the_session_with_the_host_operating_system(cx: &mut Te
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(("session-os", id.0)).label(), Some("P"));
assert_eq!(
window.find(("session-os", id.0)).label(),
Some("未探测到系统")
);
window
.within("session-tree")
.double_click(("session-row", id.0), cx);