From 80d828c8247436d9c10174a4ccc11418d302b289 Mon Sep 17 00:00:00 2001 From: brofea Date: Sat, 22 Aug 2026 23:41:49 +0800 Subject: [PATCH 01/28] chore: ignore codex config --- .codex/.gitignore | 1 + .codex/config.toml | 39 --------------------------------------- 2 files changed, 1 insertion(+), 39 deletions(-) create mode 100644 .codex/.gitignore delete mode 100644 .codex/config.toml diff --git a/.codex/.gitignore b/.codex/.gitignore new file mode 100644 index 00000000..ab8b69cb --- /dev/null +++ b/.codex/.gitignore @@ -0,0 +1 @@ +config.toml \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index e1feee37..00000000 --- a/.codex/config.toml +++ /dev/null @@ -1,39 +0,0 @@ -# Project-scoped Codex defaults for Trellis workflows. -# Codex merges this layer after the user-level config when the project -# is marked as a trusted project. To trust this project, add it under -# `[projects]` in ~/.codex/config.toml, e.g.: -# -# [projects."/abs/path/to/this/repo"] -# trust_level = "trusted" - -# Keep AGENTS.md as the primary project instruction file. -project_doc_fallback_filenames = ["AGENTS.md"] - -# Codex hooks (`hooks.json` in this directory) only fire when the user -# has enabled them in their USER-level config: `[features].hooks = true` -# in ~/.codex/config.toml (Codex 0.129+; legacy name: `codex_hooks = true`, -# still works but emits a deprecation warning on 0.129+). Project-level -# config.toml cannot set feature flags; they must be user-level. -# Codex 0.129+ additionally gates each installed hook behind a one-time -# `/hooks` TUI review; until the user approves it, the hook stays inactive. - -# NOTE: Trellis intentionally does NOT write a [features.multi_agent_v2] -# block here. Codex CLI changed `features` deserialization between 0.130 -# and 0.131: the structured table form (with max_concurrent_threads_per_session -# / *_wait_timeout_ms) is only accepted by 0.131+. On 0.130 and earlier — -# including the codex CLI bundled inside the Codex desktop app — it fails -# with `data did not match any variant of untagged enum FeatureToml`, which -# aborts the entire config load and blocks Codex from starting. Codex's own -# default for multi_agent_v2 is used instead; tune it in your user-level -# config if needed. - -# Pin the subagent recursion depth explicitly instead of relying on Codex's -# default. #445 removed the per-agent `[features] multi_agent = false` guard -# (the #240/#241 wait_agent-deadlock structural fix) because native subagent -# dispatch already caps recursion via `agents.max_depth` — but that key is -# global/user-level, not settable inside an individual agent's .toml. Pinning -# it here means an upstream default change, or a user's own global override, -# can't silently reopen the recursion the #240/#241 fix closed. Project config -# (this file) takes precedence over user-level `~/.codex/config.toml`. -[agents] -max_depth = 1 From 712ef7d173ec3032c41402d7cd839ec8cf26db83 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 00:48:00 +0800 Subject: [PATCH 02/28] feat: semanticize and re-layout main UI --- src/Tailviewer/App.cs | 7 +++ src/Tailviewer/Themes/Constants.xaml | 1 + src/Tailviewer/Themes/Semantic.xaml | 19 ++++++++ src/Tailviewer/Ui/LogView/LogViewerControl.cs | 10 ++++ .../Ui/LogView/LogViewerControl.xaml | 48 ++++++++++++++++--- src/Tailviewer/Ui/MainWindow.xaml | 35 ++------------ src/Tailviewer/Ui/MainWindow.xaml.cs | 10 +++- 7 files changed, 90 insertions(+), 40 deletions(-) create mode 100644 src/Tailviewer/Themes/Semantic.xaml diff --git a/src/Tailviewer/App.cs b/src/Tailviewer/App.cs index 07fa58db..ca00862a 100644 --- a/src/Tailviewer/App.cs +++ b/src/Tailviewer/App.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows; +using System.Windows.Media; using System.Windows.Threading; using Metrolib; using Tailviewer.BusinessLogic.ActionCenter; @@ -40,6 +41,12 @@ public class App public App() { + // Semantic neutral tokens: published at application scope (not in Semantic.xaml) so they can be swapped at runtime by a future dark-mode step. + Resources["Surface"] = Color.FromRgb(0xFF, 0xFF, 0xFF); + Resources["SurfaceMuted"] = Color.FromRgb(0xD8, 0xD8, 0xD8); + Resources["TextSecondary"] = Color.FromRgb(0xA0, 0xA0, 0xA0); + Resources["Divider"] = Color.FromRgb(0x80, 0x80, 0x80); + Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Metrolib;component/Themes/Generic.xaml") }); Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Tailviewer;component/Themes/MetrolibTheme.xaml") }); } diff --git a/src/Tailviewer/Themes/Constants.xaml b/src/Tailviewer/Themes/Constants.xaml index 584b230f..1b2c2060 100644 --- a/src/Tailviewer/Themes/Constants.xaml +++ b/src/Tailviewer/Themes/Constants.xaml @@ -3,6 +3,7 @@ + + + + + + + + diff --git a/src/Tailviewer/Ui/LogView/LogViewerControl.cs b/src/Tailviewer/Ui/LogView/LogViewerControl.cs index 55fd35c8..91167891 100644 --- a/src/Tailviewer/Ui/LogView/LogViewerControl.cs +++ b/src/Tailviewer/Ui/LogView/LogViewerControl.cs @@ -118,6 +118,16 @@ public LogViewerControl() PART_ListView.HorizontalScrollBar.ValueChanged += HorizontalScrollBarOnValueChanged; } + public void FocusSearch() + { + PART_SearchBox?.Focus(); + } + + public void FocusFindAll() + { + PART_FindAllBox?.Focus(); + } + public DataSourceDisplayMode MergedDataSourceDisplayMode { get { return (DataSourceDisplayMode) GetValue(MergedDataSourceDisplayModeProperty); } diff --git a/src/Tailviewer/Ui/LogView/LogViewerControl.xaml b/src/Tailviewer/Ui/LogView/LogViewerControl.xaml index 1a26009c..475609bf 100644 --- a/src/Tailviewer/Ui/LogView/LogViewerControl.xaml +++ b/src/Tailviewer/Ui/LogView/LogViewerControl.xaml @@ -9,7 +9,7 @@ xmlns:sidePanel="clr-namespace:Tailviewer.Ui.SidePanel" xmlns:metrolib="clr-namespace:Metrolib;assembly=Metrolib" x:Name="This" - Background="White"> + Background="{DynamicResource SurfaceBrush}"> @@ -198,6 +198,42 @@ + + + + + + + + + + + + + @@ -249,7 +285,7 @@ @@ -257,18 +293,18 @@ Margin="20"> + Fill="{DynamicResource SurfaceBrush}" /> @@ -286,7 +322,7 @@ - - @@ -99,34 +97,7 @@ ItemsSource="{Binding MainMenu.Help.Items}" /> - - - - - - diff --git a/src/Tailviewer/Ui/MainWindow.xaml.cs b/src/Tailviewer/Ui/MainWindow.xaml.cs index bdcee52e..94bfb734 100644 --- a/src/Tailviewer/Ui/MainWindow.xaml.cs +++ b/src/Tailviewer/Ui/MainWindow.xaml.cs @@ -13,6 +13,7 @@ using Tailviewer.Settings; using Tailviewer.Ui.DataSourceTree; using Tailviewer.Ui.Menu; +using Tailviewer.Ui.LogView; namespace Tailviewer.Ui { @@ -163,12 +164,17 @@ private void OnMouseMove(object sender, MouseEventArgs e) private void FocusLogFileSearch() { - PartSearchBox.Focus(); + FindLogViewer()?.FocusSearch(); } private void FocusLogFileSearchAll() { - PartFindAllBox.Focus(); + FindLogViewer()?.FocusFindAll(); + } + + private LogViewerControl FindLogViewer() + { + return this.FindChildrenOfType().FirstOrDefault(); } private void FocusDataSourceSearch() From d9fe5f435cda494d23402cdeb1f93c55203b51e4 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 00:48:12 +0800 Subject: [PATCH 03/28] chore(task): archive 08-22-ui-semantic-layout --- .../08-22-ui-semantic-layout/check.jsonl | 8 ++ .../08-22-ui-semantic-layout/design.md | 135 ++++++++++++++++++ .../08-22-ui-semantic-layout/implement.jsonl | 7 + .../08-22-ui-semantic-layout/implement.md | 75 ++++++++++ .../2026-08/08-22-ui-semantic-layout/prd.md | 74 ++++++++++ .../research/bindings-findall-lifecycle.md | 29 ++++ .../08-22-ui-semantic-layout/task.json | 26 ++++ 7 files changed, 354 insertions(+) create mode 100644 .trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/check.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/design.md create mode 100644 .trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/implement.md create mode 100644 .trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/prd.md create mode 100644 .trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/research/bindings-findall-lifecycle.md create mode 100644 .trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/task.json diff --git a/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/check.jsonl b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/check.jsonl new file mode 100644 index 00000000..600b5bef --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/check.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/ui/theming.md", "reason": "核对强调色 DynamicResource、冻结画刷、Metrolib 覆盖是否被破坏"} +{"file": ".trellis/spec/ui/index.md", "reason": "UI 层整体规范与第三方依赖基线"} +{"file": ".trellis/spec/ui/mvvm.md", "reason": "核对重排未引入 XAML 事件处理器或违反命令绑定约定"} +{"file": ".trellis/spec/ui/project-structure.md", "reason": "核对未越界改动 BusinessLogic/ViewModel/本地化产物"} +{"file": ".trellis/spec/build/index.md", "reason": "核对 warning-free 构建与项目格式约束"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "核对硬编码色迁移完整性、无重复遗漏"} +{"file": ".trellis/tasks/08-22-ui-semantic-layout/prd.md", "reason": "验收标准与红线清单,作为 check 的判定依据"} +{"file": ".trellis/tasks/08-22-ui-semantic-layout/design.md", "reason": "边界、性能基线与回滚点,作为 check 的判定依据"} diff --git a/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/design.md b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/design.md new file mode 100644 index 00000000..98a351f0 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/design.md @@ -0,0 +1,135 @@ +# 设计:语义化 UI 设计基础 + 主界面重排 + +> 方向 A 已获主负责人批准(2026-08-22)。本文档把批准决策落实为可执行设计,语义资源给出具体基线值。 + +## 1. 边界:什么能改 / 什么不能改 + +### 可改(视图层,仅本任务范围) +- `src/Tailviewer/Ui/MainWindow.xaml`(头部 + 主内容骨架)。 +- `src/Tailviewer/Ui/MainWindow.xaml.cs`(焦点转发桥接,见 §5)。 +- `src/Tailviewer/Ui/LogView/LogViewerControl.xaml`(新增搜索行 + 中性色迁移)。 +- `src/Tailviewer/Ui/LogView/LogViewerControl.cs`(新增 `FocusSearch`/`FocusFindAll` 公开方法)。 +- `src/Tailviewer/Themes/Semantic.xaml`(新增,见 §4)。 +- `src/Tailviewer/Themes/Constants.xaml`(merged dictionaries 新增 `Semantic.xaml`,一处新增)。 +- `src/Tailviewer/App.cs`(在应用作用域发布语义中性 `Color` 键,见 §4)。 + +### 不可改(红线,违反即回滚) +- `src/Tailviewer/BusinessLogic/**`。 +- `src/Tailviewer/Ui/**/*ViewModel*.cs`、`IMainWindowViewModel.cs`、`AbstractMainPanelViewModel.cs`。 +- `src/Tailviewer/Ui/LogView/LogEntryListView.cs`、`TextCanvas.cs`、`TextLine.cs`、`TextSegment.cs`、`TextBrushes.cs`、`DataSource/DataSourceCanvas.cs`。 +- `src/Tailviewer/Ui/ThemeManager.cs`、`ThemePalette.cs`(仅新增键也不改现有契约)。 +- `tools/generate_localization.py` 生成产物(`Strings*.resx`、`Strings.cs`)。 + +## 2. 布局重排(方向 A,已批准) + +现状 Header(`MainWindow.xaml:66-167`): +``` +[logo 32][Menu][SearchTextBox 200][FilterTextBox 200][title suffix *][ActionCenter 30] +``` +重排后: +``` +Header: [logo 32][Menu][title suffix *][ActionCenter 30] +LogViewerControl Row1(新增搜索行): + [SearchTextBox 200][FilterTextBox 200] ← 自 Header 移入,绑定见 §3 +``` + +- Header 列数 6 → 4,删除两列搜索框;标题后缀列(star)获得空间,1024 宽度不再拥挤。 +- 搜索行利用 `LogViewerControl.xaml` 现有**空置的 Row1(Height=Auto)**,不改 Row0(50px) 工具栏与 Row2(3*) 日志区结构,`LogEntryListView` 零接触。 +- 顶部工具栏 `Row0` 与日志区之间新增一条搜索行,语义上搜索属于日志操作。 + +## 3. 绑定契约(不改 ViewModel) + +`LogViewerControl` 的 DataContext = `LogViewMainPanelViewModel`(由 `LogViewMainPanelDataTemplate.xaml` 隐式模板承载,该 VM 由 `MainWindowViewModel.LogViewPanel` 提供)。故新搜索行绑定为: + +| 控件 | 属性 | 绑定(DataContext 相对) | 原 Header 绑定 | +|------|------|--------------------------|----------------| +| `SearchTextBox` (x:Name=`PART_SearchBox`) | `Text` | `{Binding Search.Term, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}` | `LogViewPanel.Search.Term` | +| | `OccurenceCount` | `{Binding Search.ResultCount}` | `LogViewPanel.Search.ResultCount` | +| | `CurrentOccurenceIndex` | `{Binding Search.CurrentResultIndex, Mode=TwoWay}` | `LogViewPanel.Search.CurrentResultIndex` | +| | `Visibility` | `{Binding CurrentDataSource, Converter=NullToCollapsedConverter}` | `LogViewPanel.CurrentDataSource` | +| `FilterTextBox` (x:Name=`PART_FindAllBox`) | `FilterText` | `{Binding FindAll.SearchTerm, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}` | `LogViewPanel.FindAll.SearchTerm` | +| | `Visibility` | `{Binding CurrentDataSource, Converter=NullToCollapsedConverter}` | `LogViewPanel.CurrentDataSource` | + +- `Search` 为 `ISearchViewModel`(Term/ResultCount/CurrentResultIndex,`IDataSourceViewModel.Search`)。 +- `FindAll` 为 `IFindAllViewModel`(`SearchTerm`),与 FindAll 结果视图(`DataSource.FindAll.Show` 驱动,`LogViewerControl.xaml:278`)共享同一对象;`SearchTerm` setter 已触发 `PropertyChanged` 并翻转 `Show`,生命周期一致,无需新逻辑。 +- 不再走 `MainWindow` 的 `LogViewPanel.*` 路径,也不引入新 ViewModel 属性。 + +## 4. 语义资源(具体基线值,等价复用现有颜色,DynamicResource 化) + +`Semantic.xaml` 经 `Themes/Constants.xaml` 的 merged dictionaries 进入资源图(`App`/`MainWindow`/`LogViewerControl` 均经由 `Constants.xaml` 获得,**不直接改 `App.cs` 合并字典**)。镜像强调色架构(`theming.md`):`Color` 键**不在** `Semantic.xaml` 本地声明(本地声明会遮蔽应用作用域值,阻断运行时替换),而是在 `App` 构造中发布到 `Application.Current.Resources`;`Semantic.xaml` 只声明 Brush,其 `Color` 与所有消费者都用 `{DynamicResource}`,以便后续深色模式实时替换。 + +```xml + + + + + +``` + +```csharp +// App 构造:应用作用域发布默认 Color(与 ThemeManager 发布强调色同构) +Resources["Surface"] = Color.FromRgb(0xFF, 0xFF, 0xFF); // #FFFFFF +Resources["SurfaceMuted"] = Color.FromRgb(0xD8, 0xD8, 0xD8); // #D8D8D8 +Resources["TextSecondary"] = Color.FromRgb(0xA0, 0xA0, 0xA0); // #A0A0A0 +Resources["Divider"] = Color.FromRgb(0x80, 0x80, 0x80); // #808080 +``` + +迁移映射(仅本任务范围,已实现): + +| 位置 | 现值 | 迁移为 | +|------|------|--------| +| `LogViewerControl.xaml` 根 `Background` | `White` | `{DynamicResource SurfaceBrush}`(根元素不能用 StaticResource 解析自身资源,实测修正) | +| 错误占位 Ellipse `Fill` | `#D8D8D8` | `{DynamicResource SurfaceMutedBrush}` | +| 错误占位消息/动作 `Foreground` ×2 | `#A0A0A0` | `{DynamicResource TextSecondaryBrush}` | +| 错误占位图标 Path `Fill` | `White` | `{DynamicResource SurfaceBrush}` | +| FindAll 头 TextBlock `Foreground` | `White`(on Primary) | `{DynamicResource PrimaryForegroundBrush}`(强调前景,随主题实时切换) | +| `MainWindow.xaml` flyout 分隔 `Rectangle.Fill` | `Gray` | `{DynamicResource DividerBrush}` | + +- 语义 Brush 及消费者全部 `{DynamicResource}`(chrome 层,性能可忽略);强调色仍走 `ThemePalette` + `{DynamicResource}`(`theming.md` 规则不变)。 +- `SeparatorBrush`(数据源树 spacer/侧栏边框)来自 Metrolib Constants,保持原样;`LogEntryListView.cs` 的内置 `225,228,232` 分隔条属红线,本次不迁移。 + +## 5. 焦点桥接(关键兼容性修正) + +`MainWindow.xaml.cs` 原逻辑 `PartSearchBox.Focus()` / `PartFindAllBox.Focus()` 依赖 Header 命名域,重排后失效。改为最小 code-behind 转发: + +1. `LogViewerControl`(视图层,非 ViewModel)新增: + ```csharp + public void FocusSearch() => PART_SearchBox?.Focus(); + public void FocusFindAll() => PART_FindAllBox?.Focus(); + ``` + `PART_SearchBox`/`PART_FindAllBox` 为 `InitializeComponent` 生成的命名字段;模板未生成时字段为 null,`?.` 安全空操作。 + +2. `MainWindow.xaml.cs` 改为: + ```csharp + private void FocusLogFileSearch() => FindLogViewer()?.FocusSearch(); + private void FocusLogFileSearchAll() => FindLogViewer()?.FocusFindAll(); + + private LogViewerControl FindLogViewer() + => this.FindChildrenOfType().FirstOrDefault(); + ``` + - `FindChildrenOfType` 为 Metrolib 已有扩展(`MainWindow.xaml.cs:195`、`AutoPopup.cs:63` 已使用),沿视觉树递归查找。 + - 模板尚未生成 / 当前内容不是日志页 / 无实例 → `FirstOrDefault()` 返回 null → `?.` 空操作,不抛异常。 + - 本应用主内容恒为 `LogViewPanel`(日志页),flyout 为覆盖层,日志 `LogViewerControl` 始终在树中;该防御逻辑覆盖启动时序与未来页面化。 + +3. 不改任何 ViewModel、不改命令绑定;四个全局快捷键的 `InputBinding` 与命令 DP 原样保留。 + +## 6. 性能基线 + +- 渲染热路径(`TextCanvas.OnRender`、`TextLine.Render`、`LogEntryListView.OnTimer`)零改动。 +- 新增 `{DynamicResource}` 仅用于 chrome(头部/搜索行/错误占位),不在逐行渲染路径;渲染热路径零改动。 +- 主题实时切换仍走 `ThemeManager.Apply` 一次性发布;语义 `Color` 键在应用作用域一次性发布,Brush `Color` 经 `{DynamicResource}` 跟随,不引入每帧计算。 +- `FindLogViewer()` 仅由快捷键触发(低频),不进入布局/渲染循环。 + +## 7. 兼容性 + +- `net48` + `UseWPF` + `TreatWarningsAsErrors` 下零警告。 +- `Semantic.xaml` 不抢占 Metrolib 隐式样式,不重复声明强调 `Color` 键、也不本地声明语义 `Color` 键(`Color` 键仅在应用作用域发布,避开 `theming.md` 陷阱)。 +- 强命名与输出目录不变;SDK 项目 XAML 自动 glob,无需改 csproj。 +- 文案未变,无需重新生成本地化。 + +## 8. 回滚形状 + +- R1(资源层):移除 `Constants.xaml` 中 `Semantic.xaml` merge + 删除 `Semantic.xaml` + 移除 `App.cs` 中语义 `Color` 发布 + 还原被迁移 XAML 的颜色 → 零残留。 +- R2(中性色迁移):按文件 revert。 +- R3(重排+桥接):revert `MainWindow.xaml`(恢复 Header 搜索框)、`MainWindow.xaml.cs`(恢复 `PartSearchBox.Focus()`)、`LogViewerControl.xaml/.cs`(移除搜索行与焦点方法)。 +- 任一步触碰 §1 红线即回滚到最近回滚点并回到 Plan 修订。 diff --git a/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/implement.jsonl b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/implement.jsonl new file mode 100644 index 00000000..a0d4a702 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/implement.jsonl @@ -0,0 +1,7 @@ +{"file": ".trellis/spec/ui/index.md", "reason": "WPF UI 总览:无 MVVM 框架、Bootstrapper 启动、Metrolib 依赖"} +{"file": ".trellis/spec/ui/theming.md", "reason": "主题与强调色架构、DynamicResource 规则、Metrolib 覆盖与冻结画刷陷阱,本任务核心约束"} +{"file": ".trellis/spec/ui/mvvm.md", "reason": "视图/ViewModel 绑定约定与命令注入方式,重排时不得违反"} +{"file": ".trellis/spec/ui/project-structure.md", "reason": "Ui/BusinessLogic/Themes 目录职责与本地化流程"} +{"file": ".trellis/spec/build/index.md", "reason": "net48/UseWPF/TreatWarningsAsErrors、SDK 与经典项目差异、签名"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "迁移硬编码色值前先搜索、避免遗漏引用"} +{"file": ".trellis/tasks/08-22-ui-semantic-layout/design.md", "reason": "本任务技术设计:边界、布局方向、资源分类、性能基线与回滚形状"} diff --git a/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/implement.md b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/implement.md new file mode 100644 index 00000000..9bac582a --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/implement.md @@ -0,0 +1,75 @@ +# 实施计划:语义化 UI 设计基础 + 主界面重排(方向 A,已批准,已实施) + +> 每个阶段独立可验证/可回滚。遵守 `design.md` §1 红线清单。语义资源给具体值、不留 TODO。 +> 状态:已全部实施并验证(2026-08-22 收尾修正后复验)。 + +## 阶段 0:前置确认(已由主负责人批准) + +- [x] 方向 A 已批准。 +- [x] 范围 = MainWindow + LogViewerControl + 其直接使用的样式资源;不扩展侧栏/设置/About。 +- [x] 不新增紧凑模式设置;保持 WPF/Metrolib、ViewModel/BusinessLogic、渲染/虚拟化不变。 +- [x] 语义资源用具体基线值(等价复用现有颜色)。 + +## 阶段 1:资源字典(回滚点 R1) + +1. [x] 新增 `src/Tailviewer/Themes/Semantic.xaml`:四个语义 Brush(`SurfaceBrush`/`SurfaceMutedBrush`/`TextSecondaryBrush`/`DividerBrush`),`Color` 用 `{DynamicResource}`(`Color` 键不在本地声明)。 +2. [x] 经 `src/Tailviewer/Themes/Constants.xaml` merged dictionaries 合并 `Semantic.xaml`(**不改 App.cs 合并字典**)。 +3. [x] 在 `src/Tailviewer/App.cs` 构造中于应用作用域发布语义 `Color` 键默认值(`Surface`/`SurfaceMuted`/`TextSecondary`/`Divider`,镜像强调色架构)。 +4. [x] 验证:`dotnet build src/Tailviewer/Tailviewer.csproj` warning-free。 + +## 阶段 2:中性色迁移(回滚点 R2) + +5. [x] `LogViewerControl.xaml`:根 `Background` → `{DynamicResource SurfaceBrush}`(根元素不能用 StaticResource,实测修正);错误占位 `#D8D8D8` → `SurfaceMutedBrush`;`#A0A0A0`×2 → `TextSecondaryBrush`;图标 `Fill="White"` → `SurfaceBrush`;FindAll 头 `Foreground="White"` → `{DynamicResource PrimaryForegroundBrush}`;以上消费者全部 `{DynamicResource}`。 +6. [x] `MainWindow.xaml`:flyout 分隔 `Fill="Gray"` → `{DynamicResource DividerBrush}`。 +7. [x] 每批构建;grep 校验无 `{StaticResource Primary*}` 回归。 + +## 阶段 3:主界面重排 + 焦点桥接(回滚点 R3) + +8. [x] `MainWindow.xaml`:Header 删除 `PartSearchBox`/`PartFindAllBox`,列数 6 → 4(logo/Menu/star/ActionCenter),标题后缀列置为 star。 +9. [x] `LogViewerControl.xaml`:在现有空置 Row1(Auto)新增搜索行 Grid,放入 `controls:SearchTextBox`(x:Name=`PART_SearchBox`) 与 `controls:FilterTextBox`(x:Name=`PART_FindAllBox`),绑定按 `design.md` §3(`Search.Term/ResultCount/CurrentResultIndex`、`FindAll.SearchTerm`、`CurrentDataSource` 折叠)。 +10. [x] `LogViewerControl.cs`:新增 `public void FocusSearch()` / `public void FocusFindAll()`(`PART_SearchBox?.Focus()` 空安全)。 +11. [x] `MainWindow.xaml.cs`:`FocusLogFileSearch`/`FocusLogFileSearchAll` 改为经 `FindLogViewer()`(`this.FindChildrenOfType().FirstOrDefault()`)转发;删除对 `PartSearchBox`/`PartFindAllBox` 的引用;添加 `using Tailviewer.Ui.LogView;`(`System.Linq` 已有)。 +12. [x] 验证:构建通过;主题色切换仍实时生效(强调色引用无 `{StaticResource}` 回归)。 + +## 阶段 4:回归与收尾 + +13. [x] 运行目标 UI STA 单测:`MainWindowTest`、`LogViewerControlTest`、`LogEntryListViewTest`、`TextCanvasTest` → **94/91/0/3**。 +14. [x] FindAll 回归:`FilterTextBox` 与 FindAll 结果视图共享同一 `IFindAllViewModel`(代码核实,`research/bindings-findall-lifecycle.md`;`LogViewerControlTest` 通过)。 +15. [x] 红线文件审计:`git diff --name-only` 无 §1 不可改文件。 +16. [x] `git diff --check` 通过。 + +## 验证结果(实际执行) + +- 构建:app + tests 项目均 0 警告 / 0 错误。 +- 目标 UI STA:94 总 / 91 通过 / 0 失败 / 3 跳过。 +- 全量 `Tailviewer.Tests.dll`:2365 总 / 2295 通过 / 67 失败 / 3 跳过;66 为基线失败(`git stash` 干净基线复跑一致,zh-CN 本地化 + 状态顺序依赖),1 为 `LogEntryListViewTest.TestMouseWheelDown2` 偶发 Freezable 跨线程竞态(隔离复跑 1/1 通过)。 + +## 验证命令 + +```bash +dotnet build src/Tailviewer/Tailviewer.csproj +dotnet build src/Tailviewer.Tests/Tailviewer.Tests.csproj + +rg -n "StaticResource (Primary|Secondary)" src/Tailviewer # 应为空 +rg -n "StaticResource (SurfaceBrush|SurfaceMutedBrush|TextSecondaryBrush|DividerBrush)" src/Tailviewer # 应为空 + +git diff --check +git diff --name-only # 红线文件审计 + +# 目标 UI STA(nunit3-console 3.12.0) +nunit3-console.exe bin\Tailviewer.Tests.dll "--where=test =~ 'MainWindowTest' or test =~ 'LogViewerControlTest' or test =~ 'LogEntryListViewTest' or test =~ 'TextCanvasTest'" +``` + +## Review Gates + +- G1(阶段 1 后):`Semantic.xaml` 全具体值,merge 经 `Constants.xaml` 正确,`Color` 键仅在应用作用域发布。✅ +- G2(阶段 2 后):无强调色 `{StaticResource}` 回归;`Semantic.xaml` 不本地声明 `Color` 键。✅ +- G3(阶段 3 后):快捷键聚焦经桥接正常;`MainWindow.xaml.cs` 不再引用 `PartSearchBox`/`PartFindAllBox`。✅ +- G4(阶段 4 后):构建 warning-free + UI STA 单测通过 + 红线文件零改动。✅ + +## 回滚点 + +- R1:删除 `Constants.xaml` 中 `Semantic.xaml` merge + 删除 `Semantic.xaml` + 移除 `App.cs` 中语义 `Color` 发布 + 还原颜色迁移。 +- R2:按文件 revert 中性色迁移。 +- R3:revert 重排与桥接(恢复 Header 搜索框 + 原 code-behind)。 +- 越界即回滚:任一步触碰 §1 红线文件,立即 revert 到最近回滚点并回到 Plan。 diff --git a/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/prd.md b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/prd.md new file mode 100644 index 00000000..6bf6feab --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/prd.md @@ -0,0 +1,74 @@ +# 语义化 UI 设计基础 + 主界面重排 + +## Goal + +在不迁移 UI 框架、不重写业务逻辑与日志渲染器的前提下,建立一套语义化设计资源(颜色)作为后续深色模式与全控件 Fluent 化的基础,并对主界面(窗口头部 + 日志查看区)做一次低风险重排:把拥挤的搜索/过滤框从窗口标题栏移入日志查看区,缓解 1024 最小宽度下的头部拥挤。 + +## 已批准决策(主负责人 2026-08-22 审核通过) + +- A1:采用 design.md **方向 A**(渐进式收紧,保留三栏骨架与全部命令)。 +- A2:语义迁移与重排范围**仅**覆盖 `MainWindow` + `LogViewerControl` 及其直接使用的样式资源;不扩展到设置页/侧栏/About。 +- A3:不新增紧凑模式等用户可见设置。 +- A4:保持 WPF/Metrolib、ViewModel/BusinessLogic、`LogEntryListView`/`TextCanvas`/`TextBrushes`/分页与虚拟化不变。 +- A5:语义资源必须给出**可运行的具体基线值**,优先等价复用现有颜色;“不锁定值”不等于留下 TODO。 +- A8(收尾修正):语义资源是深色模式的基础。`Semantic.xaml` 四个语义 Brush 的 `Color` 引用及本任务范围内这些 Brush 的消费者全部使用 `{DynamicResource}`(仅 chrome,性能可忽略);`Color` 键在 `App` 构造发布到应用作用域(镜像强调色架构),保持强调色规则与日志自绘红线不变。 +- A6:搜索框移入 `LogViewerControl` 后,`MainWindow.xaml.cs` 不得再引用 `PartSearchBox`/`PartFindAllBox`(已不属于 MainWindow 命名域);改为 `LogViewerControl` 暴露焦点方法,`MainWindow` 通过活动 `LogViewerControl` 转发,并正确处理模板尚未生成 / 找不到实例的情况。不改 ViewModel。 +- A7:新位置 `SearchTextBox`/`FilterTextBox` 绑定基于 `LogViewerControl` 的 DataContext(= `LogViewMainPanelViewModel`)上的 `Search`/`FindAll`/`CurrentDataSource` 路径;FindAll 生命周期沿用既有(每数据源持有),并补回归验证。 + +## 非目标(本任务不做) + +- 不迁移 UI 框架、不重写业务逻辑 / ViewModel / 日志分页与自绘渲染。 +- 不做深色模式、不做全控件 Fluent 化。 +- 不改 `src/Installer`、`tools/`、`.github/`、插件项目。 + +## 已确认事实(仓库证据) + +| # | 事实 | 证据 | +|---|------|------| +| F1 | WPF + .NET Framework 4.8,SDK 主项目,`TreatWarningsAsErrors` | `src/Tailviewer/Tailviewer.csproj` | +| F2 | 启动对象 `Bootstrapper`,无 `App.xaml`;`App` 构造中合并 Metrolib Generic + `MetrolibTheme.xaml` | `src/Tailviewer/App.cs:43-44` | +| F3 | Metrolib 0.3.0.162 | csproj `PackageReference` | +| F4 | 主窗口 `ChromelessWindow`,`MinWidth=1024`,自绘 Header | `MainWindow.xaml:1-16` | +| F5 | Header 搜索/过滤框固定宽 200×2 + 菜单 + ActionCenter,1024 下拥挤 | `MainWindow.xaml:66-167` | +| F6 | 日志自绘 `TextCanvas.OnRender`(背景硬编码 `Brushes.White`) | `LogView/TextCanvas.cs` | +| F7 | `LogEntryListView` 纯代码 Grid + 33ms 虚拟化;其分隔条 `Color.FromRgb(225,228,232)` 为代码内置(本任务红线) | `LogView/LogEntryListView.cs` | +| F8 | 主题链路 `UISettings.ThemeColor → ThemeManager.Apply → Application.Resources → {DynamicResource}`;`TextBrushes` 用可变画刷 | `Ui/ThemeManager.cs`、`ThemePalette.cs`、`LogView/TextBrushes.cs` | +| F9 | 硬编码中性色散落:`#D8D8D8`、`#A0A0A0`、`White`、`Gray` 出现在 `LogViewerControl.xaml`/`MainWindow.xaml` | 各 XAML | +| F10 | `LogViewerControl` 经 `LogViewMainPanelDataTemplate` 承载,DataContext = `LogViewMainPanelViewModel`;其 `Search`(ISearchViewModel)、`FindAll`(IFindAllViewModel)、`CurrentDataSource` 均在 DataContext 路径上 | `LogViewMainPanelDataTemplate.xaml:18`、`LogViewMainPanelViewModel.cs`、`IDataSourceViewModel.cs:136-138` | +| F11 | `FindAllViewModel`(嵌套类)每数据源持有,`SearchTerm` setter 触发 PropertyChanged 并驱动 `Show` | `AbstractDataSourceViewModel.cs:505-570` | +| F12 | `FindChildrenOfType` 扩展(Metrolib)已在 `MainWindow.xaml.cs`/`AutoPopup.cs` 使用 | `MainWindow.xaml.cs:195`、`AutoPopup.cs:63` | +| F13 | 快捷键 Ctrl+F / Ctrl+Shift+F / Ctrl+E / Ctrl+Shift+N 经 `Window.InputBindings` + 命令 DP | `MainWindow.xaml:18-27` | + +## 需求 + +- R1 新增语义资源字典 `src/Tailviewer/Themes/Semantic.xaml`,经 `Themes/Constants.xaml` 的 merged dictionaries 进入资源图(不改 App.cs 合并字典);仅覆盖本任务范围内的语义角色(表面/弱表面/次要前景/分隔线),给出具体基线值并等价复用现有颜色(见 design.md §4)。 +- R2 将 `MainWindow.xaml`、`LogViewerControl.xaml` 范围内硬编码中性色迁移为语义资源引用;语义 Brush 及其消费者、强调色引用全部 `{DynamicResource}`(`theming.md`)。 +- R3 重排主窗口头部:移除 Header 中的 `PartSearchBox`/`PartFindAllBox`,Header 仅保留 logo/菜单/标题后缀/ActionCenter;搜索/过滤框下移进 `LogViewerControl` 的搜索行。 +- R4 迁移与重排遵守红线:不改 `BusinessLogic/`、不改任何 ViewModel、不改 `LogEntryListView`/`TextCanvas`/`TextLine`/`TextSegment`/`TextBrushes`/`DataSourceCanvas`。 +- R5 保留 Ctrl+F / Ctrl+Shift+F / Ctrl+E / Ctrl+Shift+N 及菜单命令键位行为不变。 +- R6 焦点桥接(A6/A7):`LogViewerControl` 暴露 `FocusSearch()`/`FocusFindAll()`;`MainWindow` 经活动 `LogViewerControl` 转发,模板未生成/找不到实例时安全空操作;不改 ViewModel。 +- R7 新搜索/过滤框绑定走 DataContext 的 `Search.Term/ResultCount/CurrentResultIndex` 与 `FindAll.SearchTerm`;`Visibility` 由 `CurrentDataSource` 空值折叠。 + +## 验收标准 + +- [x] `Semantic.xaml` 已创建并经 `Constants.xaml` merged dictionaries 进入资源图;所有键有具体值(无 TODO),值等价复用现有颜色。 +- [x] `MainWindow.xaml` 头部在 1024 最小宽度下不重叠、不裁切(STA 测试通过;目检为人工项)。 +- [x] 四个全局快捷键功能不变:Ctrl+F / Ctrl+Shift+F 经桥接聚焦到新位置的搜索/过滤框(`MainWindowTest` 覆盖 InputBinding 同步)。 +- [x] 主题色切换仍实时生效:强调色引用无 `{StaticResource Primary*}` 回归。 +- [x] 红线文件零改动:`BusinessLogic/**`、`Ui/**/*ViewModel*`、`LogEntryListView.cs`、`TextCanvas.cs`、`TextLine.cs`、`TextSegment.cs`、`TextBrushes.cs`、`DataSourceCanvas.cs`。 +- [x] 解决方案构建 warning-free;目标 UI STA 单测(`MainWindowTest`、`LogViewerControlTest`、`LogEntryListViewTest`、`TextCanvasTest`)通过。 +- [x] FindAll 回归:`FilterTextBox` 的 `FindAll.SearchTerm` 与 FindAll 结果视图共享同一 `IFindAllViewModel`(代码核实 + `LogViewerControlTest` 通过)。 + +## 验证证据(已执行) + +- 构建:`dotnet build src/Tailviewer/Tailviewer.csproj` 与 `src/Tailviewer.Tests/Tailviewer.Tests.csproj` 均 0 警告 / 0 错误(`TreatWarningsAsErrors`)。 +- 目标 UI STA 单测(nunit3-console 3.12.0,`--where` 过滤 4 fixture):**94 总 / 91 通过 / 0 失败 / 3 跳过**。 +- 全量 `Tailviewer.Tests.dll`:**2365 总 / 2295 通过 / 67 失败 / 3 跳过**;其中 66 为**基线失败**(`git stash` 干净基线复跑结果一致,集中在 `LogViewerViewModelTest`/`TextBrushesTest`/`TimeFiltersViewModelTest`/`FileDataSourceViewModelTest` 等,根因是 zh-CN 本地化文案不匹配 + 测试执行顺序依赖),另 1 个为 `LogEntryListViewTest.TestMouseWheelDown2` **偶发**(Freezable 跨线程竞态,隔离复跑 1/1 通过,与本次改动无关)。 +- 资源解析修正:`LogViewerControl` 根 `Background` 由 `{StaticResource SurfaceBrush}` 改为 `{DynamicResource SurfaceBrush}`(根元素无法用 StaticResource 解析自身资源,实测 STA 测试 `XamlParseException` 后修正)。 +- `git diff --check`:通过。 + +## 剩余限制(已知、本任务不解决) + +- `LogEntryListView.cs` 内置的分隔条颜色 `225,228,232` 为代码内置,属红线文件,本次不迁移(记录于 design.md,供后续 Fluent 化任务处理)。 +- 侧栏/设置页/About 的中性色与间距仍为硬编码,留待后续任务。 +- `TextCanvas` 背景 `Brushes.White` 属自绘渲染路径,本次不改。 diff --git a/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/research/bindings-findall-lifecycle.md b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/research/bindings-findall-lifecycle.md new file mode 100644 index 00000000..015d5596 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/research/bindings-findall-lifecycle.md @@ -0,0 +1,29 @@ +# 研究:LogViewerControl 绑定契约与 FindAll 生命周期 + +调查日期:2026-08-22(方向 A 批准后,实现前补充确认) + +## 结论 + +1. **DataContext 链路**:`MainWindow` 的 `ContentPresenter`(PartContent) 绑定 `LogViewPanel`(`LogViewMainPanelViewModel`),经 `LogViewMainPanelDataTemplate.xaml` 的 `DataTemplate DataType=LogViewMainPanelViewModel` 实例化 `LogViewerControl`。因此 `LogViewerControl.DataContext == LogViewMainPanelViewModel`。 + - 证据:`LogViewerControl.xaml` 已用 `{Binding CurrentDataSource}`、`{Binding SidePanels}`、`{Binding SelectedSidePanel}`、`{Binding DataSources}`(均为 LogViewMainPanelViewModel 成员)。 + +2. **Search 路径**:`LogViewMainPanelViewModel.Search` 类型为 `ISearchViewModel`(`ChangeDataSource` 中 `Search = value?.Search`),暴露 `Term`/`ResultCount`/`CurrentResultIndex`。`IDataSourceViewModel.Search` 同为 `ISearchViewModel`(`IDataSourceViewModel.cs:136`)。 + - 故新 `SearchTextBox` 绑定 `{Binding Search.Term}` 等,等价于原 `LogViewPanel.Search.*`。 + +3. **FindAll 路径与生命周期**:`LogViewMainPanelViewModel.FindAll` 类型 `IFindAllViewModel`(`FindAll = value?.FindAll`)。实现类为 `AbstractDataSourceViewModel` 的嵌套 `FindAllViewModel`,**每个数据源持有一个**,其 `SearchTerm` getter/setter 代理到 `_dataSource.FindAllFilter`,setter 触发 `PropertyChanged` 并翻转 `Show`。 + - `LogViewerControl.xaml` 的 FindAll 结果视图绑定 `{Binding DataSource.FindAll.Show/LogSource/...}`(ElementName=This 的 `DataSource` DP,其值 = `LogViewerViewModel.DataSource` = 同一 `IDataSourceViewModel`)。 + - 因此 `FilterTextBox.FilterText = {Binding FindAll.SearchTerm}` 与 FindAll 结果视图引用**同一对象**,输入即可打开/关闭面板,无生命周期缺口,无需新增逻辑。 + +4. **焦点桥接可行工具**:`FindChildrenOfType`(Metrolib 扩展)已在 `MainWindow.xaml.cs:195` 与 `AutoPopup.cs:63`(`Application.Current?.MainWindow?.FindChildrenOfType()`)使用,可沿视觉树查找 `LogViewerControl`。空安全 `FirstOrDefault()?.` 处理模板未生成场景。 + +5. **App 字典合并点**:`App` 构造函数 `Resources.MergedDictionaries` 依次合并 Metrolib Generic.xaml、`Themes/MetrolibTheme.xaml`(后者内部 merge `Constants.xaml`)。`Semantic.xaml` 应在此之后合并(app 级资源),或并入 `Constants.xaml` 的 MergedDictionaries;本实现采用 App 构造处新增一行,最小侵入。 + +## 涉及文件(只读证据) + +- src/Tailviewer/Ui/LogView/LogViewMainPanelDataTemplate.xaml +- src/Tailviewer/Ui/LogView/LogViewMainPanelViewModel.cs +- src/Tailviewer/Ui/LogView/AbstractDataSourceViewModel.cs(FindAllViewModel 嵌套类,~503-570 行) +- src/Tailviewer/Ui/LogView/ISearchViewModel.cs +- src/Tailviewer/Ui/DataSourceTree/IDataSourceViewModel.cs(Search/FindAll 定义,136-138 行) +- src/Tailviewer/App.cs(字典合并,43-44 行) +- src/Tailviewer/Ui/MainWindow.xaml.cs(FindChildrenOfType 用法,195 行) diff --git a/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/task.json b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/task.json new file mode 100644 index 00000000..d4c0414a --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/task.json @@ -0,0 +1,26 @@ +{ + "id": "ui-semantic-layout", + "name": "ui-semantic-layout", + "title": "语义化 UI 设计基础 + 主界面重排", + "description": "建立语义化设计资源并重排主界面;不迁移框架、不重写日志渲染器、不做深色模式/全控件 Fluent 化", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "brofea", + "assignee": "brofea", + "createdAt": "2026-08-22", + "completedAt": "2026-08-23", + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file From 603db2f9817d864c8415210fc1813c48dc9150f2 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 00:48:20 +0800 Subject: [PATCH 04/28] chore: record journal --- .trellis/workspace/brofea/index.md | 7 +++--- .trellis/workspace/brofea/journal-1.md | 33 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.trellis/workspace/brofea/index.md b/.trellis/workspace/brofea/index.md index 8e14d088..ed6bf0fa 100644 --- a/.trellis/workspace/brofea/index.md +++ b/.trellis/workspace/brofea/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 7 -- **Last Active**: 2026-08-22 +- **Total Sessions**: 8 +- **Last Active**: 2026-08-23 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~245 | Active | +| `journal-1.md` | ~278 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 8 | 2026-08-23 | 完成语义化 UI 与主界面重排 | `712ef7d1` | `ui` | | 7 | 2026-08-22 | Unify remaining Metrolib accents to theme color | `2014bf0b`, `196c0894` | `dev` | | 6 | 2026-08-22 | 修复本地化后 4 个硬编码英文断言的测试 | `fe507568`, `60814c52`, `3a06b562` | `dev` | | 5 | 2026-08-22 | Add AND/OR toggle for quick filter combination | `c0586700`, `f79b7b57` | `dev` | diff --git a/.trellis/workspace/brofea/journal-1.md b/.trellis/workspace/brofea/journal-1.md index 13c51be5..b14f888a 100644 --- a/.trellis/workspace/brofea/journal-1.md +++ b/.trellis/workspace/brofea/journal-1.md @@ -243,3 +243,36 @@ Re-themed all remaining hardcoded Metrolib blue accents (#3998D6 family) to the ### Next Steps - None - task complete + + +## Session 8: 完成语义化 UI 与主界面重排 + +**Date**: 2026-08-23 +**Task**: 完成语义化 UI 与主界面重排 +**Branch**: `ui` + +### Summary + +完成语义化中性资源、主窗口搜索区重排与焦点桥接;保持日志自绘热路径不变,并为后续深色模式将语义资源改为 DynamicResource。应用与测试项目构建 0 警告/0 错误,目标 UI STA 测试 94 总/91 通过/0 失败/3 跳过。完整解决方案因当前 SDK 不支持 Installer ResolveComReference 未通过;其余全量失败与基线及偶发测试竞态相关。 + +### Main Changes + +- Detailed change bullets were not supplied; see the summary above. + +### Git Commits + +| Hash | Message | +|------|---------| +| `712ef7d1` | (see git log) | + +### Testing + +- Validation was not recorded for this session. + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From b5999cdba4e7945846bbe5d43fccf6886761f6fe Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 02:49:28 +0800 Subject: [PATCH 05/28] feat: add dark mode theme pipeline --- .trellis/tasks/08-23-ui-dark-mode/check.jsonl | 10 + .trellis/tasks/08-23-ui-dark-mode/design.md | 183 ++++++++++++++++++ .../tasks/08-23-ui-dark-mode/implement.jsonl | 12 ++ .../tasks/08-23-ui-dark-mode/implement.md | 94 +++++++++ .trellis/tasks/08-23-ui-dark-mode/prd.md | 83 ++++++++ .../research/approved-decisions.md | 31 +++ .../research/dark-palette-and-contrast.md | 42 ++++ .../research/hardcoded-color-inventory.md | 43 ++++ .../research/theme-architecture.md | 53 +++++ .trellis/tasks/08-23-ui-dark-mode/task.json | 26 +++ .../Settings/UISettingsTest.cs | 26 ++- src/Tailviewer.Tests/Tailviewer.Tests.csproj | 1 + .../Ui/SemanticPaletteTest.cs | 47 +++++ src/Tailviewer.Tests/Ui/TextBrushesTest.cs | 77 +++++++- src/Tailviewer/App.cs | 18 +- src/Tailviewer/Localization/Strings.cs | 1 + src/Tailviewer/Localization/Strings.resx | 3 + .../Localization/Strings.zh-CN.resx | 3 + src/Tailviewer/Settings/UISettings.cs | 10 +- src/Tailviewer/Themes/Constants.xaml | 14 +- src/Tailviewer/Themes/MetrolibTheme.xaml | 94 +++++++-- src/Tailviewer/Themes/Semantic.xaml | 1 + .../Ui/About/AboutFlyoutDataTemplate.xaml | 2 +- .../Ui/ActionCenter/ActionCenterControl.xaml | 4 +- .../Ui/ActionCenter/ActionCenterItem.xaml | 2 +- .../Ui/ActionCenter/ExportTemplate.xaml | 2 +- .../Ui/ActionCenter/NotificationTemplate.xaml | 2 +- .../Ui/DataSourceTree/DataSourcesControl.xaml | 2 +- .../FileDataSourceTemplate.xaml | 4 +- .../FolderDataSourceTemplate.xaml | 4 +- .../Ui/DataSourceTree/TreeViewItemStyle.xaml | 6 +- src/Tailviewer/Ui/EmptyStateStyle.xaml | 2 +- src/Tailviewer/Ui/FlatImage.xaml | 2 +- src/Tailviewer/Ui/ImageLabel.xaml | 4 +- .../Ui/LogView/AbstractLogColumnPresenter.cs | 2 +- .../Ui/LogView/DataSource/DataSourceCanvas.cs | 4 +- src/Tailviewer/Ui/LogView/LogEntryListView.cs | 2 +- .../LogView/LogViewMainPanelDataTemplate.xaml | 8 +- .../Ui/LogView/LogViewerControl.xaml | 4 +- src/Tailviewer/Ui/LogView/TextBrushes.cs | 77 ++++++-- src/Tailviewer/Ui/LogView/TextCanvas.cs | 2 +- .../Ui/LogView/ToolbarToggleButtonStyle.xaml | 2 +- src/Tailviewer/Ui/MainWindow.xaml | 5 +- src/Tailviewer/Ui/Menu/MenuItemTemplates.xaml | 14 +- .../Plugins/PluginsMainPanelDataTemplate.xaml | 8 +- .../Ui/QuickFilter/FilterToggleButton.xaml | 2 +- .../QuickFilter/QuickFiltersDataTemplate.xaml | 2 +- src/Tailviewer/Ui/SemanticPalette.cs | 75 +++++++ .../Ui/Settings/SettingsControl.xaml | 25 ++- .../Ui/Settings/SettingsFlyoutViewModel.cs | 32 ++- .../Bookmarks/BookmarksDataTemplate.xaml | 6 +- .../HighlightersSidePanelDataTemplate.xaml | 6 +- .../Issues/IssuesSidePanelDataTemplate.xaml | 4 +- .../Outline/OutlineSidePanelDataTemplate.xaml | 4 +- .../PropertiesSidePanelDataTemplate.xaml | 4 +- .../QuickFiltersSidePanelDataTemplate.xaml | 6 +- src/Tailviewer/Ui/ThemeManager.cs | 21 +- tools/generate_localization.py | 1 + 58 files changed, 1101 insertions(+), 123 deletions(-) create mode 100644 .trellis/tasks/08-23-ui-dark-mode/check.jsonl create mode 100644 .trellis/tasks/08-23-ui-dark-mode/design.md create mode 100644 .trellis/tasks/08-23-ui-dark-mode/implement.jsonl create mode 100644 .trellis/tasks/08-23-ui-dark-mode/implement.md create mode 100644 .trellis/tasks/08-23-ui-dark-mode/prd.md create mode 100644 .trellis/tasks/08-23-ui-dark-mode/research/approved-decisions.md create mode 100644 .trellis/tasks/08-23-ui-dark-mode/research/dark-palette-and-contrast.md create mode 100644 .trellis/tasks/08-23-ui-dark-mode/research/hardcoded-color-inventory.md create mode 100644 .trellis/tasks/08-23-ui-dark-mode/research/theme-architecture.md create mode 100644 .trellis/tasks/08-23-ui-dark-mode/task.json create mode 100644 src/Tailviewer.Tests/Ui/SemanticPaletteTest.cs create mode 100644 src/Tailviewer/Ui/SemanticPalette.cs diff --git a/.trellis/tasks/08-23-ui-dark-mode/check.jsonl b/.trellis/tasks/08-23-ui-dark-mode/check.jsonl new file mode 100644 index 00000000..ff919e94 --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/check.jsonl @@ -0,0 +1,10 @@ +{"file": ".trellis/spec/ui/theming.md", "reason": "核对强调色 DynamicResource、冻结画刷、Metrolib 覆盖是否被破坏"} +{"file": ".trellis/spec/ui/index.md", "reason": "UI 层整体规范与第三方依赖基线"} +{"file": ".trellis/spec/ui/mvvm.md", "reason": "核对开关未引入 XAML 事件处理器或违反命令绑定约定"} +{"file": ".trellis/spec/ui/project-structure.md", "reason": "核对未越界改动 BusinessLogic/ViewModel/本地化产物"} +{"file": ".trellis/spec/build/index.md", "reason": "核对 warning-free 构建与项目格式约束"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "核对硬编码色迁移完整性、无重复遗漏"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/prd.md", "reason": "验收标准与红线清单,作为 check 的判定依据"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/design.md", "reason": "边界、性能基线与回滚点,作为 check 的判定依据"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/research/approved-decisions.md", "reason": "主负责人批准结论与附加护栏,作为 check 的边界判定"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/research/hardcoded-color-inventory.md", "reason": "核对迁移覆盖完整性(逐文件对照)"} diff --git a/.trellis/tasks/08-23-ui-dark-mode/design.md b/.trellis/tasks/08-23-ui-dark-mode/design.md new file mode 100644 index 00000000..026bf631 --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/design.md @@ -0,0 +1,183 @@ +# 设计:深色模式(语义 Token + 日志渲染器参与) + +> 本文档把 prd.md 的 D1–D7 决策点落实为可执行设计,并给出每个决策的推荐与证据。 +> **所有 D1–D7 决策已于 2026-08-23 由主负责人批准**(见 prd.md「已批准决策」),含 D2 红线扩展与 D5 Metrolib 覆盖深度。§10 保留证据表并标注批准结果。 + +## 1. 边界:什么能改 / 什么不能改 + +### 可改(视图层 + 设置模型 + 主题管线) +- `src/Tailviewer/App.cs` — 应用作用域发布完整中性 `Color` 键(浅/深两套由 ThemeManager 负责,App 构造只做初始占位或保持现状)。 +- `src/Tailviewer/Ui/ThemeManager.cs` — 扩展为同时应用"强调色 + 深色模式",并更新渲染器画刷。 +- `src/Tailviewer/Ui/ThemePalette.cs` 或新增 `src/Tailviewer/Ui/SemanticPalette.cs` — 纯函数计算深/浅中性调色板(可单测)。 +- `src/Tailviewer/Themes/Semantic.xaml`、`Constants.xaml`、`MetrolibTheme.xaml` — 语义 Brush 扩展与 Metrolib 定向中性覆盖。 +- `src/Tailviewer/Settings/UISettings.cs`、`ApplicationSettings.cs`(序列化透传)— 新增深色标志。 +- `src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs`、`SettingsControl.xaml` — 新增开关 + 即时应用。 +- **依赖 D2 批准**:`TextCanvas.cs`、`DataSourceCanvas.cs`、`AbstractLogColumnPresenter.cs`、`TextBrushes.cs`、`LogEntryListView.cs`(仅背景/分隔条画刷,不改虚拟化/分页/命中测试)。 +- `tools/generate_localization.py`(新增字符串)与其生成产物(`Strings*.resx`/`Strings.cs`)。 + +### 不可改(红线,违反即回滚) +- `src/Tailviewer/BusinessLogic/**`。 +- `src/Tailviewer/Ui/**/*ViewModel*.cs`(除 `SettingsFlyoutViewModel` 新增一个布尔属性)、`IMainWindowViewModel.cs`、`AbstractMainPanelViewModel.cs`。 +- `LogEntryListView`/`TextCanvas`/`DataSourceCanvas`/`TextLine`/`TextSegment` 的**渲染算法、分页、虚拟化、命中测试、事件与坐标系逻辑、用户数据语义**——仅允许替换背景/前景画刷来源 + 重绘所需的最小失效(D2 批准范围)。 +- `ThemePalette` 强调色现有契约(`Compute(Color)` 的 Primary*/Secondary* 语义不变,测试基线不变);`ThemeManager` 的强调色行为与实时切换能力不变(仅追加中性色与深色参数,不改既有键名/语义)。 + +### 主负责人附加护栏(超出即 out of scope) +- 保留强调色 `ThemePalette`/`ThemeManager` 契约与实时切换。 +- 不改 `BusinessLogic/` 或无关 ViewModel。 +- 仅新增深色模式字符串并重生成本地化;不触及其他字符串。 +- 渲染热路径零每帧 `DynamicResource` 查找、零新增分配。 + +## 2. 激活与持久化(D1,推荐:二态 + 即时生效) + +现状:`UISettings` 只有 `Language`/`ThemeColor`;`ApplicationSettings.Save` 写 `` 元素属性;`Restore` 按属性名回退默认。 + +推荐方案: +- `UISettings` 新增 `public bool DarkMode { get; set; }`,构造默认 `false`(浅色,保持现有行为)。 +- `Save` 写 `writer.WriteAttributeString("darkmode", XmlConvert.ToString(DarkMode))`;`Restore` 读 `"darkmode"`(`reader.ReadAttributeAsBool` 或等价,缺省/非法回退 `false`);`Clone` 复制。 +- 启动应用:`App.StartApplication` 中 `ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.DarkMode)`(替换现有单参调用)。 +- 切换:`SettingsFlyoutViewModel.DarkMode` setter 写设置 + `SaveAsync()` + `Dispatcher.BeginInvoke(ThemeManager.Apply(...))`(镜像 `ThemeColor` setter 的延迟模式,避免在控件回调中同步改资源重入渲染)。 + +**备选(不推荐本任务做)**:Light/Dark/System 三态需监听 OS 主题变化(注册表/`SystemParameters`),跨 .NET Framework 4.8 且引入"用户设置 + 系统"两层优先级,复杂度明显上升;作为后续任务更合适。 + +## 3. 资源所有权与更新机制 + +### 3.1 所有权(与任务 1 一致并扩展) +``` +UISettings.ThemeColor ──┐ + ├─> ThemeManager.Apply(color, darkMode) +UISettings.DarkMode ───┘ │ + ┌─────────────────────────┼──────────────────────────────┐ + │ 应用作用域 Color 键 │ 渲染器可变画刷(C#) │ + │ Primary*/Secondary*(既有)│ TextBrushes.UpdateTheme │ + │ Surface/SurfaceMuted/ │ + 新增 UpdateNeutral(dark) │ + │ TextPrimary/TextSecondary│ │ + │ Divider/TitleBar/ │ │ + │ OverlayBackground/ │ │ + │ UnfocusedOverlay │ │ + └─────────────────────────┴──────────────────────────────┘ + │ + v + XAML Brush({DynamicResource})—— chrome 层实时跟随 +``` +- `Semantic.xaml`/`Constants.xaml` 只声明 `SolidColorBrush`,其 `Color` 一律 `{DynamicResource}`;`Color` 键只在应用作用域发布(`App` 构造 + `ThemeManager`)。 +- `Constants.xaml` 现有的 `TitleBar`/`OverlayBackground`/`UnfocusedOverlay` 本地 `Color` 键**必须迁出**:`Color` 键移到应用作用域,`TitleBarBrush`/`OverlayBackgroundBrush` 等改为 `Color="{DynamicResource ...}"`(否则深色无法替换)。`UnfocusedOverlay` 是 `#30FFFFFF`(白 19% 透明)语义上偏浅色遮罩,深色可保持不变或略加深(D4)。 + +### 3.2 调色板助手(纯函数,可单测) +新增 `SemanticPalette`(`Compute(bool darkMode)`)返回中性 `Color` 集合,镜像 `ThemePalette` 风格: +```csharp +public sealed class SemanticPalette +{ + public Color Surface, SurfaceMuted, TextPrimary, TextSecondary, Divider, TitleBar, OverlayBackground; + public static SemanticPalette Compute(bool darkMode) { ... } +} +``` +`ThemeManager.Apply(Color primary, bool darkMode)`: +1. `CurrentPrimary = primary`,计算 `ThemePalette.Compute(primary)`,发布 Primary*/Secondary*(现状不变)。 +2. `CurrentDarkMode = darkMode`,计算 `SemanticPalette.Compute(darkMode)`,发布中性 `Color` 键。 +3. `TextBrushes.UpdateTheme(primary)`(现状)+ `TextBrushes.UpdateNeutral(darkMode)`(新增,见 §6)。 + +> 单一入口保证浅色/深色/强调色三者一致性,避免"改了强调色但忘了中性色"的漂移。 + +## 4. 深色调色板基线(D4,主负责人可调) + +| 角色 | 键 | 浅色(现有) | 深色(推荐基线) | 说明 | +|---|---|---|---|---| +| 表面 | `Surface` | `#FFFFFF` | `#1E1E1E` | 主背景(LogViewerControl 根、渲染器画布) | +| 弱表面 | `SurfaceMuted` | `#D8D8D8` | `#2D2D30` | 占位/弱表面 | +| 主前景(新) | `TextPrimary` | `#333333` | `#DCDCDC` | 默认文字(设置页/控件/渲染器默认前景) | +| 次要前景 | `TextSecondary` | `#A0A0A0` | `#9E9E9E` | 提示/次要文字 | +| 分隔线 | `Divider` | `#808080` | `#3F3F46` | 分隔/边框 | +| 标题栏 | `TitleBar` | `#EAEDF2` | `#252526` | 底部信息条 | +| 遮罩 | `OverlayBackground` | `#55000000` | `#99000000` | flyout 遮罩 | +| 禁用前景 | `DisabledForegroundBrush` | `#B7B7B7` | `#6B6B6B` | MetrolibTheme 覆盖 | +| 渲染器未聚焦选中 | —(C#) | `#D7D7D7` | `#3F3F46` | `TextBrushes` | +| 渲染器交替行 | —(C#) | `#E8F1F7` | `#252526` | `TextBrushes.GetAlternatingColor` | + +对比度(相对 `Surface #1E1E1E`,WCAG 相对亮度): +- `TextPrimary #DCDCDC` ≈ 15.3:1(AA/AAA 达标)。 +- `TextSecondary #9E9E9E` ≈ 6.5:1(AA 达标)。 +- 强调色交互见 §7。 + +## 5. 覆盖范围映射(逐文件) + +### 5.1 chrome(主窗口 + 日志查看区) +- `MainWindow.xaml`:flyout 分隔 `DividerBrush` 已 DynamicResource;`OverlayBackgroundBrush` 由 §3.1 迁移后随主题变化;Header `ForegroundBrushInverted`(白)在强调色底上保持不变。 +- `LogViewerControl.xaml`:根 `SurfaceBrush` 已就绪;底部 `TitleBarBrush` 改 DynamicResource 后随主题变化。 + +### 5.2 设置页 + flyout +- `SettingsControl.xaml`:根 `Background="White"` → `{DynamicResource SurfaceBrush}`、`Foreground="#333333"` → `{DynamicResource TextPrimaryBrush}`;三处 `#6B6B6B` → `{DynamicResource TextSecondaryBrush}`。 +- 其他 flyout/数据模板内硬编码中性色(`EmptyStateStyle`、`ImageLabel`、`About`、`ActionCenter`、`QuickFilter`、`HyperlinkRun` 禁用色)按 `research/hardcoded-color-inventory.md` 迁移到语义 Brush;**severity 语义色**(红 `#E81123`/黄 `#FFC300`/绿等)保持不动。 + +### 5.3 Metrolib 定向中性覆盖(D5) +Metrolib 控件在其编译主题字典内用 `{StaticResource}` 硬编码浅色(`theming.md` 已注明)。深色下必须对这些**中性表面/前景/边框**做定向覆盖,否则出现"白底控件挂在深色窗口上"。方法沿用 `theming.md` 的规则:在 `MetrolibTheme.xaml`(`App` 构造中紧跟 Metrolib Generic 之后合并,赢过主题字典)加隐式样式,`BasedOn="{StaticResource {x:Type controls:X}}"`,只重设中性色属性为 `{DynamicResource ...}`。 + +覆盖清单(中性色,非 Fluent 重绘): +- `FlatGroupBox` 标题/背景、`FlatTabControl` 背景与未选中前景、`FlatScrollBar` 轨道/滑块中性色、`Menu`/`FlatContextMenu` 背景与前景、数据源树/侧栏边框(`SeparatorBrush` 来源改为动态中性键)、`ComboBox`/`EditorTextBox`/`PathChooserTextBox`/`FilterTextBox`/`FlatPasswordBox` 的 `Background`/`Foreground`/`BorderBrush`/水印色、`DisabledForegroundBrush`。 +- **明确不做**:不改控件形状、圆角、图标绘制、视觉状态动画、强调色绘制(这些仍走现有 Primary*/Secondary* DynamicResource)。 + +> 边界判定口诀:**换"颜色"= 本任务;换"形状/状态机/布局"= 后续 Fluent 任务**。 + +## 6. 渲染器参与设计(D2,红线扩展,需批准) + +目标:深色下日志主区域不再出现大面积 `Brushes.White`,且默认文字可读;**渲染热路径零新增分配、零每帧资源查找**。 + +### 6.1 现状问题 +`TextCanvas.OnRender`/`DataSourceCanvas.OnRender`/`AbstractLogColumnPresenter.OnRender` 均 `DrawRectangle(Brushes.White)`。`TextBrushes` 的默认前景是 `settings.Other.ForegroundColor`(默认 `Black`);若只把背景变深而前景不动,文字不可见。 + +### 6.2 方案:主题感知可变画刷 + 默认色翻转 +- 在 `TextBrushes` 增加静态可变画刷组(`SolidColorBrush`,不 Freeze): + - `CanvasBackgroundBrush`(画布背景,浅色白 / 深色 `Surface`)。 + - `DefaultForegroundBrush`(非 ColorByLevel 的默认前景,浅色黑 / 深色 `TextPrimary`)。 + - `DefaultBackgroundBrush`(非 ColorByLevel 的默认背景,浅色透明 / 深色透明;供交替行与选中补白)。 + - `SelectedUnfocusedBackgroundBrush` 改为可变(浅 `#D7D7D7` / 深 `#3F3F46`)。 + - `AlternatingBackgroundBrush`(浅 `#E8F1F7` / 深 `#252526`,替代 `GetAlternatingColor` 对白/透明的硬编码返回)。 + - `DataSourceFilenameForegroundBrush` 改为可变(浅 `#808080` / 深 `#9E9E9E`)。 +- `ThemeManager` 新增调用 `TextBrushes.UpdateNeutral(bool darkMode)`,一次性 `.Color =` 更新上述可变画刷(与 `UpdateTheme` 同构)。 +- 三处 `OnRender` 的 `Brushes.White` 改为 `TextBrushes.CanvasBackgroundBrush`(静态引用,无每帧查找/分配)。 +- `TextBrushes.ForegroundBrush`/`BackgroundBrush` 的**非 ColorByLevel 分支**改用 `DefaultForegroundBrush`/`DefaultBackgroundBrush`;**ColorByLevel 分支仍走 `_foregroundBrushes`/`_backgroundBrushes`(用户显式色,D3 保持)**。 +- `LogEntryListView` 分隔条 `Color.FromRgb(225,228,232)` → 一个主题可变画刷(浅 `#E1E4E8` / 深 `#3F3F46`),仅在构造时创建、切换时更新颜色,不重建控件。 + +### 6.3 ColorByLevel 交互(D3) +- 用户通过设置页配置的各级别前景/背景(`LogViewerSettings`)**原样保留**;深色模式下这些显式色可能对比不足,但这是用户显式选择,本任务不擅自改用户数据。 +- 默认(非 ColorByLevel)路径是深色模式可读性的关键,由 `DefaultForegroundBrush` 保证。 + +## 7. 强调色交互与可访问性(D7) + +- 强调色 `Primary` 默认 `#0047AB`(深蓝)。深色表面 `#1E1E1E` 上,`PrimaryForegroundBrush`(白)落在 `PrimaryBrush` 底上,对比约 8.1:1(AA/AAA 达标)——保持现状即可。 +- `MainWindow` 背景与 `LogViewerControl` 工具栏是 `SecondaryBrush`(= 强调色),文字 `PrimaryForegroundBrush`(白):若用户把强调色调成很浅的颜色,白字对比不足。这是**既有行为**,非本任务引入;不扩大范围,但在验收中作为已知限制记录。 +- `SecondaryForeground = Black`(F13)当前无消费方;深色模式下若未来被消费需重新评估,本任务不改其语义。 +- 高亮 `#FFFF4D` + 黑前景、高亮选中 `#FF9632` + 黑前景在深/浅两态均可读,保持不变。 +- 选择(选中)背景 = 强调色 + 白前景,两态一致,保持不变。 + +## 8. 性能约束 + +- 渲染热路径(`OnRender`/`TextLine.Render`/`LogEntryListView.OnTimer`/虚拟化)**零新增每帧 `DynamicResource` 查找、零新增对象分配**——所有主题相关画刷在 `ThemeManager` 切换时一次性更新,渲染仅读取静态 `Brush` 引用。 +- XAML 中新增 `{DynamicResource}` 仅用于 chrome(窗口/设置/侧栏/flyout),不进入逐行渲染;主题切换仍是一次性发布,非每帧计算。 +- `SemanticPalette.Compute` 为纯函数、只在切换时调用一次,无热路径开销。 + +## 9. 兼容性与回滚形状 + +- `net48` + `UseWPF` + `TreatWarningsAsErrors` 零警告。 +- 深色属性可缺省(旧设置文件回退浅色),无 schema 迁移、无 `neededPatching` 需求。 +- 强调色既有单测(`ThemePaletteTest`/`TextBrushesTest`/`UISettingsTest`)基线不破坏。 + +回滚点: +- **R1 资源层**:撤销 `App.cs`/`ThemeManager.cs` 中性键发布 + 撤销 `SemanticPalette` + 恢复 `Constants.xaml` 本地 `Color` 键与 `{StaticResource}` → 回到纯浅色。 +- **R2 设置层**:撤销 `UISettings`/`ApplicationSettings`/`SettingsFlyoutViewModel`/`SettingsControl.xaml` 深色字段与开关。 +- **R3 覆盖层**:撤销 `MetrolibTheme.xaml` 定向覆盖、撤销各 XAML 硬编码色迁移。 +- **R4 渲染器层**:撤销 `TextCanvas`/`DataSourceCanvas`/`AbstractLogColumnPresenter`/`TextBrushes`/`LogEntryListView` 画刷替换,恢复 `Brushes.White` 与硬编码画刷。 +- 任一步触碰 §1 红线(超出 D2 批准范围)即回滚到最近回滚点并回到 Plan 修订。 + +## 10. 决策结论(主负责人 2026-08-23 已批准) + +| 决策 | 结论 | 证据 | +|---|---|---| +| D1 激活模型 | **两态 toggle + 即时生效 + 持久化,默认浅色;不做跟随系统** | 与 `ThemeColor` 实时切换架构同构(F2/F6) | +| D2 渲染器参与 | **参与(红线扩展已批准)**,仅画刷来源/更新 + 最小失效,不改渲染算法/分页/虚拟化/命中测试/事件坐标/用户数据语义 | 日志区是主内容,不参与则深色"不可用" | +| D3 ColorByLevel | **用户显式色保持**,仅默认(非 ColorByLevel)路径随主题翻转 | 用户显式配置不应被主题静默改写 | +| D4 深色值 | **§4 基线批准** | 对比达标(§7) | +| D5 Metrolib 覆盖深度 | **定向中性色覆盖**(应用实际使用的控件),仅颜色/资源,不改形状/布局/模板(最小 setter 之外)/状态机/动画/Fluent | 不覆盖则白底控件悬在深色窗口上 | +| D6 设置入口 | **设置页「主题」分组**,不做 Header 快捷开关 | 最小改动、可发现 | +| D7 默认值 | **默认浅色**,旧 XML 缺失 `darkmode` 回退浅色,无迁移 | 向后兼容 | + +> 上述决策已生效,实现不得静默偏离;任何超出边界的计划项标记 out of scope。 diff --git a/.trellis/tasks/08-23-ui-dark-mode/implement.jsonl b/.trellis/tasks/08-23-ui-dark-mode/implement.jsonl new file mode 100644 index 00000000..e7dc6a1f --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/implement.jsonl @@ -0,0 +1,12 @@ +{"file": ".trellis/spec/ui/index.md", "reason": "WPF UI 总览:无 MVVM 框架、Bootstrapper 启动、Metrolib 依赖"} +{"file": ".trellis/spec/ui/theming.md", "reason": "主题/强调色架构、DynamicResource 规则、冻结画刷陷阱、Metrolib 覆盖方法——本任务核心约束"} +{"file": ".trellis/spec/ui/mvvm.md", "reason": "视图/ViewModel 绑定约定,设置开关不得违反命令/绑定约定"} +{"file": ".trellis/spec/ui/project-structure.md", "reason": "Ui/Settings/Themes 目录职责与本地化再生流程"} +{"file": ".trellis/spec/build/index.md", "reason": "net48/UseWPF/TreatWarningsAsErrors、SDK 项目差异、签名"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "迁移硬编码色值前先搜索、避免遗漏引用"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/research/approved-decisions.md", "reason": "主负责人批准结论与附加护栏,作为实现边界"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/research/theme-architecture.md", "reason": "资源链、ThemeManager、渲染器画刷、设置持久化的仓库证据"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/research/hardcoded-color-inventory.md", "reason": "硬编码中性色迁移目标清单(逐文件)"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/research/dark-palette-and-contrast.md", "reason": "深色调色板基线、对比度、强调色交互分析"} +{"file": ".trellis/tasks/08-23-ui-dark-mode/design.md", "reason": "边界、调色板、覆盖范围、渲染器参与设计、性能与回滚"} +{"file": ".trellis/tasks/archive/2026-08/08-22-ui-semantic-layout/design.md", "reason": "任务 1 语义资源架构与本任务的延续关系"} diff --git a/.trellis/tasks/08-23-ui-dark-mode/implement.md b/.trellis/tasks/08-23-ui-dark-mode/implement.md new file mode 100644 index 00000000..21deeea6 --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/implement.md @@ -0,0 +1,94 @@ +# 实施计划:深色模式(语义 Token + 日志渲染器参与) + +> 每个阶段独立可验证/可回滚。遵守 `design.md` §1 红线清单。 +> **前置**:主负责人已于 2026-08-23 批准 D1–D7(含 D2 红线扩展与 D5 Metrolib 覆盖深度),并给出附加护栏(见 prd.md「已批准决策」)。 +> 状态:规划完成,待 `task.py start` 激活;尚未实现。 + +## 阶段 0:前置确认(主负责人,已完成) + +- [x] D1 激活模型:两态 toggle + 即时生效(默认浅色,不做跟随系统)。 +- [x] D2 渲染器参与:批准扩展红线文件集(`TextCanvas.cs`/`DataSourceCanvas.cs`/`AbstractLogColumnPresenter.cs`/`TextBrushes.cs`/`LogEntryListView.cs` 最小分隔条);仅画刷来源/更新 + 最小失效。 +- [x] D3 ColorByLevel:用户显式色保持,仅默认前景/背景翻转。 +- [x] D4 深色值:采用 design.md §4 基线。 +- [x] D5 Metrolib 覆盖深度:定向中性色覆盖(非 Fluent 重绘,仅颜色/资源)。 +- [x] D6 设置入口:设置页「主题」分组(紧邻 ThemeColor;无 Header 快捷开关)。 +- [x] 附加护栏:保留强调色 ThemePalette/ThemeManager 契约与实时切换;不改 BusinessLogic/无关 ViewModel;仅新增深色字符串重生成本地化;渲染热路径零每帧 DynamicResource/零新增分配。 + +## 阶段 1:调色板与主题管线(回滚点 R1) + +1. [ ] 新增 `src/Tailviewer/Ui/SemanticPalette.cs`:纯函数 `Compute(bool darkMode)` 返回中性 `Color`(design.md §4 基线)。 +2. [ ] 扩展 `ThemeManager.Apply(Color primary, bool darkMode)`:保留强调色逻辑,新增中性 `Color` 键发布 + `TextBrushes.UpdateNeutral(darkMode)`;保留旧签名或改为唯一签名并更新两处调用点(`App.cs:246`、`SettingsFlyoutViewModel`)。 +3. [ ] `App.cs`:语义 `Color` 键发布改由 ThemeManager 统一负责(App 构造可留初始占位,避免空资源);启动应用点改为 `ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.DarkMode)`。 +4. [ ] `Constants.xaml`:`UnfocusedOverlay`/`TitleBar`/`OverlayBackground` 三个 `Color` 键移出(删除本地声明),`TitleBarBrush`/`OverlayBackgroundBrush` 等改 `Color="{DynamicResource ...}"`。 +5. [ ] `Semantic.xaml`:新增 `TextPrimaryBrush`(`Color="{DynamicResource TextPrimary}"`);确认全部 Brush 用 DynamicResource。 +6. [ ] 验证:构建 warning-free;`rg "StaticResource (Primary|Secondary|Surface|TextPrimary|TextSecondary|Divider|TitleBar|OverlayBackground)" src/Tailviewer` 仅允许非主题键(如 `DisabledForegroundBrush` 迁移前的中间态)。 + +## 阶段 2:设置模型与持久化(回滚点 R2) + +7. [ ] `UISettings`:新增 `DarkMode` 布尔(默认 false)+ `Save`/`Restore`/`Clone` 覆盖。 +8. [ ] `SettingsFlyoutViewModel`:新增 `DarkMode` 属性,setter 写设置 + `SaveAsync()` + Dispatcher 延迟 `ThemeManager.Apply(ThemeColor, value)`。 +9. [ ] `SettingsControl.xaml`:主题分组新增深色开关(`CheckBox` 或 `OneWayToggle`,绑定 `DarkMode`)。 +10. [ ] `tools/generate_localization.py`:新增 `DarkMode`(英/zh-CN)字符串;重跑生成三件套(`Strings.resx`/`Strings.zh-CN.resx`/`Strings.cs`)。 +11. [ ] 新增 `UISettingsTest` 深色字段用例(构造默认/Clone/Roundtrip/缺省回退)。 +12. [ ] 验证:`dotnet build`;`UISettingsTest` 通过。 + +## 阶段 3:视图层中性色迁移(回滚点 R3) + +13. [ ] `SettingsControl.xaml`:根 `Background White→SurfaceBrush`、`Foreground #333333→TextPrimaryBrush`、三处 `#6B6B6B→TextSecondaryBrush`。 +14. [ ] 按 `research/hardcoded-color-inventory.md` 迁移 `EmptyStateStyle`、`ImageLabel`、`AboutFlyoutDataTemplate`、`ActionCenter` 中性色、`QuickFilter/FilterToggleButton`、`MetrolibTheme.xaml` 的 `DisabledForegroundBrush`/水印色;severity 色不动。 +15. [ ] 每批构建;grep 校验无主题键 `{StaticResource}` 回归。 + +## 阶段 4:Metrolib 定向中性覆盖(回滚点 R3) + +16. [ ] 在 `MetrolibTheme.xaml` 加隐式样式(`BasedOn="{StaticResource {x:Type controls:X}}"`)覆盖 `FlatGroupBox`、`FlatTabControl`、`FlatScrollBar`、`FlatContextMenu`/`Menu`、`ComboBox`、文本框族(`EditorTextBox`/`PathChooserTextBox`/`FilterTextBox`/`FlatPasswordBox`)的 `Background`/`Foreground`/`BorderBrush`/水印为 `{DynamicResource}` 语义键。 +17. [ ] 数据源树/侧栏 `SeparatorBrush` 来源改为动态中性键(或新增 `DividerBrush` 替代),消除浅色边框残留。 +18. [ ] 验证:构建通过;人工/STA 目检深色下无"白底控件悬空"。 + +## 阶段 5:渲染器参与(回滚点 R4,依赖 D2) + +19. [ ] `TextBrushes`:新增可变画刷组 `CanvasBackgroundBrush`/`DefaultForegroundBrush`/`DefaultBackgroundBrush`/`SelectedUnfocusedBackgroundBrush`(改可变)/`AlternatingBackgroundBrush`/`DataSourceFilenameForegroundBrush`(改可变);新增 `UpdateNeutral(bool darkMode)` 一次性更新;`ForegroundBrush`/`BackgroundBrush` 非 ColorByLevel 分支改用默认画刷;`GetAlternatingColor` 改用 `AlternatingBackgroundBrush`。 +20. [ ] `TextCanvas.OnRender`、`DataSourceCanvas.OnRender`、`AbstractLogColumnPresenter.OnRender`:`Brushes.White` → `TextBrushes.CanvasBackgroundBrush`;`DataSourceCanvas` 默认前景 `Brushes.Black` → `TextBrushes.DefaultForegroundBrush`。 +21. [ ] `LogEntryListView` 分隔条:硬编码 `225,228,232` → 主题可变画刷(浅/深值见 design.md §4)。 +22. [ ] 验证:构建 warning-free;STA 单测(`LogEntryListViewTest`/`TextCanvasTest`)通过;深色下渲染器目检可读。 + +## 阶段 6:回归与收尾 + +23. [ ] 运行目标 UI STA 单测:`MainWindowTest`、`LogViewerControlTest`、`LogEntryListViewTest`、`TextCanvasTest` + `UISettingsTest` + 新增中性调色板单测。 +24. [ ] 强调色回归:浅/深两态切换强调色实时生效(`rg "StaticResource Primary"` 为空)。 +25. [ ] 红线审计:`git diff --name-only` 仅含 D2 批准的渲染器文件 + 视图/设置/主题文件;无 `BusinessLogic/**`、无 ViewModel(除 `SettingsFlyoutViewModel`)。 +26. [ ] `git diff --check` 通过。 +27. [ ] 深色/浅色目检清单:主窗口、日志渲染器、设置页、侧栏、About、ActionCenter、flyout 遮罩、滚动条、菜单、组合框均正确。 + +## 验证命令 + +```bash +dotnet build src/Tailviewer/Tailviewer.csproj +dotnet build src/Tailviewer.Tests/Tailviewer.Tests.csproj + +python tools/generate_localization.py +git status --porcelain # 确认 Strings*.resx / Strings.cs 已重新生成 + +rg -n "StaticResource (Primary|Secondary|Surface|TextPrimary|TextSecondary|Divider|TitleBar|OverlayBackground)" src/Tailviewer # 应为空 + +# 目标 UI STA(nunit3-console 3.12.0,参考任务 1) +nunit3-console.exe bin\Tailviewer.Tests.dll "--where=test =~ 'MainWindowTest' or test =~ 'LogViewerControlTest' or test =~ 'LogEntryListViewTest' or test =~ 'TextCanvasTest' or test =~ 'UISettingsTest'" + +git diff --check +git diff --name-only # 红线文件审计 +``` + +## Review Gates + +- G1(阶段 1 后):`SemanticPalette` 纯函数可单测;`ThemeManager.Apply(color, darkMode)` 单入口;`Color` 键只在应用作用域;无主题键 `{StaticResource}` 回归。 +- G2(阶段 2 后):深色属性 roundtrip + 缺省回退;开关即时切换无重启;本地化三件套已重生成。 +- G3(阶段 3/4 后):无"白底控件悬空";severity 色与强调色绘制未动;未引入 Fluent 重绘。 +- G4(阶段 5 后):渲染热路径零新增每帧资源查找/分配;STA 渲染单测通过。 +- G5(阶段 6 后):构建 warning-free + 目标 STA 通过 + 红线审计通过。 + +## 回滚点 + +- R1:撤销调色板/主题管线(恢复浅色语义键 + `Constants.xaml` 本地 `Color` 键 + 单参 `Apply`)。 +- R2:撤销设置模型与开关。 +- R3:撤销视图层迁移与 Metrolib 覆盖。 +- R4:撤销渲染器画刷替换,恢复 `Brushes.White` 与硬编码画刷。 +- 越界即回滚:任一步触碰 §1 红线(超出 D2 批准范围),立即 revert 到最近回滚点并回到 Plan。 diff --git a/.trellis/tasks/08-23-ui-dark-mode/prd.md b/.trellis/tasks/08-23-ui-dark-mode/prd.md new file mode 100644 index 00000000..2e64f7d0 --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/prd.md @@ -0,0 +1,83 @@ +# 深色模式(语义 Token + 日志渲染器参与) + +## Goal + +在任务 08-22-ui-semantic-layout 建立的语义色基础之上,实现一个**可用**的深色模式:切换后整个应用(窗口 chrome、日志查看区、设置/侧栏/About、以及自定义高性能日志渲染器)即时切换为深色,无需重启,且不破坏现有的强调色(ThemeColor)实时切换能力。 + +约束边界: +- **保留** WPF / Metrolib、MVVM 无框架结构、`LogEntryListView`/`TextCanvas`/`DataSourceCanvas` 的自绘高性能渲染与 33ms 虚拟化机制。 +- **不做** Fluent 全控件重排/重绘(留给后续任务)、不做与主题无关的重构。 +- 语义 `Color`/`Brush` 资源是任务 1 的产出,本任务以其为基础扩展,不推翻。 + +## 已批准决策(主负责人 2026-08-23 审核通过) + +> 以下决策已由主负责人拍板,作为本任务的执行边界;实现时不得静默扩大范围。 + +- D1 **激活/持久化模型**:两态 Light/Dark 切换 + 即时生效 + 持久化,默认浅色;**不做**跟随系统(Light/Dark/System)模式。 +- D2 **日志渲染器参与(红线扩展,已批准)**:批准 `TextCanvas`、`DataSourceCanvas`、`AbstractLogColumnPresenter`、`TextBrushes`,以及 `LogEntryListView` 的**最小分隔条变更**。允许的改动**仅限**主题感知的画刷来源/更新,以及重绘所需的最小失效(invalidate);**不得**改动渲染算法、分页、虚拟化、命中测试、事件/坐标逻辑或用户数据语义。 +- D3 **ColorByLevel 交互**:保留用户配置的各级别前景/背景值;仅默认(非 ColorByLevel)渲染颜色随主题切换。 +- D4 **深色调色板基线**:批准文档化基线——`Surface #1E1E1E`、`SurfaceMuted #2D2D30`、`TextPrimary #DCDCDC`、`TextSecondary #9E9E9E`、`Divider #3F3F46`、`TitleBar #252526`、`OverlayBackground #99000000`,及 design.md §4 记录的渲染器各值。 +- D5 **Metrolib 覆盖深度**:批准对**应用实际使用的** Metrolib 控件做定向中性色覆盖。仅改颜色/资源;**不得**改动控件形状、布局、模板(超出最小样式 setter)、视觉状态机、动画或 Fluent 样式。 +- D6 **设置入口**:设置页「主题」分组,紧邻 ThemeColor;本任务**不做** Header 快捷开关。 +- D7 **默认值/迁移**:默认浅色;旧设置文件缺失 `darkmode` 属性时回退浅色,无 schema 迁移。 + +### 主负责人附加护栏 + +- 保留现有强调色 `ThemePalette`/`ThemeManager` 契约与实时强调色切换。 +- 不改 `BusinessLogic/` 或无关 ViewModel。 +- 除新增深色模式字符串外,**不重新生成本地化**(不触及其他字符串)。 +- 渲染热路径保持**零**每帧 `DynamicResource` 查找、**零**新增分配。 +- 任何计划项若会超出上述边界,标记为 out of scope,而非静默扩大。 + +## 非目标(本任务不做) + +- 不做 Fluent 全控件重绘、不改控件形状/视觉状态机。 +- 不做「跟随系统」主题(Light/Dark/System)、不做自动定时切换。 +- 不改 `BusinessLogic/`、不改任何 ViewModel(除设置页 `SettingsFlyoutViewModel` 新增一个布尔属性——见 §需求)、不改本地化逻辑(仅新增字符串键并重新生成)。 +- 不改 `src/Installer`、`tools/`、`.github/`、插件项目。 + +## 已确认事实(仓库证据,2026-08-23 复核) + +| # | 事实 | 证据 | +|---|------|------| +| F1 | 无 `App.xaml`,启动对象 `Bootstrapper`;`App` 构造中发布语义 `Color` 键到应用作用域,再合并 Metrolib `Generic.xaml` + `MetrolibTheme.xaml` | `App.cs:41-52` | +| F2 | `ThemeManager.Apply(Color)` 只发布强调色(Primary*/Secondary*)并调用 `TextBrushes.UpdateTheme`;启动时在 `App.StartApplication` 调用一次 | `ThemeManager.cs`、`App.cs:246` | +| F3 | 语义 Brush(`SurfaceBrush` 等)`Color` 用 `{DynamicResource}`,`Color` 键**仅**在应用作用域发布;`Semantic.xaml` 不本地声明 `Color` 键 | `Semantic.xaml`、`App.cs` | +| F4 | `Constants.xaml` 仍有本地声明的中性 `Color` 键:`TitleBar #EAEDF2`、`OverlayBackground #55000000`、`UnfocusedOverlay #30FFFFFF`,且对应 Brush 用 `{StaticResource}` | `Constants.xaml:18-24` | +| F5 | 设置持久化:`UISettings`(`Language`/`ThemeColor`)经 `ApplicationSettings.Save/Restore` 写入 `` 元素;`SaveAsync` 防抖;属性可缺省回退 | `UISettings.cs`、`ApplicationSettings.cs` | +| F6 | 设置页 `SettingsFlyoutViewModel.ThemeColor` setter 已示范"即时应用 + `SaveAsync` + Dispatcher 延迟"模式 | `SettingsFlyoutViewModel.cs:248-270` | +| F7 | 日志渲染器三处硬编码 `Brushes.White` 背景:`TextCanvas.OnRender`、`DataSourceCanvas.OnRender`、`AbstractLogColumnPresenter.OnRender` | 各 `OnRender` | +| F8 | `TextBrushes` 静态画刷:选中背景=强调色(可变)、选中前景=白、未聚焦选中=#D7D7D7、高亮前景=黑/背景=#FFFF4D、行号前景=强调色(可变)、数据源文件名前景=#808080;交替行色 `GetAlternatingColor` 返回 `#E8F1F7` | `TextBrushes.cs` | +| F9 | `LogEntryListView` 分隔条 `Fill = Color.FromRgb(225,228,232)` 为代码内置 | `LogEntryListView.cs:194` | +| F10 | 默认级别色:Other/Info 前景 `Colors.Black`、背景 `Transparent`;Warning/Error/Fatal 前景白/背景黄红;Trace/Debug 前景 #808080 | `LogViewerSettings.cs` | +| F11 | 硬编码中性色散落点:设置页 `#333333`/`#6B6B6B`、`EmptyStateStyle` `#A0A0A0`、`ImageLabel` `#E0E0E0`/`#B7B7B7`、ActionCenter `#333333`、`QuickFilter` `#444444`、`MetrolibTheme.xaml` `DisabledForegroundBrush #B7B7B7`、FilterTextBox 水印 `#717171` | 见 `research/hardcoded-color-inventory.md` | +| F12 | 强调色 `Secondary = primary`,`MainWindow` 背景与 `LogViewerControl` 工具栏均用 `{DynamicResource SecondaryBrush}`(即强调色底),文字用 `PrimaryForegroundBrush`(白) | `ThemePalette.cs`、`MainWindow.xaml:66`、`LogViewerControl.xaml` | +| F13 | `SecondaryForegroundBrush`(`SecondaryForeground=Black`)当前**无消费方** | grep 仅 `Constants.xaml:37` 定义 | + +## 需求 + +- R1 新增**深色语义调色板**:在应用作用域按主题发布中性 `Color` 键(浅色=现有值,深色=新基线),并把 `Constants.xaml` 中本地声明的 `TitleBar`/`OverlayBackground`/`UnfocusedOverlay` 迁到应用作用域(Brush 用 `{DynamicResource}`),保持"`Color` 键只在应用作用域、Brush 用 DynamicResource"规则。 +- R2 新增 `TextPrimary` 语义角色(默认前景),替换设置页/控件硬编码的 `#333333` 等默认前景;所有新/改语义 Brush 与消费方用 `{DynamicResource}`。 +- R3 激活与持久化:`UISettings` 新增深色模式标志(默认浅色),Save/Restore/Clone 覆盖,`ApplicationSettings` `` 元素序列化;启动时与 `ThemeColor` 一同应用。 +- R4 即时切换:设置页「主题」分组新增深色开关,切换即 `ThemeManager` 应用新主题(Dispatcher 延迟,同强调色模式),无需重启。 +- R5 渲染器参与(**D2 已批准**):`TextCanvas`/`DataSourceCanvas`/`AbstractLogColumnPresenter` 的背景由可变画刷提供并随主题切换;`TextBrushes` 新增"默认前景/背景、未聚焦选中背景、交替行色、分隔条"等主题相关画刷,在 `ThemeManager` 切换时一次性更新,不在 `OnRender` 每帧做资源查找。仅允许画刷来源/更新与重绘所需的最小失效;不改渲染算法/分页/虚拟化/命中测试/事件坐标。 +- R6 强调色兼容:深色/浅色两态下,强调色切换仍实时生效;`PrimaryForegroundBrush`(白)等强调色消费方行为不变。 +- R7 可访问性:深色下默认前景/次要前景对深色表面满足可读对比(≥4.5:1 常规文字,见 design.md §7)。 +- R8 不回退:所有主题相关键/画刷不得出现 `{StaticResource Primary*}`/`{StaticResource Surface*}` 回归;渲染热路径零新增分配/每帧资源查找。 +- R9 本地化:新增"深色模式"字符串键(英/zh-CN),重跑 `python tools/generate_localization.py` 生成三件套。 + +## 验收标准 + +- [ ] 设置页新增深色开关;开启后整个应用(窗口 chrome、日志查看区含渲染器、设置/侧栏/About/flyout、Metrolib 控件中性色)即时切换为深色,无需重启;关闭即恢复浅色。 +- [ ] 深色状态持久化:重启后保持上次选择;旧设置文件缺失属性时回退浅色(`UISettingsTest` 覆盖 roundtrip/缺省)。 +- [ ] 强调色在浅色/深色下均实时切换(无 `{StaticResource Primary*}` 回归)。 +- [ ] 日志渲染器:深色下画布深底、默认前景浅色可读;`ColorByLevel` 用户显式色不变;选中/高亮/悬停/未聚焦选中在深色下均可见。 +- [ ] 可访问性目检:深色下默认/次要文字对比达标(人工项 + design.md §7 数值)。 +- [ ] 红线审计:仅 D2 批准文件(`TextCanvas`/`DataSourceCanvas`/`AbstractLogColumnPresenter`/`TextBrushes`/`LogEntryListView` 最小分隔条)被触碰,且只改画刷来源/更新与最小失效;不改 `BusinessLogic/**`、不改 ViewModel(`SettingsFlyoutViewModel` 仅新增布尔属性)、不改渲染算法/分页/虚拟化/命中测试/事件坐标。 +- [ ] 解决方案构建 warning-free(`net48` + `TreatWarningsAsErrors`)。 +- [ ] 目标 UI STA 单测通过(`MainWindowTest`/`LogViewerControlTest`/`LogEntryListViewTest`/`TextCanvasTest`)+ 新增 `UISettingsTest` 深色字段 + 中性调色板单测。 +- [ ] `git diff --check` 通过;`generate_localization.py` 三件套已重新生成且无手改。 + +## Notes + +- 本任务是 UI 主题现代化序列的第 2 个交付物(第 1 个已归档:08-22-ui-semantic-layout,第 3 个为后续 Fluent 全控件化)。当前以**独立任务**(`parent: null`)形态创建,与任务 1 一致;如主负责人希望建立正式 parent/child 树,可后续 `task.py add-subtask` 挂接,不影响本任务验收。 diff --git a/.trellis/tasks/08-23-ui-dark-mode/research/approved-decisions.md b/.trellis/tasks/08-23-ui-dark-mode/research/approved-decisions.md new file mode 100644 index 00000000..b429a11b --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/research/approved-decisions.md @@ -0,0 +1,31 @@ +# 已批准决策记录(2026-08-23) + +主负责人审核并批准了 prd.md 的 D1–D7 决策。此文件为权威批准记录,供实现/检查子代理对齐。 + +## 批准结论 + +| 决策 | 结论 | +|---|---| +| D1 | 两态 Light/Dark 切换,即时生效,持久化,默认浅色;不做跟随系统模式 | +| D2 | 批准 `TextCanvas`/`DataSourceCanvas`/`AbstractLogColumnPresenter`/`TextBrushes` 参与 + `LogEntryListView` 最小分隔条变更。仅主题感知画刷来源/更新 + 重绘所需最小失效;不改渲染算法/分页/虚拟化/命中测试/事件坐标/用户数据语义 | +| D3 | 保留用户配置的 ColorByLevel 前景/背景;仅默认(非 ColorByLevel)渲染颜色随主题切换 | +| D4 | 批准基线调色板:`Surface #1E1E1E`、`SurfaceMuted #2D2D30`、`TextPrimary #DCDCDC`、`TextSecondary #9E9E9E`、`Divider #3F3F46`、`TitleBar #252526`、`OverlayBackground #99000000`,及 design.md §4 记录的渲染器各值 | +| D5 | 批准对应用实际使用的 Metrolib 控件做定向中性色覆盖;仅颜色/资源,不改形状/布局/模板(最小 setter 之外)/状态机/动画/Fluent | +| D6 | 设置页「主题」分组,紧邻 ThemeColor;本任务无 Header 快捷开关 | +| D7 | 默认浅色;旧 XML 缺失 `darkmode` 属性回退浅色,无 schema 迁移 | + +## 附加护栏 + +- 保留强调色 `ThemePalette`/`ThemeManager` 契约与实时强调色切换。 +- 不改 `BusinessLogic/` 或无关 ViewModel。 +- 仅新增深色模式字符串并重生成本地化;不触及其他字符串。 +- 渲染热路径零每帧 `DynamicResource` 查找、零新增分配。 +- 任何超出边界的计划项标记 out of scope,不得静默扩大。 + +## 涉及文件(D2 红线扩展批准范围) + +- `src/Tailviewer/Ui/LogView/TextCanvas.cs` — 仅背景画刷来源(`Brushes.White` → `TextBrushes.CanvasBackgroundBrush`)+ 最小失效。 +- `src/Tailviewer/Ui/LogView/DataSource/DataSourceCanvas.cs` — 仅背景画刷来源 + 默认前景画刷来源。 +- `src/Tailviewer/Ui/LogView/AbstractLogColumnPresenter.cs` — 仅背景画刷来源。 +- `src/Tailviewer/Ui/LogView/TextBrushes.cs` — 仅新增/改造可变画刷与 `UpdateNeutral`,不改渲染算法。 +- `src/Tailviewer/Ui/LogView/LogEntryListView.cs` — 仅分隔条画刷来源(最小变更)。 diff --git a/.trellis/tasks/08-23-ui-dark-mode/research/dark-palette-and-contrast.md b/.trellis/tasks/08-23-ui-dark-mode/research/dark-palette-and-contrast.md new file mode 100644 index 00000000..df2de272 --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/research/dark-palette-and-contrast.md @@ -0,0 +1,42 @@ +# 研究:深色调色板与对比度 / 强调色交互 + +调查日期:2026-08-23 + +## 推荐深色调色板(VS Code 类中性灰,可调) + +| 键 | 深色值 | 用途 | +|---|---|---| +| `Surface` | `#1E1E1E` | 主背景 | +| `SurfaceMuted` | `#2D2D30` | 弱表面/占位 | +| `TextPrimary`(新) | `#DCDCDC` | 默认前景 | +| `TextSecondary` | `#9E9E9E` | 次要前景 | +| `Divider` | `#3F3F46` | 分隔/边框 | +| `TitleBar` | `#252526` | 标题栏/底部信息条 | +| `OverlayBackground` | `#99000000` | flyout 遮罩(黑 60%) | +| 禁用前景 | `#6B6B6B` | `DisabledForegroundBrush` | +| 未聚焦选中(C#) | `#3F3F46` | `SelectedUnfocusedBackgroundBrush` | +| 交替行(C#) | `#252526` | `AlternatingBackgroundBrush` | + +## WCAG 相对亮度对比(对 `Surface #1E1E1E`) + +| 前景 | 对比度 | 达标 | +|---|---|---| +| `#DCDCDC` | ≈ 15.3:1 | AA / AAA | +| `#9E9E9E` | ≈ 6.5:1 | AA | +| `#FFFFFF`(PrimaryForeground 落在强调色底) | 取决于强调色 | 见下 | + +## 强调色交互分析 + +- 默认强调色 `#0047AB`(深蓝)。`PrimaryForegroundBrush` = 白,落在 `PrimaryBrush`(`#0047AB`)底上:相对亮度 `#0047AB`≈0.026,白≈1.0 → 对比 ≈ (1.0+0.05)/(0.026+0.05) ≈ 13.8:1,AA/AAA 达标。 +- `MainWindow` 背景与 `LogViewerControl` 工具栏是 `SecondaryBrush`(= 强调色),文字 `PrimaryForegroundBrush`(白)。**若用户把强调色调成很浅的颜色,白字对比不足**——这是既有行为,非本任务引入;验收时记录为已知限制,不扩大范围。 +- 选中(选中背景=强调色 + 白前景)在深/浅两态一致,无需改动。 +- 高亮 `#FFFF4D`+黑前景、高亮选中 `#FF9632`+黑前景,深/浅两态均对比充足,保持不变。 + +## 强调色在深色表面上的建议(供未来,不在本任务) + +- 若后续想让强调色在深色下更醒目,可在 `ThemePalette`/`SemanticPalette` 增加"深色下自动 Lighten 强调色"的派生规则;本任务**不做**,保持强调色语义与 `ThemePaletteTest` 基线不变。 + +## 结论 + +- 深色可读性主要由 `TextPrimary`/`TextSecondary` 对 `Surface` 保证,基线值对比达标。 +- 强调色交互不新增风险(既有白字-on-强调色行为延续);仅记录浅色强调色的已知限制。 diff --git a/.trellis/tasks/08-23-ui-dark-mode/research/hardcoded-color-inventory.md b/.trellis/tasks/08-23-ui-dark-mode/research/hardcoded-color-inventory.md new file mode 100644 index 00000000..1ab452da --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/research/hardcoded-color-inventory.md @@ -0,0 +1,43 @@ +# 研究:硬编码中性色清单(深色模式迁移目标) + +调查日期:2026-08-23。范围 `src/Tailviewer`,仅列**中性色**(需随主题翻转);severity 语义色(红/黄/绿)与强调色**不列**(保持不变)。 + +## XAML + +| 位置 | 值 | 迁移目标 | +|---|---|---| +| `Themes/Constants.xaml:18` | `UnfocusedOverlay #30FFFFFF`(本地 Color) | 迁应用作用域 + `{DynamicResource}` | +| `Themes/Constants.xaml:19` | `TitleBar #EAEDF2`(本地 Color) | 迁应用作用域 + `{DynamicResource}` | +| `Themes/Constants.xaml:21` | `OverlayBackground #55000000`(本地 Color) | 迁应用作用域 + `{DynamicResource}` | +| `Themes/MetrolibTheme.xaml:10` | `DisabledForegroundBrush #B7B7B7` | `TextSecondary`/新 `Disabled` 语义键,`{DynamicResource}` | +| `Themes/MetrolibTheme.xaml:369` | FilterTextBox 水印 `Foreground="#717171"` | `TextSecondaryBrush` | +| `Themes/MetrolibTheme.xaml:452` | FilterTextBox 无效态 `Background="#E81123"` | 语义错误色(保持红色系,非中性,可不动) | +| `Ui/EmptyStateStyle.xaml:7` | `Foreground #A0A0A0` | `TextSecondaryBrush` | +| `Ui/ImageLabel.xaml:15,18` | `#E0E0E0`(浅)/`#B7B7B7`(深) | `TextSecondary`/`Disabled` 语义键 | +| `Ui/About/AboutFlyoutDataTemplate.xaml:8` | `Foreground #333333` | `TextPrimaryBrush` | +| `Ui/ActionCenter/BugTemplate.xaml:23` | `Fill #E81123` | 保持(错误红) | +| `Ui/ActionCenter/NotificationTemplate.xaml:36,40,44` | `#333333`/`#FFC300`/`#E81123` | 中性 `#333333`→`TextPrimaryBrush`;黄/红保持 | +| `Ui/ActionCenter/ExportTemplate.xaml:28` | `Fill #333333` | `TextPrimaryBrush` | +| `Ui/QuickFilter/FilterToggleButton.xaml:14` | `Fill #444444` | `TextPrimaryBrush` | +| `Ui/Settings/SettingsControl.xaml:14` | `Foreground #333333` | `TextPrimaryBrush` | +| `Ui/Settings/SettingsControl.xaml:14` | `Background White` | `SurfaceBrush` | +| `Ui/Settings/SettingsControl.xaml:54,377,401` | `#6B6B6B` | `TextSecondaryBrush` | + +## C# + +| 位置 | 值 | 迁移目标 | +|---|---|---| +| `Ui/LogView/AbstractLogColumnPresenter.cs:100` | `Brushes.White`(OnRender 背景) | `TextBrushes.CanvasBackgroundBrush`(D2) | +| `Ui/LogView/DataSource/DataSourceCanvas.cs:85` | `Brushes.White`(OnRender 背景) | `TextBrushes.CanvasBackgroundBrush`(D2) | +| `Ui/LogView/DataSource/DataSourceCanvas.cs:224` | `Brushes.Black`(默认前景) | `TextBrushes.DefaultForegroundBrush`(D2) | +| `Ui/LogView/TextCanvas.cs:285` | `Brushes.White`(OnRender 背景) | `TextBrushes.CanvasBackgroundBrush`(D2) | +| `Ui/LogView/TextBrushes.cs:31-43` | 选中/高亮/未聚焦选中/文件名前景硬编码 | 见 design.md §6.2 可变画刷化(D2) | +| `Ui/LogView/TextBrushes.cs:166` | `#E8F1F7`(交替行) | `TextBrushes.AlternatingBackgroundBrush`(D2) | +| `Ui/LogView/LogEntryListView.cs:194` | `Color.FromRgb(225,228,232)`(分隔条) | 主题可变画刷(D2) | +| `Ui/SidePanel/HyperlinkRun.cs:144` | `DisabledBrush #B7B7B7` | `TextSecondary` 语义键(C# 侧可选) | + +## 结论 + +- 中性色迁移量大但机械;severity 色(`#E81123`/`#FFC300`)与强调色不动。 +- C# 渲染器侧的迁移全部落入 D2 红线扩展范围,需主负责人批准;XAML 侧(设置页/About/ActionCenter/EmptyState/ImageLabel)属于"视图层迁移",不触碰红线。 +- `HyperlinkRun` 是 WPF `Run` 子类,若迁移需谨慎(冻结画刷/静态字段);可延后或仅接受浅色禁用态,标记为已知限制。 diff --git a/.trellis/tasks/08-23-ui-dark-mode/research/theme-architecture.md b/.trellis/tasks/08-23-ui-dark-mode/research/theme-architecture.md new file mode 100644 index 00000000..e243ec1c --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/research/theme-architecture.md @@ -0,0 +1,53 @@ +# 研究:主题/设置架构与资源链 + +调查日期:2026-08-23(dark mode 任务规划阶段) + +## 结论 + +1. **启动与资源合并链** + - 无 `App.xaml`;启动对象 `Bootstrapper`(`Tailviewer.csproj` ``)。 + - `App` 构造函数(`App.cs:41-52`)顺序: + 1. 应用作用域发布语义 `Color` 键:`Surface`/`SurfaceMuted`/`TextSecondary`/`Divider`。 + 2. `Resources.MergedDictionaries.Add(Metrolib Generic.xaml)`。 + 3. `Resources.MergedDictionaries.Add(Tailviewer MetrolibTheme.xaml)`。 + - `MetrolibTheme.xaml` 内部 merge `Themes/Constants.xaml`;`Constants.xaml` 内部 merge Metrolib `Constants.xaml` + `Themes/Semantic.xaml`。 + +2. **主题应用管线** + - `ThemeManager.Apply(Color primary)`(`Ui/ThemeManager.cs`):设置 `CurrentPrimary`、`TextBrushes.UpdateTheme(primary)`,把 `ThemePalette.Compute(primary)` 的 Primary*/Secondary* `Color` 写入 `Application.Current.Resources`;`Application.Current == null` 时 no-op(测试/设计时)。 + - 启动调用点:`App.StartApplication` 中 `ThemeManager.Apply(settings.Ui.ThemeColor)`(`App.cs:246`),发生在 `new App()` 之后、`window.Show()` 之前。 + - 设置页 `SettingsFlyoutViewModel.ThemeColor` setter(`SettingsFlyoutViewModel.cs:248-270`):写 `_settings.Ui.ThemeColor` → `SaveAsync()` → `Dispatcher.BeginInvoke(ThemeManager.Apply)`(避免控件回调中同步改资源重入渲染)。 + +3. **语义资源所有权规则(任务 1 已建立,本任务扩展)** + - `Semantic.xaml` 只声明 `SolidColorBrush`,`Color` 一律 `{DynamicResource}`;`Color` 键只在应用作用域发布(`App.cs`)。 + - 原因:`Constants.xaml` 本地声明 `Color` 键会遮蔽应用作用域值,阻断运行时替换(`theming.md` 陷阱)。 + - **遗留问题**:`Constants.xaml:18-24` 仍本地声明 `UnfocusedOverlay`/`TitleBar`/`OverlayBackground` 三个 `Color` 键,且 `TitleBarBrush`/`OverlayBackgroundBrush` 用 `{StaticResource}`。深色模式必须把它们迁到应用作用域并改 `{DynamicResource}`。 + +4. **设置持久化** + - `UISettings`(`Settings/UISettings.cs`):`Language`(string,默认 "en")、`ThemeColor`(`Color`,默认 `#0047AB`);`Save(XmlWriter)` 写属性、`Restore(XmlReader)` 按名回退、`Clone`。 + - `ApplicationSettings.Save()`:写 `` 元素调 `_ui.Save(writer)`;`Restore` 按元素名分发到 `_ui.Restore`。`SaveAsync()` 经 `_saveTask` 串行防抖 + `AllowSave` 门控。 + - 新增 `DarkMode` 字段只需:`UISettings` 属性 + Save/Restore/Clone + 测试;`ApplicationSettings` 无需结构改动。 + +5. **渲染器画刷现状(D2 相关)** + - `TextCanvas.OnRender`:`DrawRectangle(Brushes.White)`(`TextCanvas.cs:285`)。 + - `DataSourceCanvas.OnRender`:`DrawRectangle(Brushes.White)`(`DataSourceCanvas.cs:85`);`CreateFormattedText` 默认前景 `Brushes.Black`。 + - `AbstractLogColumnPresenter.OnRender`:`DrawRectangle(Brushes.White)`(`AbstractLogColumnPresenter.cs:100`)。 + - `TextBrushes`(`Ui/LogView/TextBrushes.cs`): + - 静态可变画刷(`UpdateTheme` 改 `.Color`):`SelectedBackgroundBrush`(=强调色)、`LineNumberForegroundBrush`(=强调色)、`DataSourceCharacterCodeForegroundBrush`(=强调色)。 + - 静态不可变画刷:`SelectedForegroundBrush=White`、`SelectedUnfocusedBackgroundBrush=#D7D7D7`、`HighlightedForegroundBrush=Black`、`HighlightedBackgroundBrush=#FFFF4D`、`HighlightedSelectedForegroundBrush=Black`、`HighlightedSelectedBackgroundBrush=#FF9632`、`DataSourceFilenameForegroundBrush=#808080`。 + - 实例级 `_foregroundBrushes`/`_backgroundBrushes`/`_alternateBackgroundBrushes` 来自 `ILogViewerSettings`(用户可配置级别色);无 settings 时回退 `Black`/`White`。 + - `GetAlternatingColor`:对 `White`/透明背景返回 `#E8F1F7`,否则原样。 + - `LogEntryListView` 构造分隔条:`Fill = new SolidColorBrush(Color.FromRgb(225,228,232))`(`LogEntryListView.cs:194`)。 + +6. **强调色在深色下的关键事实** + - `ThemePalette.Secondary = primary`;`MainWindow` 背景、`LogViewerControl` 工具栏 `Background={DynamicResource SecondaryBrush}`(强调色底),文字 `PrimaryForegroundBrush`(白)。因此深色模式不改变这两处"强调色底 + 白字"的组合。 + - `SecondaryForeground = Black` 对应的 `SecondaryForegroundBrush` 当前无消费方(grep 仅定义处)。 + +## 涉及文件(只读证据) + +- src/Tailviewer/App.cs(构造 + StartApplication 主题应用点) +- src/Tailviewer/Ui/ThemeManager.cs、ThemePalette.cs +- src/Tailviewer/Themes/Constants.xaml、Semantic.xaml、MetrolibTheme.xaml +- src/Tailviewer/Settings/UISettings.cs、ApplicationSettings.cs +- src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs、SettingsControl.xaml +- src/Tailviewer/Ui/LogView/TextCanvas.cs、TextBrushes.cs、TextLine.cs、AbstractLogColumnPresenter.cs、DataSource/DataSourceCanvas.cs、LogEntryListView.cs +- .trellis/spec/ui/theming.md(DynamicResource 规则、冻结画刷陷阱、Metrolib 覆盖规则) diff --git a/.trellis/tasks/08-23-ui-dark-mode/task.json b/.trellis/tasks/08-23-ui-dark-mode/task.json new file mode 100644 index 00000000..e6f25a20 --- /dev/null +++ b/.trellis/tasks/08-23-ui-dark-mode/task.json @@ -0,0 +1,26 @@ +{ + "id": "ui-dark-mode", + "name": "ui-dark-mode", + "title": "Dark mode (semantic tokens + log renderer participation)", + "description": "基于语义 token 实现两态深色模式(即时切换+持久化,默认浅色);日志渲染器(TextCanvas/DataSourceCanvas/AbstractLogColumnPresenter/TextBrushes/LogEntryListView 最小分隔条)主题感知画刷参与;Metrolib 定向中性色覆盖;保留强调色契约与实时切换;不做 Fluent 全控件化与跟随系统模式", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "brofea", + "assignee": "brofea", + "createdAt": "2026-08-23", + "completedAt": null, + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/src/Tailviewer.Tests/Settings/UISettingsTest.cs b/src/Tailviewer.Tests/Settings/UISettingsTest.cs index f061b350..0a2a600d 100644 --- a/src/Tailviewer.Tests/Settings/UISettingsTest.cs +++ b/src/Tailviewer.Tests/Settings/UISettingsTest.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Text; using System.Windows.Media; using System.Xml; @@ -45,6 +45,7 @@ public void TestConstruction() var settings = new UISettings(); settings.Language.Should().Be(UISettings.DefaultLanguage); settings.ThemeColor.Should().Be(UISettings.DefaultThemeColor); + settings.DarkMode.Should().BeFalse(); } [Test] @@ -59,13 +60,15 @@ public void TestClone() var settings = new UISettings { Language = "zh-CN", - ThemeColor = Colors.Red + ThemeColor = Colors.Red, + DarkMode = true }; var clone = settings.Clone(); clone.Should().NotBeSameAs(settings); clone.Language.Should().Be("zh-CN"); clone.ThemeColor.Should().Be(Colors.Red); + clone.DarkMode.Should().BeTrue(); } [Test] @@ -74,12 +77,14 @@ public void TestRoundtrip() var settings = new UISettings { Language = "zh-CN", - ThemeColor = Color.FromRgb(0x12, 0x34, 0x56) + ThemeColor = Color.FromRgb(0x12, 0x34, 0x56), + DarkMode = true }; var restored = Restore(Save(settings)); restored.Language.Should().Be("zh-CN"); restored.ThemeColor.Should().Be(Color.FromRgb(0x12, 0x34, 0x56)); + restored.DarkMode.Should().BeTrue(); } [Test] @@ -88,6 +93,7 @@ public void TestRestoreFromEmpty() var restored = Restore(""); restored.Language.Should().Be(UISettings.DefaultLanguage); restored.ThemeColor.Should().Be(UISettings.DefaultThemeColor); + restored.DarkMode.Should().BeFalse(); } [Test] @@ -96,5 +102,19 @@ public void TestRestoreFromInvalidColor() var restored = Restore(""); restored.ThemeColor.Should().Be(UISettings.DefaultThemeColor); } + + [Test] + public void TestRestoreDarkMode() + { + var restored = Restore(""); + restored.DarkMode.Should().BeTrue(); + } + + [Test] + public void TestRestoreFromInvalidDarkMode() + { + var restored = Restore(""); + restored.DarkMode.Should().BeFalse(); + } } } diff --git a/src/Tailviewer.Tests/Tailviewer.Tests.csproj b/src/Tailviewer.Tests/Tailviewer.Tests.csproj index 549c16cb..4e390059 100644 --- a/src/Tailviewer.Tests/Tailviewer.Tests.csproj +++ b/src/Tailviewer.Tests/Tailviewer.Tests.csproj @@ -182,6 +182,7 @@ + diff --git a/src/Tailviewer.Tests/Ui/SemanticPaletteTest.cs b/src/Tailviewer.Tests/Ui/SemanticPaletteTest.cs new file mode 100644 index 00000000..a8862e80 --- /dev/null +++ b/src/Tailviewer.Tests/Ui/SemanticPaletteTest.cs @@ -0,0 +1,47 @@ +using System.Windows.Media; +using FluentAssertions; +using NUnit.Framework; +using Tailviewer.Ui; + +namespace Tailviewer.Tests.Ui +{ + [TestFixture] + public sealed class SemanticPaletteTest + { + [Test] + public void TestLightPalette() + { + var palette = SemanticPalette.Compute(false); + + palette.Surface.Should().Be(Color.FromRgb(0xFF, 0xFF, 0xFF)); + palette.SurfaceMuted.Should().Be(Color.FromRgb(0xD8, 0xD8, 0xD8)); + palette.TextPrimary.Should().Be(Color.FromRgb(0x33, 0x33, 0x33)); + palette.TextSecondary.Should().Be(Color.FromRgb(0xA0, 0xA0, 0xA0)); + palette.Divider.Should().Be(Color.FromRgb(0x80, 0x80, 0x80)); + palette.TitleBar.Should().Be(Color.FromRgb(0xEA, 0xED, 0xF2)); + palette.OverlayBackground.Should().Be(Color.FromArgb(0x55, 0x00, 0x00, 0x00)); + palette.DisabledForeground.Should().Be(Color.FromRgb(0xB7, 0xB7, 0xB7)); + } + + [Test] + public void TestDarkPalette() + { + var palette = SemanticPalette.Compute(true); + + palette.Surface.Should().Be(Color.FromRgb(0x1E, 0x1E, 0x1E)); + palette.SurfaceMuted.Should().Be(Color.FromRgb(0x2D, 0x2D, 0x30)); + palette.TextPrimary.Should().Be(Color.FromRgb(0xDC, 0xDC, 0xDC)); + palette.TextSecondary.Should().Be(Color.FromRgb(0x9E, 0x9E, 0x9E)); + palette.Divider.Should().Be(Color.FromRgb(0x3F, 0x3F, 0x46)); + palette.TitleBar.Should().Be(Color.FromRgb(0x25, 0x25, 0x26)); + palette.OverlayBackground.Should().Be(Color.FromArgb(0x99, 0x00, 0x00, 0x00)); + palette.DisabledForeground.Should().Be(Color.FromRgb(0x6B, 0x6B, 0x6B)); + } + + [Test] + public void TestUnfocusedOverlayIsStable() + { + SemanticPalette.Compute(false).UnfocusedOverlay.Should().Be(SemanticPalette.Compute(true).UnfocusedOverlay); + } + } +} diff --git a/src/Tailviewer.Tests/Ui/TextBrushesTest.cs b/src/Tailviewer.Tests/Ui/TextBrushesTest.cs index a0c86708..b2b558e2 100644 --- a/src/Tailviewer.Tests/Ui/TextBrushesTest.cs +++ b/src/Tailviewer.Tests/Ui/TextBrushesTest.cs @@ -1,7 +1,8 @@ -using System.Threading; +using System.Threading; using System.Windows.Media; using FluentAssertions; using NUnit.Framework; +using Tailviewer.Api; using Tailviewer.Settings; using Tailviewer.Ui.LogView; @@ -31,5 +32,79 @@ public void TestUpdateTheme() TextBrushes.UpdateTheme(UISettings.DefaultThemeColor); } + + [Test] + public void TestUpdateNeutral() + { + TextBrushes.UpdateNeutral(true); + + TextBrushes.CanvasBackgroundBrush.Color.Should().Be(Color.FromRgb(0x1E, 0x1E, 0x1E)); + TextBrushes.DefaultForegroundBrush.Color.Should().Be(Color.FromRgb(0xDC, 0xDC, 0xDC)); + TextBrushes.SelectedUnfocusedBackgroundBrush.Color.Should().Be(Color.FromRgb(0x3F, 0x3F, 0x46)); + TextBrushes.AlternatingBackgroundBrush.Color.Should().Be(Color.FromRgb(0x25, 0x25, 0x26)); + TextBrushes.DataSourceFilenameForegroundBrush.Color.Should().Be(Color.FromRgb(0x9E, 0x9E, 0x9E)); + + TextBrushes.UpdateNeutral(false); + + TextBrushes.CanvasBackgroundBrush.Color.Should().Be(Colors.White); + TextBrushes.DefaultForegroundBrush.Color.Should().Be(Colors.Black); + TextBrushes.SelectedUnfocusedBackgroundBrush.Color.Should().Be(Color.FromRgb(0xD7, 0xD7, 0xD7)); + TextBrushes.AlternatingBackgroundBrush.Color.Should().Be(Color.FromRgb(0xE8, 0xF1, 0xF7)); + TextBrushes.DataSourceFilenameForegroundBrush.Color.Should().Be(Color.FromRgb(0x80, 0x80, 0x80)); + } + + [Test] + public void TestSelectedUnfocusedForegroundUsesDefaultBrush() + { + var brushes = new TextBrushes(new LogViewerSettings()); + + try + { + TextBrushes.UpdateNeutral(false); + var light = (SolidColorBrush) brushes.ForegroundBrush(true, false, false, LevelFlags.Info); + light.Should().BeSameAs(TextBrushes.DefaultForegroundBrush); + light.Color.Should().Be(Colors.Black); + + TextBrushes.UpdateNeutral(true); + light.Should().BeSameAs(TextBrushes.DefaultForegroundBrush); + light.Color.Should().Be(Color.FromRgb(0xDC, 0xDC, 0xDC)); + + var info = (SolidColorBrush) brushes.ForegroundBrush(true, false, true, LevelFlags.Info); + info.Should().NotBeSameAs(TextBrushes.DefaultForegroundBrush); + info.Color.Should().Be(LogViewerSettings.DefaultInfo.ForegroundColor); + } + finally + { + TextBrushes.UpdateNeutral(false); + } + } + + [Test] + public void TestAlternateBackgroundFollowsNeutralTheme() + { + var settings = new LogViewerSettings(); + settings.Warning.BackgroundColor = Color.FromRgb(0x10, 0x20, 0x30); + var brushes = new TextBrushes(settings); + + try + { + TextBrushes.UpdateNeutral(false); + + var defaultAlternating = brushes.BackgroundBrush(false, false, true, LevelFlags.Info, 1); + defaultAlternating.Should().BeSameAs(TextBrushes.AlternatingBackgroundBrush); + ((SolidColorBrush) defaultAlternating).Color.Should().Be(Color.FromRgb(0xE8, 0xF1, 0xF7)); + + var userAlternating = brushes.BackgroundBrush(false, false, true, LevelFlags.Warning, 1); + + TextBrushes.UpdateNeutral(true); + + ((SolidColorBrush) defaultAlternating).Color.Should().Be(Color.FromRgb(0x25, 0x25, 0x26)); + ((SolidColorBrush) userAlternating).Color.Should().Be(Color.FromRgb(0x10, 0x20, 0x30)); + } + finally + { + TextBrushes.UpdateNeutral(false); + } + } } } diff --git a/src/Tailviewer/App.cs b/src/Tailviewer/App.cs index ca00862a..210789ed 100644 --- a/src/Tailviewer/App.cs +++ b/src/Tailviewer/App.cs @@ -41,11 +41,17 @@ public class App public App() { - // Semantic neutral tokens: published at application scope (not in Semantic.xaml) so they can be swapped at runtime by a future dark-mode step. - Resources["Surface"] = Color.FromRgb(0xFF, 0xFF, 0xFF); - Resources["SurfaceMuted"] = Color.FromRgb(0xD8, 0xD8, 0xD8); - Resources["TextSecondary"] = Color.FromRgb(0xA0, 0xA0, 0xA0); - Resources["Divider"] = Color.FromRgb(0x80, 0x80, 0x80); + // Semantic neutral tokens: published at application scope (not in Semantic.xaml) so they can be swapped at runtime by the dark-mode step. The real light/dark values are applied by ThemeManager.Apply. + var neutral = SemanticPalette.Compute(darkMode: false); + Resources["Surface"] = neutral.Surface; + Resources["SurfaceMuted"] = neutral.SurfaceMuted; + Resources["TextPrimary"] = neutral.TextPrimary; + Resources["TextSecondary"] = neutral.TextSecondary; + Resources["Divider"] = neutral.Divider; + Resources["TitleBar"] = neutral.TitleBar; + Resources["OverlayBackground"] = neutral.OverlayBackground; + Resources["UnfocusedOverlay"] = neutral.UnfocusedOverlay; + Resources["DisabledForeground"] = neutral.DisabledForeground; Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Metrolib;component/Themes/Generic.xaml") }); Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Tailviewer;component/Themes/MetrolibTheme.xaml") }); @@ -243,7 +249,7 @@ private static int StartApplication(SingleApplicationHelper.IMutex mutex, string actionCenter.Add(Build.Current); var application = new App(); - ThemeManager.Apply(settings.Ui.ThemeColor); + ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.DarkMode); var dispatcher = Dispatcher.CurrentDispatcher; var uiDispatcher = new UiDispatcher(dispatcher); services.RegisterInstance(uiDispatcher); diff --git a/src/Tailviewer/Localization/Strings.cs b/src/Tailviewer/Localization/Strings.cs index 1dd653b3..db02278c 100644 --- a/src/Tailviewer/Localization/Strings.cs +++ b/src/Tailviewer/Localization/Strings.cs @@ -43,6 +43,7 @@ public static class Strings public static string Created => _rm.GetString("Created") ?? "Created"; public static string CurrentDataSource => _rm.GetString("CurrentDataSource") ?? "CurrentDataSource"; public static string CustomFormatsGroup => _rm.GetString("CustomFormatsGroup") ?? "CustomFormatsGroup"; + public static string DarkMode => _rm.GetString("DarkMode") ?? "DarkMode"; public static string DataSourceDescription => _rm.GetString("DataSourceDescription") ?? "DataSourceDescription"; public static string DataSourceExcluded => _rm.GetString("DataSourceExcluded") ?? "DataSourceExcluded"; public static string DataSourceFilter => _rm.GetString("DataSourceFilter") ?? "DataSourceFilter"; diff --git a/src/Tailviewer/Localization/Strings.resx b/src/Tailviewer/Localization/Strings.resx index 80741879..852112a2 100644 --- a/src/Tailviewer/Localization/Strings.resx +++ b/src/Tailviewer/Localization/Strings.resx @@ -111,6 +111,9 @@ Custom Log file Formats + + Dark mode + Identifies this data source amongst all others in this group - also displayed next to each log line diff --git a/src/Tailviewer/Localization/Strings.zh-CN.resx b/src/Tailviewer/Localization/Strings.zh-CN.resx index 9cb573a8..4d6cab3c 100644 --- a/src/Tailviewer/Localization/Strings.zh-CN.resx +++ b/src/Tailviewer/Localization/Strings.zh-CN.resx @@ -111,6 +111,9 @@ 自定义日志文件格式 + + 深色模式 + 用于在该组的所有数据源中标识此数据源——同时会显示在每一行日志旁边 diff --git a/src/Tailviewer/Settings/UISettings.cs b/src/Tailviewer/Settings/UISettings.cs index 89dc3d50..3307ad9c 100644 --- a/src/Tailviewer/Settings/UISettings.cs +++ b/src/Tailviewer/Settings/UISettings.cs @@ -19,6 +19,8 @@ public sealed class UISettings public Color ThemeColor { get; set; } + public bool DarkMode { get; set; } + public UISettings() { Language = DefaultLanguage; @@ -31,7 +33,8 @@ public UISettings Clone() return new UISettings { Language = Language, - ThemeColor = ThemeColor + ThemeColor = ThemeColor, + DarkMode = DarkMode }; } @@ -39,6 +42,7 @@ public void Save(XmlWriter writer) { writer.WriteAttributeString("language", Language ?? DefaultLanguage); writer.WriteAttributeColor("themecolor", ThemeColor); + writer.WriteAttributeString("darkmode", XmlConvert.ToString(DarkMode)); } public void Restore(XmlReader reader) @@ -58,6 +62,10 @@ public void Restore(XmlReader reader) reader.ReadAttributeAsColor("themecolor", Log, DefaultThemeColor, out var themeColor); ThemeColor = themeColor; break; + + case "darkmode": + DarkMode = bool.TryParse(reader.ReadContentAsString(), out var darkMode) && darkMode; + break; } } } diff --git a/src/Tailviewer/Themes/Constants.xaml b/src/Tailviewer/Themes/Constants.xaml index 1b2c2060..80f15a2d 100644 --- a/src/Tailviewer/Themes/Constants.xaml +++ b/src/Tailviewer/Themes/Constants.xaml @@ -15,13 +15,8 @@ #FFFFFF --> - #30FFFFFF - #EAEDF2 - - #55000000 - - - + + @@ -36,6 +31,9 @@ - + + + + \ No newline at end of file diff --git a/src/Tailviewer/Themes/MetrolibTheme.xaml b/src/Tailviewer/Themes/MetrolibTheme.xaml index c3ff8224..457a0abc 100644 --- a/src/Tailviewer/Themes/MetrolibTheme.xaml +++ b/src/Tailviewer/Themes/MetrolibTheme.xaml @@ -1,13 +1,15 @@ + xmlns:converters="clr-namespace:Metrolib.Converters;assembly=Metrolib" + xmlns:list="clr-namespace:Metrolib.Controls.List;assembly=Metrolib" + xmlns:tab="clr-namespace:Metrolib.Controls.Tab;assembly=Metrolib"> - + @@ -200,9 +202,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tailviewer/Themes/Semantic.xaml b/src/Tailviewer/Themes/Semantic.xaml index 8a1f8a70..52528097 100644 --- a/src/Tailviewer/Themes/Semantic.xaml +++ b/src/Tailviewer/Themes/Semantic.xaml @@ -13,6 +13,7 @@ + diff --git a/src/Tailviewer/Ui/About/AboutFlyoutDataTemplate.xaml b/src/Tailviewer/Ui/About/AboutFlyoutDataTemplate.xaml index b10af223..2f6cffdc 100644 --- a/src/Tailviewer/Ui/About/AboutFlyoutDataTemplate.xaml +++ b/src/Tailviewer/Ui/About/AboutFlyoutDataTemplate.xaml @@ -5,7 +5,7 @@ xmlns:mainPanel="clr-namespace:Tailviewer.Ui.About"> - diff --git a/src/Tailviewer/Ui/ActionCenter/ActionCenterControl.xaml b/src/Tailviewer/Ui/ActionCenter/ActionCenterControl.xaml index 5fd14b63..12b1efaf 100644 --- a/src/Tailviewer/Ui/ActionCenter/ActionCenterControl.xaml +++ b/src/Tailviewer/Ui/ActionCenter/ActionCenterControl.xaml @@ -10,7 +10,7 @@ d:DesignWidth="300" d:DesignHeight="300" d:DataContext="{d:DesignInstance actionCenter:ActionCenterViewModel}" - Background="White"> + Background="{DynamicResource SurfaceBrush}"> @@ -28,7 +28,7 @@ - + diff --git a/src/Tailviewer/Ui/ActionCenter/ActionCenterItem.xaml b/src/Tailviewer/Ui/ActionCenter/ActionCenterItem.xaml index 6613b207..531dc97d 100644 --- a/src/Tailviewer/Ui/ActionCenter/ActionCenterItem.xaml +++ b/src/Tailviewer/Ui/ActionCenter/ActionCenterItem.xaml @@ -12,7 +12,7 @@ diff --git a/src/Tailviewer/Ui/ActionCenter/ExportTemplate.xaml b/src/Tailviewer/Ui/ActionCenter/ExportTemplate.xaml index b07b9b02..9d010855 100644 --- a/src/Tailviewer/Ui/ActionCenter/ExportTemplate.xaml +++ b/src/Tailviewer/Ui/ActionCenter/ExportTemplate.xaml @@ -25,7 +25,7 @@ + Fill="{DynamicResource TextPrimaryBrush}" /> diff --git a/src/Tailviewer/Ui/ActionCenter/NotificationTemplate.xaml b/src/Tailviewer/Ui/ActionCenter/NotificationTemplate.xaml index 2d715e53..0c7300cc 100644 --- a/src/Tailviewer/Ui/ActionCenter/NotificationTemplate.xaml +++ b/src/Tailviewer/Ui/ActionCenter/NotificationTemplate.xaml @@ -33,7 +33,7 @@ - + diff --git a/src/Tailviewer/Ui/DataSourceTree/DataSourcesControl.xaml b/src/Tailviewer/Ui/DataSourceTree/DataSourcesControl.xaml index 359b9fc7..2e5047bc 100644 --- a/src/Tailviewer/Ui/DataSourceTree/DataSourcesControl.xaml +++ b/src/Tailviewer/Ui/DataSourceTree/DataSourcesControl.xaml @@ -28,7 +28,7 @@ diff --git a/src/Tailviewer/Ui/MainWindow.xaml b/src/Tailviewer/Ui/MainWindow.xaml index e018872e..717eb2b9 100644 --- a/src/Tailviewer/Ui/MainWindow.xaml +++ b/src/Tailviewer/Ui/MainWindow.xaml @@ -163,7 +163,7 @@ - @@ -171,7 +171,7 @@ @@ -188,6 +188,7 @@ VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding CurrentFlyout.Name}" + Foreground="{DynamicResource TextPrimaryBrush}" FontSize="32" /> - - - - + + + + @@ -167,7 +167,7 @@ IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}" Placement="Bottom" VerticalOffset="-1"> - + diff --git a/src/Tailviewer/Ui/Plugins/PluginsMainPanelDataTemplate.xaml b/src/Tailviewer/Ui/Plugins/PluginsMainPanelDataTemplate.xaml index f1b460f0..b69c7dfa 100644 --- a/src/Tailviewer/Ui/Plugins/PluginsMainPanelDataTemplate.xaml +++ b/src/Tailviewer/Ui/Plugins/PluginsMainPanelDataTemplate.xaml @@ -13,7 +13,7 @@ - + @@ -63,11 +63,11 @@ @@ -104,7 +104,7 @@ diff --git a/src/Tailviewer/Ui/QuickFilter/FilterToggleButton.xaml b/src/Tailviewer/Ui/QuickFilter/FilterToggleButton.xaml index e55fe4d4..4c373b77 100644 --- a/src/Tailviewer/Ui/QuickFilter/FilterToggleButton.xaml +++ b/src/Tailviewer/Ui/QuickFilter/FilterToggleButton.xaml @@ -11,7 +11,7 @@ + Fill="{DynamicResource TextPrimaryBrush}" /> diff --git a/src/Tailviewer/Ui/QuickFilter/QuickFiltersDataTemplate.xaml b/src/Tailviewer/Ui/QuickFilter/QuickFiltersDataTemplate.xaml index ade0a43f..ff087785 100644 --- a/src/Tailviewer/Ui/QuickFilter/QuickFiltersDataTemplate.xaml +++ b/src/Tailviewer/Ui/QuickFilter/QuickFiltersDataTemplate.xaml @@ -22,7 +22,7 @@ diff --git a/src/Tailviewer/Ui/SemanticPalette.cs b/src/Tailviewer/Ui/SemanticPalette.cs new file mode 100644 index 00000000..da53dff2 --- /dev/null +++ b/src/Tailviewer/Ui/SemanticPalette.cs @@ -0,0 +1,75 @@ +using System.Windows.Media; + +namespace Tailviewer.Ui +{ + /// + /// The neutral (non-accent) semantic color palette. It provides the light and + /// dark variants of the well-known neutral keys that are + /// published at application scope and consumed through DynamicResource bindings. + /// + public sealed class SemanticPalette + { + public Color Surface { get; } + public Color SurfaceMuted { get; } + public Color TextPrimary { get; } + public Color TextSecondary { get; } + public Color Divider { get; } + public Color TitleBar { get; } + public Color OverlayBackground { get; } + public Color UnfocusedOverlay { get; } + public Color DisabledForeground { get; } + + private SemanticPalette(Color surface, + Color surfaceMuted, + Color textPrimary, + Color textSecondary, + Color divider, + Color titleBar, + Color overlayBackground, + Color unfocusedOverlay, + Color disabledForeground) + { + Surface = surface; + SurfaceMuted = surfaceMuted; + TextPrimary = textPrimary; + TextSecondary = textSecondary; + Divider = divider; + TitleBar = titleBar; + OverlayBackground = overlayBackground; + UnfocusedOverlay = unfocusedOverlay; + DisabledForeground = disabledForeground; + } + + /// + /// Computes the neutral palette for the given theme mode. + /// + /// + public static SemanticPalette Compute(bool darkMode) + { + if (darkMode) + { + return new SemanticPalette( + surface: Color.FromRgb(0x1E, 0x1E, 0x1E), + surfaceMuted: Color.FromRgb(0x2D, 0x2D, 0x30), + textPrimary: Color.FromRgb(0xDC, 0xDC, 0xDC), + textSecondary: Color.FromRgb(0x9E, 0x9E, 0x9E), + divider: Color.FromRgb(0x3F, 0x3F, 0x46), + titleBar: Color.FromRgb(0x25, 0x25, 0x26), + overlayBackground: Color.FromArgb(0x99, 0x00, 0x00, 0x00), + unfocusedOverlay: Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF), + disabledForeground: Color.FromRgb(0x6B, 0x6B, 0x6B)); + } + + return new SemanticPalette( + surface: Color.FromRgb(0xFF, 0xFF, 0xFF), + surfaceMuted: Color.FromRgb(0xD8, 0xD8, 0xD8), + textPrimary: Color.FromRgb(0x33, 0x33, 0x33), + textSecondary: Color.FromRgb(0xA0, 0xA0, 0xA0), + divider: Color.FromRgb(0x80, 0x80, 0x80), + titleBar: Color.FromRgb(0xEA, 0xED, 0xF2), + overlayBackground: Color.FromArgb(0x55, 0x00, 0x00, 0x00), + unfocusedOverlay: Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF), + disabledForeground: Color.FromRgb(0xB7, 0xB7, 0xB7)); + } + } +} diff --git a/src/Tailviewer/Ui/Settings/SettingsControl.xaml b/src/Tailviewer/Ui/Settings/SettingsControl.xaml index 7996b327..b9eb8960 100644 --- a/src/Tailviewer/Ui/Settings/SettingsControl.xaml +++ b/src/Tailviewer/Ui/Settings/SettingsControl.xaml @@ -11,8 +11,8 @@ mc:Ignorable="d" d:DesignHeight="600" d:DesignWidth="800" d:DataContext="{d:DesignInstance settings:SettingsFlyoutViewModel}" - Foreground="#333333" - Background="White"> + Foreground="{DynamicResource TextPrimaryBrush}" + Background="{DynamicResource SurfaceBrush}"> @@ -51,7 +51,7 @@ SelectedItem="{Binding Language, Mode=TwoWay}" DisplayMemberPath="DisplayName" /> @@ -314,6 +314,10 @@ + + + + @@ -321,11 +325,20 @@ - + @@ -374,7 +387,7 @@ /> @@ -398,7 +411,7 @@ SelectedItem="{Binding DefaultTextFileEncoding}"/> diff --git a/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs b/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs index e1880f3a..cbf8c460 100644 --- a/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs +++ b/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs @@ -243,6 +243,34 @@ public int TabWidth } } + public bool DarkMode + { + get { return _settings.Ui.DarkMode; } + set + { + if (value == _settings.Ui.DarkMode) + return; + + _settings.Ui.DarkMode = value; + EmitPropertyChanged(); + + _settings.SaveAsync(); + + // Defer the theme application until the toggle has finished updating, + // otherwise mutating the application resources during the toggle's own + // callback re-enters the layout/rendering pass. + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null) + { + dispatcher.BeginInvoke(new Action(() => ThemeManager.Apply(ThemeColor, value))); + } + else + { + ThemeManager.Apply(ThemeColor, value); + } + } + } + public Color ThemeColor { get { return _settings.Ui.ThemeColor; } @@ -262,11 +290,11 @@ public Color ThemeColor var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null) { - dispatcher.BeginInvoke(new Action(() => ThemeManager.Apply(value))); + dispatcher.BeginInvoke(new Action(() => ThemeManager.Apply(value, _settings.Ui.DarkMode))); } else { - ThemeManager.Apply(value); + ThemeManager.Apply(value, _settings.Ui.DarkMode); } } } diff --git a/src/Tailviewer/Ui/SidePanel/Bookmarks/BookmarksDataTemplate.xaml b/src/Tailviewer/Ui/SidePanel/Bookmarks/BookmarksDataTemplate.xaml index 781a0579..b53dcb2b 100644 --- a/src/Tailviewer/Ui/SidePanel/Bookmarks/BookmarksDataTemplate.xaml +++ b/src/Tailviewer/Ui/SidePanel/Bookmarks/BookmarksDataTemplate.xaml @@ -10,7 +10,7 @@ - + @@ -25,7 +25,7 @@ @@ -70,7 +70,7 @@ Margin="4" VerticalAlignment="Center"> - + - + @@ -24,7 +24,7 @@ @@ -41,7 +41,7 @@ + Fill="{DynamicResource DividerBrush}"/> - + @@ -20,7 +20,7 @@ diff --git a/src/Tailviewer/Ui/SidePanel/Outline/OutlineSidePanelDataTemplate.xaml b/src/Tailviewer/Ui/SidePanel/Outline/OutlineSidePanelDataTemplate.xaml index 5012971f..12734989 100644 --- a/src/Tailviewer/Ui/SidePanel/Outline/OutlineSidePanelDataTemplate.xaml +++ b/src/Tailviewer/Ui/SidePanel/Outline/OutlineSidePanelDataTemplate.xaml @@ -8,7 +8,7 @@ - + @@ -17,7 +17,7 @@ diff --git a/src/Tailviewer/Ui/SidePanel/Property/PropertiesSidePanelDataTemplate.xaml b/src/Tailviewer/Ui/SidePanel/Property/PropertiesSidePanelDataTemplate.xaml index 34e0578c..ca806f34 100644 --- a/src/Tailviewer/Ui/SidePanel/Property/PropertiesSidePanelDataTemplate.xaml +++ b/src/Tailviewer/Ui/SidePanel/Property/PropertiesSidePanelDataTemplate.xaml @@ -10,7 +10,7 @@ - + @@ -19,7 +19,7 @@ diff --git a/src/Tailviewer/Ui/SidePanel/QuickFilters/QuickFiltersSidePanelDataTemplate.xaml b/src/Tailviewer/Ui/SidePanel/QuickFilters/QuickFiltersSidePanelDataTemplate.xaml index 8624fd25..e5057e31 100644 --- a/src/Tailviewer/Ui/SidePanel/QuickFilters/QuickFiltersSidePanelDataTemplate.xaml +++ b/src/Tailviewer/Ui/SidePanel/QuickFilters/QuickFiltersSidePanelDataTemplate.xaml @@ -15,7 +15,7 @@ - + @@ -32,7 +32,7 @@ @@ -59,7 +59,7 @@ + Fill="{DynamicResource DividerBrush}"/> - /// Computes the palette for the given base color and publishes it to + /// Computes the accent palette and the neutral (light/dark) palette for the + /// given base color and dark mode flag, then publishes them to /// 's resources. Does nothing when there /// is no current application (e.g. unit tests or design time). /// /// - public static void Apply(Color primary) + /// + public static void Apply(Color primary, bool darkMode) { CurrentPrimary = primary; + CurrentDarkMode = darkMode; TextBrushes.UpdateTheme(primary); + TextBrushes.UpdateNeutral(darkMode); var resources = Application.Current?.Resources; if (resources == null) @@ -42,6 +48,17 @@ public static void Apply(Color primary) resources["SecondaryLighter"] = palette.SecondaryLighter; resources["SecondaryDark"] = palette.SecondaryDark; resources["SecondaryForeground"] = palette.SecondaryForeground; + + var neutral = SemanticPalette.Compute(darkMode); + resources["Surface"] = neutral.Surface; + resources["SurfaceMuted"] = neutral.SurfaceMuted; + resources["TextPrimary"] = neutral.TextPrimary; + resources["TextSecondary"] = neutral.TextSecondary; + resources["Divider"] = neutral.Divider; + resources["TitleBar"] = neutral.TitleBar; + resources["OverlayBackground"] = neutral.OverlayBackground; + resources["UnfocusedOverlay"] = neutral.UnfocusedOverlay; + resources["DisabledForeground"] = neutral.DisabledForeground; } } } diff --git a/tools/generate_localization.py b/tools/generate_localization.py index c2d3d664..93aed906 100644 --- a/tools/generate_localization.py +++ b/tools/generate_localization.py @@ -35,6 +35,7 @@ # --- Settings: Theme --- "ThemeGroup": ("Theme", "主题"), "ThemeColor": ("Theme color", "主题色"), + "DarkMode": ("Dark mode", "深色模式"), # --- Menus (MainWindow.xaml) --- "MenuFile": ("_File", "文件(_F)"), From beddd227f6aafcadf2545e344c70b40d009c34e7 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 02:49:33 +0800 Subject: [PATCH 06/28] chore(task): archive 08-23-ui-dark-mode --- .../{ => archive/2026-08}/08-23-ui-dark-mode/check.jsonl | 0 .../tasks/{ => archive/2026-08}/08-23-ui-dark-mode/design.md | 0 .../{ => archive/2026-08}/08-23-ui-dark-mode/implement.jsonl | 0 .../{ => archive/2026-08}/08-23-ui-dark-mode/implement.md | 0 .../tasks/{ => archive/2026-08}/08-23-ui-dark-mode/prd.md | 0 .../08-23-ui-dark-mode/research/approved-decisions.md | 0 .../08-23-ui-dark-mode/research/dark-palette-and-contrast.md | 0 .../08-23-ui-dark-mode/research/hardcoded-color-inventory.md | 0 .../08-23-ui-dark-mode/research/theme-architecture.md | 0 .../tasks/{ => archive/2026-08}/08-23-ui-dark-mode/task.json | 4 ++-- 10 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/check.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/design.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/implement.jsonl (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/implement.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/research/approved-decisions.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/research/dark-palette-and-contrast.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/research/hardcoded-color-inventory.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/research/theme-architecture.md (100%) rename .trellis/tasks/{ => archive/2026-08}/08-23-ui-dark-mode/task.json (93%) diff --git a/.trellis/tasks/08-23-ui-dark-mode/check.jsonl b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/check.jsonl similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/check.jsonl rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/check.jsonl diff --git a/.trellis/tasks/08-23-ui-dark-mode/design.md b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/design.md similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/design.md rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/design.md diff --git a/.trellis/tasks/08-23-ui-dark-mode/implement.jsonl b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/implement.jsonl similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/implement.jsonl rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/implement.jsonl diff --git a/.trellis/tasks/08-23-ui-dark-mode/implement.md b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/implement.md similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/implement.md rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/implement.md diff --git a/.trellis/tasks/08-23-ui-dark-mode/prd.md b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/prd.md similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/prd.md rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/prd.md diff --git a/.trellis/tasks/08-23-ui-dark-mode/research/approved-decisions.md b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/approved-decisions.md similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/research/approved-decisions.md rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/approved-decisions.md diff --git a/.trellis/tasks/08-23-ui-dark-mode/research/dark-palette-and-contrast.md b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/dark-palette-and-contrast.md similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/research/dark-palette-and-contrast.md rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/dark-palette-and-contrast.md diff --git a/.trellis/tasks/08-23-ui-dark-mode/research/hardcoded-color-inventory.md b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/hardcoded-color-inventory.md similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/research/hardcoded-color-inventory.md rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/hardcoded-color-inventory.md diff --git a/.trellis/tasks/08-23-ui-dark-mode/research/theme-architecture.md b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/theme-architecture.md similarity index 100% rename from .trellis/tasks/08-23-ui-dark-mode/research/theme-architecture.md rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/theme-architecture.md diff --git a/.trellis/tasks/08-23-ui-dark-mode/task.json b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/task.json similarity index 93% rename from .trellis/tasks/08-23-ui-dark-mode/task.json rename to .trellis/tasks/archive/2026-08/08-23-ui-dark-mode/task.json index e6f25a20..6843ee00 100644 --- a/.trellis/tasks/08-23-ui-dark-mode/task.json +++ b/.trellis/tasks/archive/2026-08/08-23-ui-dark-mode/task.json @@ -3,7 +3,7 @@ "name": "ui-dark-mode", "title": "Dark mode (semantic tokens + log renderer participation)", "description": "基于语义 token 实现两态深色模式(即时切换+持久化,默认浅色);日志渲染器(TextCanvas/DataSourceCanvas/AbstractLogColumnPresenter/TextBrushes/LogEntryListView 最小分隔条)主题感知画刷参与;Metrolib 定向中性色覆盖;保留强调色契约与实时切换;不做 Fluent 全控件化与跟随系统模式", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "brofea", "assignee": "brofea", "createdAt": "2026-08-23", - "completedAt": null, + "completedAt": "2026-08-23", "branch": null, "base_branch": "master", "worktree_path": null, From b4471b8174a1616ddafd98a79c7e3d5764b1aee0 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 02:50:04 +0800 Subject: [PATCH 07/28] chore: record journal --- .trellis/workspace/brofea/index.md | 5 ++-- .trellis/workspace/brofea/journal-1.md | 39 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/brofea/index.md b/.trellis/workspace/brofea/index.md index ed6bf0fa..c9914462 100644 --- a/.trellis/workspace/brofea/index.md +++ b/.trellis/workspace/brofea/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 8 +- **Total Sessions**: 9 - **Last Active**: 2026-08-23 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~278 | Active | +| `journal-1.md` | ~317 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 9 | 2026-08-23 | 完成深色模式主题管线 | `b5999cdb`, `beddd227` | `ui` | | 8 | 2026-08-23 | 完成语义化 UI 与主界面重排 | `712ef7d1` | `ui` | | 7 | 2026-08-22 | Unify remaining Metrolib accents to theme color | `2014bf0b`, `196c0894` | `dev` | | 6 | 2026-08-22 | 修复本地化后 4 个硬编码英文断言的测试 | `fe507568`, `60814c52`, `3a06b562` | `dev` | diff --git a/.trellis/workspace/brofea/journal-1.md b/.trellis/workspace/brofea/journal-1.md index b14f888a..611767fc 100644 --- a/.trellis/workspace/brofea/journal-1.md +++ b/.trellis/workspace/brofea/journal-1.md @@ -276,3 +276,42 @@ Re-themed all remaining hardcoded Metrolib blue accents (#3998D6 family) to the ### Next Steps - None - task complete + + +## Session 9: 完成深色模式主题管线 + +**Date**: 2026-08-23 +**Task**: 完成深色模式主题管线 +**Branch**: `ui` + +### Summary + +完成可持久化、即时切换的 Light/Dark 主题管线,覆盖窗口、日志渲染器、侧栏、flyout 与 Metrolib 中性色,并通过独立构建、目标测试和运行时资源验收。 + +### Main Changes + +- Theme pipeline: two-state Light/Dark setting, persisted in UI XML, immediate Dispatcher-applied switching. +- SemanticPalette publishes Surface, SurfaceMuted, TextPrimary, TextSecondary, Divider, TitleBar, OverlayBackground, UnfocusedOverlay and DisabledForeground. +- Migrated chrome, settings, side panels, flyouts, menus, data-source tree and Metrolib neutral controls to DynamicResource; retained accent, severity, inverted and explicit ColorByLevel colors. +- Extended the approved renderer brush-only scope for canvas backgrounds, default/selected/alternating text brushes and separator brush without changing rendering algorithms, virtualization, paging or hit testing. +- Independent verification: app/tests builds 0 warnings and 0 errors; target UI/semantic test set 114 total, 111 passed, 0 failed, 3 ignored; runtime App/resource/style load passed; neutral StaticResource audit and git diff --check passed. A broader parallel NUnit filter including TextLineTest exposed the existing static Freezable cross-STA test scheduling race; the task-target filter passed. + + +### Git Commits + +| Hash | Message | +|------|---------| +| `b5999cdb` | (see git log) | +| `beddd227` | (see git log) | + +### Testing + +- Validation was not recorded for this session. + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From 80f3e582a2d520df41dc49ad2ec7e5ee08702a1a Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 03:52:08 +0800 Subject: [PATCH 08/28] feat: fluentize shared UI controls --- src/Tailviewer/Themes/Constants.xaml | 1 + src/Tailviewer/Themes/Fluent.xaml | 23 ++ src/Tailviewer/Themes/MetrolibTheme.xaml | 254 ++++++++++++++++-- .../Ui/DataSourceTree/DataSourcesControl.xaml | 3 +- .../Ui/DataSourceTree/TreeViewItemStyle.xaml | 3 +- .../Ui/LogView/ToolbarToggleButtonStyle.xaml | 3 +- src/Tailviewer/Ui/Menu/MenuItemTemplates.xaml | 4 +- .../Ui/Settings/SettingsControl.xaml | 4 +- 8 files changed, 274 insertions(+), 21 deletions(-) create mode 100644 src/Tailviewer/Themes/Fluent.xaml diff --git a/src/Tailviewer/Themes/Constants.xaml b/src/Tailviewer/Themes/Constants.xaml index 80f15a2d..964e091b 100644 --- a/src/Tailviewer/Themes/Constants.xaml +++ b/src/Tailviewer/Themes/Constants.xaml @@ -4,6 +4,7 @@ + + + + 4 + 8 + + + 32 + 24 + + + 12 + 14 + 20 + + diff --git a/src/Tailviewer/Themes/MetrolibTheme.xaml b/src/Tailviewer/Themes/MetrolibTheme.xaml index 457a0abc..cc1f0862 100644 --- a/src/Tailviewer/Themes/MetrolibTheme.xaml +++ b/src/Tailviewer/Themes/MetrolibTheme.xaml @@ -21,6 +21,7 @@ + @@ -32,6 +33,7 @@ + @@ -43,6 +45,7 @@ + @@ -54,6 +57,7 @@ + @@ -65,6 +69,7 @@ + @@ -76,6 +81,7 @@ + @@ -87,6 +93,7 @@ + @@ -98,6 +105,7 @@ + @@ -109,6 +117,7 @@ + @@ -265,15 +274,18 @@ + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderBrush="{DynamicResource DividerBrush}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderBrush="{DynamicResource PrimaryBrush}" + CornerRadius="{StaticResource ControlCornerRadius}" /> @@ -341,15 +353,18 @@ + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderBrush="{DynamicResource DividerBrush}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderBrush="{DynamicResource PrimaryBrush}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderBrush="{DynamicResource DividerBrush}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderBrush="{DynamicResource PrimaryBrush}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderBrush="{DynamicResource DividerBrush}" + CornerRadius="{StaticResource ControlCornerRadius}" /> + BorderBrush="{DynamicResource PrimaryBrush}" + CornerRadius="{StaticResource ControlCornerRadius}" /> @@ -720,6 +741,7 @@ - + + + + + + + + + diff --git a/src/Tailviewer/Ui/DataSourceTree/DataSourcesControl.xaml b/src/Tailviewer/Ui/DataSourceTree/DataSourcesControl.xaml index 2e5047bc..aa3a3b6e 100644 --- a/src/Tailviewer/Ui/DataSourceTree/DataSourcesControl.xaml +++ b/src/Tailviewer/Ui/DataSourceTree/DataSourcesControl.xaml @@ -35,7 +35,8 @@ + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{StaticResource SurfaceCornerRadius}"> diff --git a/src/Tailviewer/Ui/DataSourceTree/TreeViewItemStyle.xaml b/src/Tailviewer/Ui/DataSourceTree/TreeViewItemStyle.xaml index eb7d71a0..11ae008d 100644 --- a/src/Tailviewer/Ui/DataSourceTree/TreeViewItemStyle.xaml +++ b/src/Tailviewer/Ui/DataSourceTree/TreeViewItemStyle.xaml @@ -31,7 +31,8 @@ - + diff --git a/src/Tailviewer/Ui/LogView/ToolbarToggleButtonStyle.xaml b/src/Tailviewer/Ui/LogView/ToolbarToggleButtonStyle.xaml index e8cd02ba..5a86202f 100644 --- a/src/Tailviewer/Ui/LogView/ToolbarToggleButtonStyle.xaml +++ b/src/Tailviewer/Ui/LogView/ToolbarToggleButtonStyle.xaml @@ -25,7 +25,8 @@ + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{StaticResource ControlCornerRadius}"> diff --git a/src/Tailviewer/Ui/Menu/MenuItemTemplates.xaml b/src/Tailviewer/Ui/Menu/MenuItemTemplates.xaml index 310d2d55..86ad8b1b 100644 --- a/src/Tailviewer/Ui/Menu/MenuItemTemplates.xaml +++ b/src/Tailviewer/Ui/Menu/MenuItemTemplates.xaml @@ -99,6 +99,7 @@ Placement="Right" VerticalOffset="-1"> @@ -167,7 +168,8 @@ IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}" Placement="Bottom" VerticalOffset="-1"> - + diff --git a/src/Tailviewer/Ui/Settings/SettingsControl.xaml b/src/Tailviewer/Ui/Settings/SettingsControl.xaml index b9eb8960..e29975d2 100644 --- a/src/Tailviewer/Ui/Settings/SettingsControl.xaml +++ b/src/Tailviewer/Ui/Settings/SettingsControl.xaml @@ -21,7 +21,7 @@ - + @@ -440,7 +440,7 @@ Grid.Row="3"> - + From 6eac96a32a863819a3717f3b1b0fd682db405ac6 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 03:52:22 +0800 Subject: [PATCH 09/28] chore(task): archive 08-23-ui-fluent-controls --- .../08-23-ui-fluent-controls/check.jsonl | 10 ++ .../08-23-ui-fluent-controls/design.md | 125 ++++++++++++++++++ .../08-23-ui-fluent-controls/implement.jsonl | 12 ++ .../08-23-ui-fluent-controls/implement.md | 111 ++++++++++++++++ .../2026-08/08-23-ui-fluent-controls/prd.md | 82 ++++++++++++ .../research/control-inventory.md | 85 ++++++++++++ .../research/fluent-design-tokens.md | 70 ++++++++++ .../research/metrolib-wpf-compat.md | 53 ++++++++ .../08-23-ui-fluent-controls/task.json | 26 ++++ 9 files changed, 574 insertions(+) create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/check.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/design.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/implement.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/prd.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/research/control-inventory.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/research/fluent-design-tokens.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/research/metrolib-wpf-compat.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/task.json diff --git a/.trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/check.jsonl b/.trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/check.jsonl new file mode 100644 index 00000000..0cf6ecb1 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/check.jsonl @@ -0,0 +1,10 @@ +{"file": ".trellis/spec/ui/theming.md", "reason": "核对强调色 DynamicResource、冻结画刷、Metrolib 覆盖是否被模板替换破坏"} +{"file": ".trellis/spec/ui/index.md", "reason": "UI 层整体规范与第三方依赖基线"} +{"file": ".trellis/spec/ui/mvvm.md", "reason": "核对模板替换未引入 XAML 事件处理器或违反命令/绑定约定"} +{"file": ".trellis/spec/ui/project-structure.md", "reason": "核对未越界改动 BusinessLogic/ViewModel/本地化产物"} +{"file": ".trellis/spec/build/index.md", "reason": "核对 warning-free 构建与项目格式约束"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "核对样式统一完整性、无重复遗漏"} +{"file": ".trellis/tasks/08-23-ui-fluent-controls/prd.md", "reason": "验收标准与红线清单,作为 check 的判定依据"} +{"file": ".trellis/tasks/08-23-ui-fluent-controls/design.md", "reason": "边界、token 规格、模板替换规格与回滚点,作为 check 的判定依据"} +{"file": ".trellis/tasks/08-23-ui-fluent-controls/research/metrolib-wpf-compat.md", "reason": "核对模板替换保留 PART_*/VSM/TemplateBinding,无冻结画刷/StaticResource 回归"} +{"file": ".trellis/tasks/08-23-ui-fluent-controls/research/control-inventory.md", "reason": "核对红线文件零改动(渲染器/BusinessLogic/ViewModel)与分阶段范围"} diff --git a/.trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/design.md b/.trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/design.md new file mode 100644 index 00000000..36a4af6b --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-ui-fluent-controls/design.md @@ -0,0 +1,125 @@ +# 设计:Fluent UI 控件视觉化 + +> 本文档把 prd.md 的 D1–D6 决策点落实为可执行设计。D1–D6 为规划期识别的**待批准决策**,实现前必须由主负责人拍板;§9 保留证据并标注待批准状态。 + +## 1. 边界:什么能改 / 什么不能改 + +### 可改(视图/主题层) +- `src/Tailviewer/Themes/Fluent.xaml`(新增,token 资源)。 +- `src/Tailviewer/Themes/Constants.xaml`(merged dictionaries 新增 `Fluent.xaml`,一处)。 +- `src/Tailviewer/Themes/MetrolibTheme.xaml`(4 个已拥有文本框模板加圆角/密度;`FlatButton`/`FlatToggleButton` 族样式加 `BorderRadius`/`Padding` setter;新增 CheckBox/ComboBox 模板——见 §4)。 +- `src/Tailviewer/Ui/LogView/ToolbarToggleButtonStyle.xaml`、`Ui/DataSourceTree/TreeViewItemStyle.xaml`、`Ui/Menu/MenuItemTemplates.xaml`、`Ui/DataSourceTree/DataSourcesControl.xaml` 等自研模板(加圆角/密度/字号)。 +- 仅在 D5 需要发布新 `Color` 键时:`src/Tailviewer/Ui/ThemeManager.cs`、`Ui/SemanticPalette.cs`、`App.cs`(应用作用域初始占位)。 + +### 不可改(红线,违反即回滚) +- `src/Tailviewer/BusinessLogic/**`。 +- `src/Tailviewer/Ui/**/*ViewModel*.cs`、`IMainWindowViewModel.cs`、`AbstractMainPanelViewModel.cs`。 +- `LogEntryListView.cs`、`TextCanvas.cs`、`TextLine.cs`、`TextSegment.cs`、`TextBrushes.cs`、`DataSourceCanvas.cs`、`AbstractLogColumnPresenter.cs`(渲染器红线)。 +- `ThemePalette.cs` 现有契约(`Compute(Color)` 的 Primary*/Secondary* 语义与测试基线不变)。 +- `tools/generate_localization.py` 生成产物(除非确需新字符串)。 + +## 2. Fluent token 资源(`Themes/Fluent.xaml`) + +```xml + + 4 + 8 + 32 + 24 + 12 + 14 + 20 + +``` + +- 经 `Themes/Constants.xaml` 的 merged dictionaries 进入资源图(`Constants.xaml` 已 merge `Semantic.xaml`,同路径追加 `Fluent.xaml`)。 +- **不本地声明 `Color` 键**;若阶段 C 需要 `Success`/`SelectionUnfocused` 新 `Color` 键,走 `SemanticPalette` + `ThemeManager.Apply` + `App` 初始占位(D5)。 +- 圆角引用方式:`CornerRadius="{StaticResource ControlCornerRadius}"`(`sys:Double` 可静态;圆角非实时主题切换对象)。字体/密度同理 `{StaticResource}`。 + +## 3. 分期与范围映射(D1) + +### 阶段 A(MVP,低风险,建议首批准) +| 目标 | 载体 | 改动类型 | +|---|---|---| +| `FlatButton` 族圆角 4 + 密度 Padding | `MetrolibTheme.xaml` 隐式样式追加 `BorderRadius`/`Padding` setter | setter,零模板 | +| 4 个文本框圆角 4 + 统一 Height/Padding | `MetrolibTheme.xaml` 已拥有模板的 `normalBorder/hoverBorder/focusBorder` 加 `CornerRadius` | 已拥有模板内改 | +| `ToolbarToggleButton`/`LogLevelToggleButton` 圆角(选中 pill) | `ToolbarToggleButtonStyle.xaml` 自研模板 Border 加 `CornerRadius` | 自研模板 | +| `MenuItemTemplates` popup Border 圆角 8 | `MenuItemTemplates.xaml` | 自研模板 | +| `DataSourcesControl` 根 / `TreeViewItemStyle` 选中块圆角 | 各自研模板 | 自研模板 | +| **`ComboBox` 模板**(圆角 + 语义色 + 焦点环 + 下拉箭头语义色) | `MetrolibTheme.xaml` 新增隐式样式(BasedOn WPF 默认) | **模板替换** | +| **`CheckBox` 模板**(圆角方框 + 强调色勾选 + 语义前景) | `MetrolibTheme.xaml` 新增隐式样式 | **模板替换** | +| 字号层级(FlatTextBlock/FlatGroupBox 标题/菜单 12/14/20) | `MetrolibTheme.xaml` + 自研模板 | setter | + +### 阶段 B(中风险,可选) +- `FlatScrollBar` 圆角滑块/轨道(模板替换,保留 `PART_Track`)。 +- `FlatTabControl`/`FlatTabItem` 无边框 tab + 下划线选中(模板替换)。 +- `FlatToggleButton` 族圆角(模板替换,保留 `ToggleButtonBase` 的 `CheckedBackground`/`DisabledOverlayBrush` TemplateBinding)。 +- `OneWayToggle` 圆角分段(模板替换,保留 `IsChecked/IsPressed/HasRightBorder/HasLeftBorder`)。 + +### 阶段 C(低价值,默认不做) +- `FlatGroupBox` 圆角/语义边框(模板替换)。 +- `FlatContextMenu`/`FlatProgressBar`/`CircularProgressBar` 圆角与密度。 +- `Success`/`SelectionUnfocused` 新语义 `Color` 键。 + +## 4. 关键模板替换规格(阶段 A) + +### 4.1 CheckBox(Fluent) +- 目标:圆角 4 方框(`Surface` 底 + `Divider` 边)+ 勾选态 `PrimaryBrush` 底 + 白勾;前景 `TextPrimary`;禁用用 `DisabledForeground`。 +- 保留 WPF `BulletDecorator` 语义 + `ContentPresenter`(`RecognizesAccessKey=True`),不绑定业务。 +- 勾选标记用 `Path`(`M...` 勾形)`Fill={DynamicResource PrimaryForegroundBrush}`;未勾选隐藏。 +- 焦点态:`FocusVisualStyle` 由模板内 1px `PrimaryBrush` 描边实现(D4 决策:若默认不改全局焦点,则 CheckBox/ComboBox 模板内自绘焦点态,不引入全局焦点环)。 +- **不**改 `SettingsControl.xaml` 中 `AlwaysOnTop`/`CheckForUpdates` 的绑定(`IsChecked` 双向绑定原样)。 + +### 4.2 ComboBox(Fluent) +- 目标:圆角 4 边框(`Divider` 默认 / `PrimaryBrush` 悬停聚焦)+ `Surface` 底 + `TextPrimary` 前景 + 下拉箭头语义色。 +- **保留** `PART_EditableTextBox`/`PART_Popup`(本应用为非可编辑 ComboBox,仍按标准模板结构,确保下拉键盘导航与 `ItemsSource/SelectedItem` 绑定不退化)。 +- `ToggleButton`(模板内)复用一个已主题化的按钮外观(或最小化 `ToggleButton` 模板),保留 `IsDropDownOpen` 绑定。 +- `ItemsPresenter` 置于 `Popup` 内,popup `Border` 圆角 8 + `Surface` 底 + `Divider` 边。 +- 语言/编码两个 ComboBox 的 `SelectedItem`/`DisplayMemberPath` 绑定原样保留。 + +> 通用约束(metrolib-wpf-compat.md §2):替换必须保留 `PART_*` 具名部件、完整 `CommonStates`/`FocusStates` VSM、以及对 `FlatButton`/`ToggleButtonBase` 颜色 DP 的 `TemplateBinding`;颜色一律 `{DynamicResource}`。 + +## 5. 状态色与可访问性(D3/D4/D7) + +- 强调色契约不变(`ThemePalette` 基线不变,`ThemePaletteTest` 不破)。 +- severity 红 `#E81123`/黄 `#FFC300` **保持不动**;新模板不重定义。 +- 选中 = 强调色 + 白前景;未聚焦选中复用 `SurfaceMuted`(不新增键)。 +- 禁用 = `DisabledForeground`(语义键,深/浅由 `SemanticPalette` 提供)。 +- 焦点环按 D4:默认保持 `FocusVisualStyle=null` 现状;CheckBox/ComboBox 模板内自绘焦点态。若主负责人批准恢复全局焦点环,则单独列为阶段 B 项。 + +## 6. 性能约束 + +- 渲染热路径零改动(§1 红线)。 +- 新增 `{DynamicResource}` 仅在 chrome 控件模板;主题切换仍是一次性发布,无每帧计算。 +- 圆角/密度/字号用 `{StaticResource sys:Double}`(非实时切换对象,无性能与冻结问题)。 +- 模板内 VSM Storyboard 为一次性悬停/聚焦动画,不进入布局循环。 + +## 7. 兼容性 + +- `net48` + `UseWPF` + `TreatWarningsAsErrors` 零警告。 +- `Fluent.xaml` 不抢占 Metrolib 隐式样式、不重复声明强调/语义 `Color` 键。 +- 新模板不引入 code-behind(纯 XAML 资源字典)。 +- SDK 项目 XAML 自动 glob,`Fluent.xaml` 无需改 csproj。 + +## 8. 回滚形状 + +- R1 token 层:`Constants.xaml` 移除 `Fluent.xaml` merge + 删除文件。 +- R2 圆角/密度/字号 setter:逐个删除 setter 即还原。 +- R3 模板替换:删除对应 ` +``` + +- **移除旧 Row 1**:删除整个 `Grid.Row=1` 搜索网格(行 201-235),旧左侧 `Margin="4,4,4,0"`、`*` 尾列、FilterTextBox 过滤视觉一并移除。 + +## 3. 行重排 + +- RowDefinitions:`50 | Auto | 3* | Auto | Auto` → `50 | 3* | Auto | Auto`(删搜索行 `Auto`)。 +- 元素 `Grid.Row` 递减: + - 主内容网格 `Grid.Row="2"` → `Grid.Row="1"`。 + - 遮罩 `Grid.Row="2"` → `Grid.Row="1"`。 + - `DataSourcesControl Grid.Row="2"` → `Grid.Row="1"`。 + - 底部信息条 `Grid.Row="3"` → `Grid.Row="2"`。 +- 末尾 `Auto` 行(原 index 4 → 新 index 3)仍无内容,保持现状。 + +## 4. Find All 统一视觉 + 计数 + +- 控件 `FilterTextBox`(过滤视觉)→ `SearchTextBox`(搜索视觉),与 Search 统一。 +- `Text` 绑 `FindAll.SearchTerm`(替代 `FilterText`);`RequiresExplicitSearchStart="False"` 保持即时搜索。 +- 计数: + ```csharp + // IFindAllViewModel + int Count { get; } + + // FindAllViewModel + public int Count => _dataSource.FindAllSearch?.Count ?? 0; + public void Update() { EmitPropertyChanged(nameof(Count)); } + + // AbstractDataSourceViewModel.Update() 末尾追加(镜像 _search.Update()): + _findAll.Update(); + ``` +- `CurrentOccurenceIndex` 不绑定(Find All 无单一游标);导航按钮视觉存在但无导航行为(已知限制)。 + +## 5. 属性页整页前景(`PropertiesSidePanelDataTemplate.xaml`) + +- 根 `Border`:加 `Foreground="{DynamicResource TextPrimaryBrush}"`(子元素默认继承)。 +- `Properties` 头 TextBlock:`Foreground` 由 `TextSecondaryBrush` → `TextPrimaryBrush`。 +- `DisplayName` TextBlock:加 `Foreground="{DynamicResource TextPrimaryBrush}"`(保留 `FontWeight="Bold"`)。 +- `Value` ContentPresenter:加 `Foreground="{DynamicResource TextPrimaryBrush}"`。 +- 规则依据:`theming.md` 要求语义 Brush 一律 `{DynamicResource}`;`TextPrimaryBrush` 已存在于 `Semantic.xaml`。 + +## 6. 兼容性与回滚 + +- 回滚点 R1:恢复三列顶部网格 + 恢复旧 `Grid.Row=1` 搜索行(`FilterTextBox` 左置)+ 撤销 `Count`/`Update()` 与行重排。 +- 回滚点 R2:撤销属性页整页前景。 +- 若 `SearchTextBox` 在 Find All 场景异常,回退方案(prd Notes)为文档化回退。 + +## 7. 测试 + +- `LogViewerControlTest`/`MainWindowTest`(UI STA):搜索行并入顶部网格、Find All 输入/清空/结果视图开关回归。 +- 若存在 `IFindAllViewModel` 的 Moq 实现,补充 `Count` 的 setup(编译即覆盖)。 diff --git a/.trellis/tasks/08-23-search-property-layout/implement.jsonl b/.trellis/tasks/08-23-search-property-layout/implement.jsonl new file mode 100644 index 00000000..0725ecdd --- /dev/null +++ b/.trellis/tasks/08-23-search-property-layout/implement.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/spec/ui/theming.md", "reason": "属性页整页前景走语义 Token TextPrimaryBrush 且用 {DynamicResource};Metrolib 控件换型不引入 StaticResource 回归"} +{"file": ".trellis/spec/ui/mvvm.md", "reason": "IFindAllViewModel.Count 的 INotifyPropertyChanged 与绑定模式(镜像 SearchViewModel.ResultCount)"} +{"file": ".trellis/spec/ui/project-structure.md", "reason": "XAML 视图/数据模板位置与 约定;本地化字符串引用方式"} +{"file": ".trellis/spec/build/index.md", "reason": "net48 SDK-style、TreatWarningsAsErrors、XAML 无需登记 csproj"} +{"file": ".trellis/spec/testing/index.md", "reason": "LogViewerControlTest/MainWindowTest 与 Moq 对 IFindAllViewModel.Count 的约定"} +{"file": ".trellis/spec/guides/index.md", "reason": "改 LogViewerControl.xaml 顶部网格前先 grep PART_SearchBox/PART_FindAllBox/Grid.Row 引用(Pre-Modification Rule)"} diff --git a/.trellis/tasks/08-23-search-property-layout/implement.md b/.trellis/tasks/08-23-search-property-layout/implement.md new file mode 100644 index 00000000..d31a5f75 --- /dev/null +++ b/.trellis/tasks/08-23-search-property-layout/implement.md @@ -0,0 +1,56 @@ +# 实施计划:搜索框移入顶部网格 + Find All 统一视觉 + 属性页整页前景 + +> 独立任务;不触碰 `ThemeManager`。可回滚。 +> 状态:规划完成,待 `task.py start`;尚未实现。 + +## 阶段 1:Find All 计数(回滚点 R1) + +1. [ ] `IFindAllViewModel`:新增 `int Count { get; }`。 +2. [ ] `AbstractDataSourceViewModel.FindAllViewModel`:实现 `Count`(`_dataSource.FindAllSearch?.Count ?? 0`)+ `public void Update()`(`EmitPropertyChanged(nameof(Count))`);`AbstractDataSourceViewModel.Update()` 末尾追加 `_findAll.Update();`。 +3. [ ] 验证:`dotnet build`;相关测试编译通过(Moq 对 `IFindAllViewModel.Count` setup 补齐)。 + +## 阶段 2:共享样式 + 顶部网格右置(回滚点 R1) + +4. [ ] `LogViewerControl.xaml`:`UserControl.Resources` 新增 `LogSearchTextBoxStyle`(Height/Width/Padding/VerticalAlignment/RequiresExplicitSearchStart/AcceptsTab)。 +5. [ ] 顶部网格改为四列 `Auto | * | Auto | Auto`;新增 col2 搜索容器 StackPanel(右对齐,`Visibility` 绑 `CurrentDataSource`),Search 与 Find All 引用共享样式;col3 为原 SidePanelControl。 +6. [ ] Find All:`FilterTextBox` → `SearchTextBox`(`Text` 绑 `FindAll.SearchTerm`、`OccurenceCount` 绑 `FindAll.Count`、水印 `FindAllInLogFile`)。 +7. [ ] 删除旧 `Grid.Row=1` 搜索网格。 +8. [ ] 验证:`dotnet build` warning-free;目检两框右置、视觉统一、旧行已删。 + +## 阶段 3:行重排(回滚点 R1) + +9. [ ] RowDefinitions:`50 | Auto | 3* | Auto | Auto` → `50 | 3* | Auto | Auto`;主内容/遮罩/`DataSourcesControl` 由 `Grid.Row=2` → `1`;底部信息条由 `Grid.Row=3` → `2`。 +10. [ ] 验证:构建;目检主内容/遮罩/数据源面板/底部条位置正确。 + +## 阶段 4:属性页整页前景(回滚点 R2) + +11. [ ] `PropertiesSidePanelDataTemplate.xaml`:根 Border 加 `Foreground="{DynamicResource TextPrimaryBrush}"`;Properties 头 `TextSecondaryBrush` → `TextPrimaryBrush`;`DisplayName` 与 `Value` 加 `Foreground="{DynamicResource TextPrimaryBrush}"`。 +12. [ ] 验证:构建;深/浅两态属性页整页可读。 + +## 阶段 5:回归与收尾 + +13. [ ] 目标 UI STA:`LogViewerControlTest`/`MainWindowTest`(Find All 输入/清空/结果视图开关回归)。 +14. [ ] `rg -n "PART_SearchBox|PART_FindAllBox|LogSearchTextBoxStyle|Grid.Row=\"1\"" src/Tailviewer/Ui/LogView/LogViewerControl.xaml` 核对改动点。 +15. [ ] `git diff --check`;`git diff --name-only` 红线审计(仅 §1 可改文件)。 + +## 验证命令 + +```bash +dotnet build src/Tailviewer/Tailviewer.csproj +dotnet build src/Tailviewer.Tests/Tailviewer.Tests.csproj +git diff --check +git diff --name-only +``` + +## Review Gates + +- G1:Find All 计数周期刷新且不引入 `BusinessLogic` 改动。 +- G2:两搜索框并入顶部网格右侧、视觉统一、旧 `Grid.Row=1` 已删、行重排正确、Find All 输入/清空语义不变。 +- G3:属性页整页深浅两态可读;语义 Token 用 `{DynamicResource}`。 +- G4:构建 warning-free + UI STA 通过 + 红线审计通过。 + +## 回滚点 + +- R1:撤销 `Count`/`Update()`、共享样式、顶部网格四列、行重排与 Find All 换型,恢复三列顶部网格 + 旧 `Grid.Row=1` 搜索行(`FilterTextBox` 左置)。 +- R2:撤销属性页整页前景。 +- 越界即回滚:触碰 `BusinessLogic/**` 立即 revert。 diff --git a/.trellis/tasks/08-23-search-property-layout/prd.md b/.trellis/tasks/08-23-search-property-layout/prd.md new file mode 100644 index 00000000..61671d16 --- /dev/null +++ b/.trellis/tasks/08-23-search-property-layout/prd.md @@ -0,0 +1,50 @@ +# 搜索框与属性页布局修正 + +## Goal + +1. 把日志界面两个搜索框(Search 与 Find All)从独立的 `Grid.Row=1` 搜索行**移入顶部标题网格(`Grid.Row=0` 工具栏网格)的右侧**,并**删除**旧 `Grid.Row=1` 搜索行(而非仅翻转列)。 +2. 两个搜索框用**显式共享视觉 setter/资源**统一(同为 `SearchTextBox`,共用样式资源),移除旧的左侧对齐样式与 FilterTextBox 过滤视觉。 +3. 修正属性页**整页**前景:根前景、Properties 头、属性名、属性值统一用 `TextPrimaryBrush`(DynamicResource),深色下可读。 + +> 依赖:无(独立于另外两个子任务;不触碰 `ThemeManager`)。可与 `08-23-theme-mode-and-log-colors` 并行。 + +## 已确认事实 + +| # | 事实 | 证据 | +|---|---|---| +| F1 | 顶部工具栏网格 `Grid.Row=0` 三列 `Auto | * | Auto`:col0 数据源选择器(宽 300)、col1 中央工具栏、col2 SidePanelControl(最右) | `LogViewerControl.xaml:71-199` | +| F2 | 搜索行在独立 `Grid.Row=1`(`Auto | Auto | *`,两框靠左) | `LogViewerControl.xaml:201-235` | +| F3 | Search 用 `SearchTextBox`;Find All 用 `FilterTextBox`(视觉不一致) | `LogViewerControl.xaml:211-234` | +| F4 | 行定义 5 行:`50 | Auto | 3* | Auto | Auto`;Row 2 主内容/遮罩/数据源面板、Row 3 底部信息条 | `LogViewerControl.xaml:62-69` | +| F5 | `IFindAllViewModel` 有 `Search`(`ILogSourceSearch.Count`),无 `Count` 属性;`AbstractDataSourceViewModel.Update()` 周期性调 `_search.Update()` | `IFindAllViewModel.cs`、`AbstractDataSourceViewModel.cs:784-802` | +| F6 | 属性页根 Border 无前景;Properties 头用 `TextSecondaryBrush`;`DisplayName` 与 `Value` 均无前景(深色下默认黑不可读) | `PropertiesSidePanelDataTemplate.xaml` | +| F7 | `SearchTextBox` 成员:`Watermark`/`OccurenceCount`/`CurrentOccurenceIndex`/`RequiresExplicitSearchStart` | `bin/Metrolib.xml` | + +## 需求 + +- R1 移入顶部网格:`LogViewerControl.xaml` 顶部网格改为四列 `Auto | * | Auto | Auto`,新增 col2 为搜索容器(右对齐),col3 保持 SidePanelControl;删除旧 `Grid.Row=1` 搜索行。 +- R2 行重排:RowDefinitions 由 `50 | Auto | 3* | Auto | Auto` 改为 `50 | 3* | Auto | Auto`(删除搜索行);`Grid.Row` 由 2/3 递减为 1/2(主内容、遮罩、数据源面板、底部信息条)。 +- R3 统一视觉(显式共享资源):`UserControl.Resources` 新增 `Style x:Key="LogSearchTextBoxStyle" TargetType="SearchTextBox"`,集中 Height=24、Width=200、Padding=2、VerticalAlignment=Center、RequiresExplicitSearchStart=False、AcceptsTab=True;两个框都引用该样式,移除旧的左侧边距/对齐与 FilterTextBox 过滤视觉。 +- R4 Find All 换型:`FilterTextBox` → `SearchTextBox`,`Text` 绑 `FindAll.SearchTerm`、`OccurenceCount` 绑 `FindAll.Count`、`Watermark` 用 `Strings.FindAllInLogFile`;`CurrentOccurenceIndex` 不绑。 +- R5 计数:`IFindAllViewModel` 新增 `int Count { get; }`;`FindAllViewModel` 实现 `Count`(`FindAll.Search?.Count ?? 0`)+ `Update()`;`AbstractDataSourceViewModel.Update()` 追加 `_findAll.Update()`。 +- R6 属性页整页前景:根 Border 加 `Foreground="{DynamicResource TextPrimaryBrush}"`;Properties 头由 `TextSecondaryBrush` 改 `TextPrimaryBrush`;`DisplayName` 与 `Value` 加 `Foreground="{DynamicResource TextPrimaryBrush}"`。 +- R7 红线:不碰 `BusinessLogic/**`、不改渲染算法/虚拟化、不引入新第三方依赖;保留 Search 与 Find All 既有绑定/行为(含 `FindAll.Show`/`CloseCommand` 语义)。 + +## 验收标准 + +- [ ] 两个搜索框位于顶部网格(`Grid.Row=0`)右侧,与左上数据源选择器、中央工具栏、最右 SidePanelControl 同排不重叠;旧 `Grid.Row=1` 搜索行已删除。 +- [ ] 两个框视觉统一(共享 `LogSearchTextBoxStyle` + `SearchTextBox` 搜索视觉 + 水印 + 计数);计数随 Find All 结果显示。 +- [ ] Find All 输入仍即时触发「Find all」结果视图;清空后隐藏(`Show = !string.IsNullOrEmpty(value)` 不变);Search 绑定(Term/ResultCount/CurrentResultIndex)不变。 +- [ ] 属性页根/头/名/值在浅色与深色下均清晰可读(`TextPrimaryBrush`)。 +- [ ] 解决方案构建 warning-free;目标 UI STA(`LogViewerControlTest`/`MainWindowTest`)通过;`git diff --check` 通过。 +- [ ] `IFindAllViewModel` 变更后,任何模拟/实现该接口的测试同步编译通过。 + +## 非目标 + +- 不改 Find All 结果视图(`PART_FindAllView`)本身、不改 `FindAll.Show/CloseCommand` 语义。 +- 不做 Search 与 Find All 之外的搜索 UI 改动。 + +## Notes + +- `SearchTextBox.CurrentOccurenceIndex` 对 Find All 不适用(同时展示全部匹配,无单一游标),不绑定;其「上一处/下一处」按钮外观存在但无导航行为——已知限制。 +- 若 `SearchTextBox` 在 Find All 场景的导航 chrome 表现异常,回退方案:保留统一视觉(同高/宽/边距/水印/右对齐)但 Find All 仍用 `FilterTextBox`——记录为文档化回退,不静默切换。 diff --git a/.trellis/tasks/08-23-search-property-layout/task.json b/.trellis/tasks/08-23-search-property-layout/task.json new file mode 100644 index 00000000..380bfd70 --- /dev/null +++ b/.trellis/tasks/08-23-search-property-layout/task.json @@ -0,0 +1,26 @@ +{ + "id": "search-property-layout", + "name": "search-property-layout", + "title": "搜索框与属性页布局修正", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "brofea", + "assignee": "brofea", + "createdAt": "2026-08-23", + "completedAt": null, + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-23-redesign-theme-log-colors-layout", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-23-theme-mode-and-log-colors/check.jsonl b/.trellis/tasks/08-23-theme-mode-and-log-colors/check.jsonl new file mode 100644 index 00000000..a749c831 --- /dev/null +++ b/.trellis/tasks/08-23-theme-mode-and-log-colors/check.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/spec/ui/theming.md", "reason": "检查无 {StaticResource Primary*/Surface*} 回归、Color 键只在应用作用域、新强调色派生契约"} +{"file": ".trellis/spec/testing/index.md", "reason": "UISettingsTest 迁移/roundtrip/新强调色断言、SystemThemeDetectorTest(fake provider)、本地化安全断言"} +{"file": ".trellis/spec/build/index.md", "reason": "net48 warning-free 门禁"} +{"file": ".trellis/spec/guides/index.md", "reason": "评审防误报与改值前 grep 规则"} +{"file": ".trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/approved-decisions.md", "reason": "红线审计:D2 渲染器文件本任务不得触碰;Apply(Color,bool) 主体不改"} diff --git a/.trellis/tasks/08-23-theme-mode-and-log-colors/design.md b/.trellis/tasks/08-23-theme-mode-and-log-colors/design.md new file mode 100644 index 00000000..016f828e --- /dev/null +++ b/.trellis/tasks/08-23-theme-mode-and-log-colors/design.md @@ -0,0 +1,111 @@ +# 设计:主题模式三态 + 新默认强调色 + +## 1. 边界 + +### 可改 +- `src/Tailviewer/Settings/ThemeMode.cs`(新增枚举)、`Settings/UISettings.cs`。 +- `src/Tailviewer/Ui/ThemeManager.cs`(签名 + 解析 + provider 注入;`Apply(Color,bool)` 主体不改)。 +- 新增 `src/Tailviewer/Ui/SystemThemeDetector.cs`(`ISystemThemeProvider` + `WindowsSystemThemeProvider`)。 +- `src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs`、`Ui/Settings/SettingsControl.xaml`。 +- `src/Tailviewer/App.cs`(启动调用点签名)。 +- `tools/generate_localization.py`(新增 Light/Dark/System 键;如 `DarkMode` 键无引用则删除)及其三件套产物。 + +### 不可改(红线) +- `src/Tailviewer/BusinessLogic/**`。 +- `ThemePalette`/`SemanticPalette` 现有值与契约;`TextBrushes`(本子任务不碰,`UpdateNeutral` 逻辑不变)。 +- 渲染算法/分页/虚拟化/命中测试/事件坐标。 + +## 2. 枚举与解析 + +```csharp +namespace Tailviewer.Settings +{ + public enum ThemeMode { Light = 0, Dark = 1, System = 2 } +} + +// Tailviewer.Ui +public interface ISystemThemeProvider +{ + bool IsDark(); + event Action Changed; +} + +public static class ThemeManager +{ + public static ISystemThemeProvider SystemThemeProvider { get; set; } = new WindowsSystemThemeProvider(); + + public static bool ResolveDarkMode(ThemeMode mode) + { + switch (mode) + { + case ThemeMode.Dark: return true; + case ThemeMode.Light: return false; + default: return SystemThemeProvider.IsDark(); + } + } + + public static void Apply(Color primary, ThemeMode mode) + { + var darkMode = ResolveDarkMode(mode); + Apply(primary, darkMode); // 委托既有 Apply(Color, bool),其主体不变 + } + + public static void Apply(Color primary, bool darkMode) { /* 既有逻辑,本子任务不改 */ } +} +``` + +- 三态解析在上游完成;`Apply(Color, bool)` 下游保持既有行为,与依赖子任务(等级默认色钩子)解耦。 + +## 3. 默认强调色(重设计) + +| 项 | 旧 | 新 | +|---|---|---| +| `UISettings.DefaultThemeColor` | `#0047AB` | **`#0F62FE`**(IBM Carbon Blue 60) | +| `PrimaryForeground` | White | White(不变) | +| 白字 on 强调色对比(浅色表面) | ≈13.8:1 | **≈5.0:1**(AA 达标) | +| 深色表面行为 | 同左 | 强调色不变,白字 on `SecondaryBrush`(=#0F62FE) ≈5.0:1(AA) | + +- 对比计算(WCAG 相对亮度,`#0F62FE` L≈0.160):`(1.0+0.05)/(0.160+0.05) ≈ 5.0:1`。 +- 迁移:旧设置文件缺失 `themecolor` 时 `Restore` 回退新默认 `#0F62FE`;已保存 `themecolor` 的用户值不受影响(保留用户自定义)。 +- `grep` 指引:`rg -n "#0047AB|00, 0x47, 0xAB|0047AB" src/Tailviewer` 仅 `ThemePaletteTest` 用作任意测试输入可保留;`UISettingsTest.TestDefaultThemeColorIs0047AB` 必须更新。 + +## 4. 系统检测与线程/生命周期 + +```csharp +public sealed class WindowsSystemThemeProvider : ISystemThemeProvider, IDisposable +{ + public bool IsDark() // 读 AppsUseLightTheme DWORD + public event Action Changed; + + private void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) + { + if (e.Category != UserPreferenceCategory.General) return; + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher == null) return; // 测试/设计时 no-op + dispatcher.BeginInvoke(new Action(() => Changed?.Invoke())); + } +} +``` + +- **线程模型**:`SystemEvents.UserPreferenceChanged` 在非 UI 线程触发;回调只读注册表 → 判定是否有变化 → `Dispatcher.BeginInvoke` 抛回 UI 线程再触发 `Changed`。UI 线程上才允许 `ThemeManager.Apply`(它改 `Application.Current.Resources`)。 +- **生命周期**:`App.StartApplication` 注册一次系统事件(`SystemEvents.UserPreferenceChanged += ...`),应用退出时反注册(`IDisposable`);**不在**任何类型静态构造器里订阅(避免测试进程泄漏)。`App` 内 `ThemeManager.SystemThemeProvider` 默认实例即真实 Windows 实现;测试可替换为 fake。 +- **跟随判定**:`ThemeManager` 缓存 `CurrentThemeMode`;`Changed` 回调内 `if (CurrentThemeMode == ThemeMode.System)` 比较解析前后有效 darkMode,变化才 `Apply`。 +- **可测试性**:`ResolveDarkMode` 不直接碰注册表——经 `SystemThemeProvider` 接口;单测注入 `FakeSystemThemeProvider`(可手动触发 `Changed`),无需全局注册表变更。 + +## 5. 设置页 UI + +- `SettingsFlyoutViewModel`:`DarkMode` 布尔属性替换为 `ThemeMode`(`ThemeMode` 类型),setter 写设置 + `SaveAsync()` + Dispatcher 延迟 `ThemeManager.Apply(ThemeColor, value)`(镜像现状)。 +- 新增 `ThemeModes` 只读选项集合(`DisplayName` 用 `Strings.ThemeModeLight/Dark/System`),ComboBox 绑定。 +- `SettingsControl.xaml`:`CheckBox DarkMode` → 三选控件(ComboBox/RadioButton),绑定 `ThemeMode`。 + +## 6. 兼容性与回滚 + +- 回滚点 R1:撤销 `ThemeMode`/`ISystemThemeProvider`/`WindowsSystemThemeProvider`/`ThemeManager` 签名与注入,恢复 `UISettings.DarkMode` bool 与 `CheckBox`,恢复 `DefaultThemeColor = #0047AB`。 +- 迁移不回写旧文件(只读旧 `darkmode`);无 schema 迁移。 +- 强调色既有单测(`ThemePaletteTest`)基线不变;`UISettingsTest.TestDefaultThemeColorIs0047AB` 改为新值断言。 + +## 7. 测试 + +- `UISettingsTest`:默认 `ThemeMode.Light`;`ThemeMode` Clone/Roundtrip/缺省回退;旧 `darkmode="true|false"` 迁移 Dark/Light;非法 `thememode` 回退 Light;`TestDefaultThemeColorIs0F62FE` 断言 `Color.FromRgb(0x0F, 0x62, 0xFE)`。 +- 新增 `ThemeManagerTest`/`SystemThemeDetectorTest`:`ResolveDarkMode` 三态映射(注入 fake provider);fake provider 触发 `Changed` 仅 System 模式跟随。 +- 本地化安全断言引用 `Strings.ThemeMode*`,不硬编码英文。 diff --git a/.trellis/tasks/08-23-theme-mode-and-log-colors/implement.jsonl b/.trellis/tasks/08-23-theme-mode-and-log-colors/implement.jsonl new file mode 100644 index 00000000..5f359509 --- /dev/null +++ b/.trellis/tasks/08-23-theme-mode-and-log-colors/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/spec/ui/theming.md", "reason": "主题管线契约:App 作用域 Color 键、DynamicResource 规则、冻结画刷陷阱;ThemePalette 默认强调色派生(新默认 #0F62FE)"} +{"file": ".trellis/spec/ui/mvvm.md", "reason": "SettingsFlyoutViewModel 三态属性/选项集合的 INotifyPropertyChanged 与绑定模式"} +{"file": ".trellis/spec/ui/project-structure.md", "reason": "Settings POCO 持久化(UISettings Save/Restore/Clone)与本地化重生规则"} +{"file": ".trellis/spec/build/index.md", "reason": "net48 SDK-style、TreatWarningsAsErrors、新 .cs 文件无需登记 csproj"} +{"file": ".trellis/spec/testing/index.md", "reason": "UISettingsTest(ThemeMode/新强调色)、SystemThemeDetectorTest 约定与本地化安全断言"} +{"file": ".trellis/spec/guides/index.md", "reason": "改 DarkMode→ThemeMode 与 #0047AB→#0F62FE 前先 grep 全部引用(Pre-Modification Rule)"} +{"file": ".trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/theme-architecture.md", "reason": "两态现状证据(Apply 调用点、UISettings 持久化、资源所有权)——本任务在其上做三态扩展"} +{"file": ".trellis/tasks/archive/2026-08/08-23-ui-dark-mode/research/approved-decisions.md", "reason": "D1「两态不做跟随系统」已批准;本任务将其升级为三态(文档化变更),其余 D2–D7 边界沿用"} diff --git a/.trellis/tasks/08-23-theme-mode-and-log-colors/implement.md b/.trellis/tasks/08-23-theme-mode-and-log-colors/implement.md new file mode 100644 index 00000000..f17888ca --- /dev/null +++ b/.trellis/tasks/08-23-theme-mode-and-log-colors/implement.md @@ -0,0 +1,67 @@ +# 实施计划:主题模式三态 + 新默认强调色 + +> 每个阶段独立可验证/可回滚。遵守 `design.md` §1 红线。 +> 状态:规划完成,待 `task.py start`;尚未实现。 +> 前置依赖:无(先于 `08-23-log-level-default-colors`)。 +> 干净顺序契约:本计划不引用 `TextBrushes.UpdateLevelDefaults`、无 `#if` 桩;`Apply(Color, bool)` 主体不改。 + +## 阶段 1:枚举、provider 与解析(回滚点 R1) + +1. [ ] 新增 `src/Tailviewer/Settings/ThemeMode.cs`:`enum ThemeMode { Light, Dark, System }`。 +2. [ ] 新增 `src/Tailviewer/Ui/SystemThemeDetector.cs`:`ISystemThemeProvider` + `WindowsSystemThemeProvider`(`IsDark()` 读 `AppsUseLightTheme`;`Changed` 经 `SystemEvents.UserPreferenceChanged` + Dispatcher marshal + `IDisposable`)。 +3. [ ] `ThemeManager`:新增 `SystemThemeProvider`(可注入静态属性,默认 Windows 实现)、`ResolveDarkMode(ThemeMode)`、`Apply(Color, ThemeMode)`(委托既有 `Apply(Color, bool)`)、`CurrentThemeMode` 缓存;**不改** `Apply(Color, bool)` 主体。 +4. [ ] 验证:`dotnet build src/Tailviewer/Tailviewer.csproj` warning-free;新增 `ThemeManagerTest`/`SystemThemeDetectorTest` 通过(fake provider,无注册表改动)。 + +## 阶段 2:设置模型、持久化与迁移(回滚点 R2) + +5. [ ] `UISettings`:`DarkMode` → `ThemeMode`(默认 Light);`Save`/`Restore`(含旧 `darkmode` 迁移)/`Clone`。 +6. [ ] `App.cs:252`:`ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.ThemeMode)`。 +7. [ ] 更新 `UISettingsTest`:`TestConstruction`/`TestClone`/`TestRoundtrip`/`TestRestoreFromEmpty`/`TestRestoreDarkMode`/`TestRestoreFromInvalidDarkMode` → `ThemeMode` 语义 + 旧 bool 迁移 + 非法回退。 +8. [ ] 验证:`dotnet build`;`UISettingsTest` 通过;`rg "DarkMode" src/Tailviewer` 归零(或仅历史注释)。 + +## 阶段 3:新默认强调色(回滚点 R1/R2) + +9. [ ] `UISettings.DefaultThemeColor`:`#0047AB` → `#0F62FE`(`Color.FromRgb(0x0F, 0x62, 0xFE)`)。 +10. [ ] 更新 `UISettingsTest.TestDefaultThemeColorIs0047AB` → `TestDefaultThemeColorIs0F62FE`(断言新值)。 +11. [ ] `rg -n "#0047AB|0x47" src/Tailviewer`:确认仅 `ThemePaletteTest` 任意测试输入残留(可保留),无生产代码遗留。 +12. [ ] 验证:构建;`UISettingsTest`/`ThemePaletteTest`/`TextBrushesTest` 通过(`TextBrushes` 默认画刷跟随 `DefaultThemeColor` 自动更新)。 + +## 阶段 4:设置入口 + 系统跟随(回滚点 R3) + +13. [ ] `SettingsFlyoutViewModel`:`ThemeMode` 属性(写设置 + `SaveAsync()` + Dispatcher 延迟 Apply)+ `ThemeModes` 选项集合;删除 `DarkMode` 属性。 +14. [ ] `SettingsControl.xaml`:主题分组 `CheckBox DarkMode` → 三选控件,绑定 `ThemeMode`。 +15. [ ] 系统跟随:`App.StartApplication` 注册 `WindowsSystemThemeProvider`(`SystemEvents.UserPreferenceChanged`)→ 仅 `CurrentThemeMode == System` 且有效 darkMode 变化时重新 Apply;退出时反注册。 +16. [ ] `tools/generate_localization.py`:新增 `ThemeModeLight`/`ThemeModeDark`/`ThemeModeSystem`(英/zh-CN),重跑生成三件套;删除无引用的 `DarkMode` 键。 +17. [ ] 验证:`python tools/generate_localization.py`;构建 warning-free;`git status --porcelain` 确认三件套重生成。 + +## 阶段 5:回归与收尾 + +18. [ ] `rg "StaticResource (Primary|Secondary|Surface|TextPrimary|TextSecondary|Divider|TitleBar|OverlayBackground)" src/Tailviewer` 为空。 +19. [ ] 目标 UI STA 单测:`MainWindowTest`/`SettingsControlTest`/`UISettingsTest` + 新增解析/检测单测。 +20. [ ] `git diff --check`;`git diff --name-only` 红线审计(仅 §1 可改文件)。 + +## 验证命令 + +```bash +dotnet build src/Tailviewer/Tailviewer.csproj +dotnet build src/Tailviewer.Tests/Tailviewer.Tests.csproj +python tools/generate_localization.py +rg -n "DarkMode" src/Tailviewer +rg -n "#0047AB|0x47" src/Tailviewer +rg -n "StaticResource (Primary|Secondary|Surface|TextPrimary|TextSecondary|Divider|TitleBar|OverlayBackground)" src/Tailviewer +git diff --check +``` + +## Review Gates + +- G1:`ResolveDarkMode` 经注入 `ISystemThemeProvider` 可单测;无全局注册表改动。 +- G2:三态持久化 roundtrip + 旧 bool 迁移;`DarkMode` 残留归零;新强调色 `#0F62FE` 断言就位。 +- G3:System 跟随系统主题、非 System 不跟随;本地化三件套重生成。 +- G4:强调色三态实时切换回归;构建 warning-free。 + +## 回滚点 + +- R1:撤销枚举/检测 provider/`ThemeManager` 解析与注入,恢复两态 `Apply(Color, bool)` + `#0047AB`。 +- R2:撤销 `UISettings` 字段与持久化迁移。 +- R3:撤销设置入口三选与系统跟随、恢复 `CheckBox DarkMode`。 +- 越界即回滚:触碰 `BusinessLogic/**` 或渲染器文件立即 revert。 diff --git a/.trellis/tasks/08-23-theme-mode-and-log-colors/prd.md b/.trellis/tasks/08-23-theme-mode-and-log-colors/prd.md new file mode 100644 index 00000000..92ba15f7 --- /dev/null +++ b/.trellis/tasks/08-23-theme-mode-and-log-colors/prd.md @@ -0,0 +1,51 @@ +# 主题模式与日志配色(Light / Dark / System 三态 + 新默认强调色) + +## Goal + +把已归档 08-23-ui-dark-mode 的**两态 Light/Dark**升级为 **Light/Dark/System 三态**:设置页三选、即时生效、持久化、System 跟随系统主题并实时响应。同时**重设计默认强调色**(弃用遗留 `#0047AB`,改用专业可访问的新默认,保留用户自定义 ThemeColor)。保留既有深色语义资源架构与强调色实时切换能力,不改渲染算法/虚拟化/命中测试。 + +> 依赖:本子任务先于 `08-23-log-level-default-colors`(为其提供 `ThemeMode` + 有效 darkMode 解析信号)。 +> 干净顺序契约:本子任务**不引用** `TextBrushes.UpdateLevelDefaults`、**不引入** `#if` 桩或缺失方法;等级默认色钩子完全由依赖子任务在 `ThemeManager.Apply(Color, bool)` 末尾追加。 + +## 已确认事实(仓库证据) + +| # | 事实 | 证据 | +|---|---|---| +| F1 | 两态现状:`UISettings.DarkMode`(bool) + `ThemeManager.Apply(Color, bool)` + `SemanticPalette.Compute(bool)` + `TextBrushes.UpdateNeutral(bool)` | `UISettings.cs`、`ThemeManager.cs`、`SemanticPalette.cs`、`TextBrushes.cs` | +| F2 | 设置页主题分组现为 `CheckBox IsChecked="{Binding DarkMode}"` + `ThemeColor` ColorPicker | `SettingsControl.xaml:314-341` | +| F3 | `SettingsFlyoutViewModel.DarkMode`/`ThemeColor` setter 已示范「写设置 + `SaveAsync()` + `Dispatcher.BeginInvoke(ThemeManager.Apply(...))`」 | `SettingsFlyoutViewModel.cs:248-297` | +| F4 | 启动应用点 `ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.DarkMode)`;`App` 构造用 `SemanticPalette.Compute(false)` 占位 | `App.cs:44-52, 252` | +| F5 | `UISettings.Save` 写 `darkmode` bool;`Restore` 读 `darkmode` | `UISettings.cs` | +| F6 | 默认强调色 `UISettings.DefaultThemeColor = #0047AB`;`UISettingsTest.TestDefaultThemeColorIs0047AB` 硬编码断言该值 | `UISettings.cs`、`UISettingsTest.cs` | + +## 需求 + +- R1 新增 `ThemeMode { Light, Dark, System }`(`Tailviewer.Settings`);`UISettings.DarkMode`(bool)替换为 `UISettings.ThemeMode`(默认 `Light`)。 +- R2 持久化与迁移:`Save` 写 `thememode`;`Restore` 先读 `thememode`(`Enum.TryParse`,非法回退 Light),缺失时回读旧 `darkmode`(`true`→Dark,否则 Light),再缺省回退 Light。`Clone` 复制。 +- R3 系统检测(可测试):新增 `ISystemThemeProvider { bool IsDark(); event Action Changed; }`;默认 `WindowsSystemThemeProvider` 读 `HKCU\...\Themes\Personalize` 的 `AppsUseLightTheme` DWORD(0=深色/1=浅色,缺失=浅色)。`ResolveDarkMode(ThemeMode)` 纯函数:`Dark→true`、`Light→false`、`System→provider.IsDark()`。 +- R4 系统跟随:`WindowsSystemThemeProvider` 订阅 `SystemEvents.UserPreferenceChanged`,仅在 `ThemeMode == System` 且解析出的有效 darkMode 实际变化时,经 Dispatcher 重新 `ThemeManager.Apply`。非 System 不响应。 +- R5 主题管线:`ThemeManager.Apply(Color primary, ThemeMode mode)` 解析有效 darkMode 后复用既有 `Apply(Color, bool)`;`Apply(Color, bool)` 主体**不改**。更新 `App.cs:252` 与 `SettingsFlyoutViewModel` 调用点为三态签名。 +- R6 设置入口:主题分组三选(ComboBox 或三 RadioButton),绑定 `ThemeMode`,替换 `DarkMode` 复选框。新增本地化键(Light/Dark/System),重跑 `python tools/generate_localization.py`。 +- R7 **默认强调色(重设计)**:`UISettings.DefaultThemeColor` 由 `#0047AB` 改为 **`#0F62FE`**(IBM Carbon Blue 60);`ThemePalette`/`PrimaryForeground=White` 契约不变;记录浅/深对比(见 design §3);更新 `UISettingsTest.TestDefaultThemeColorIs0047AB` 为新值断言;用户自定义 ThemeColor 持久化不受影响(旧设置缺失 `themecolor` 时回退新默认)。 +- R8 架构红线:`Color` 键只在应用作用域发布;Brush 用 `{DynamicResource}`;强调色实时切换不变;渲染热路径零每帧资源查找/分配。 + +## 验收标准 + +- [ ] 设置页主题分组三选(Light/Dark/System);选择即全应用即时切换,无需重启。 +- [ ] System 跟随系统主题;系统主题变化实时跟随;切回 Light/Dark 后不再跟随。 +- [ ] 持久化:重启保持所选模式;旧 `darkmode` bool 迁移正确;无属性回退 Light。 +- [ ] 默认强调色为 `#0F62FE`;白字对比 ≈5.0:1;强调色三模式实时切换;用户自定义 ThemeColor 保留。 +- [ ] `UISettingsTest`:`ThemeMode` 默认/Clone/Roundtrip/缺省/旧 bool 迁移/非法回退;`TestDefaultThemeColor` 断言新值 `#0F62FE`。 +- [ ] `ResolveDarkMode` 用注入的 `ISystemThemeProvider` 可单测(无全局注册表改动);`WindowsSystemThemeProvider` 单独用抽象隔离测试。 +- [ ] 解决方案构建 warning-free;`git diff --check` 通过;本地化三件套重生成且无手改。 + +## 非目标 + +- 不做日志等级默认色阶(子任务 `08-23-log-level-default-colors`)。 +- 不改 `BusinessLogic/**`、不改无关 ViewModel、不改渲染算法/分页/虚拟化/命中测试/事件坐标。 +- 不引入自动定时切换、不做浅色强调色对比度增强。 + +## Notes + +- `SystemEvents.UserPreferenceChanged` 在非 UI 线程触发,须 Marshal 到 `Application.Current.Dispatcher`;`Application.Current` 为 null(测试/设计时)时 no-op(见 design §5 生命周期/线程模型)。 +- `AppsUseLightTheme` 为 Windows 10/11 键;缺失(旧系统)按浅色,标记为已知限制。 diff --git a/.trellis/tasks/08-23-theme-mode-and-log-colors/task.json b/.trellis/tasks/08-23-theme-mode-and-log-colors/task.json new file mode 100644 index 00000000..92801ec3 --- /dev/null +++ b/.trellis/tasks/08-23-theme-mode-and-log-colors/task.json @@ -0,0 +1,26 @@ +{ + "id": "theme-mode-and-log-colors", + "name": "theme-mode-and-log-colors", + "title": "主题模式与日志配色", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "brofea", + "assignee": "brofea", + "createdAt": "2026-08-23", + "completedAt": null, + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-23-redesign-theme-log-colors-layout", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/src/Tailviewer.Tests/Settings/LogLevelDefaultsTest.cs b/src/Tailviewer.Tests/Settings/LogLevelDefaultsTest.cs new file mode 100644 index 00000000..84e74df2 --- /dev/null +++ b/src/Tailviewer.Tests/Settings/LogLevelDefaultsTest.cs @@ -0,0 +1,65 @@ +using System.Windows.Media; +using FluentAssertions; +using NUnit.Framework; +using Tailviewer.Api; +using Tailviewer.Settings; + +namespace Tailviewer.Tests.Settings +{ + [TestFixture] + public sealed class LogLevelDefaultsTest + { + [Test] + public void TestLightPalette() + { + var light = LogLevelDefaults.Light; + AssertLevel(light.Other, Color.FromRgb(0x7C, 0x6E, 0x9E), Colors.Transparent); + AssertLevel(light.Trace, Color.FromRgb(0x9A, 0xA5, 0xB1), Colors.Transparent); + AssertLevel(light.Debug, Color.FromRgb(0x6B, 0x7A, 0x8F), Colors.Transparent); + AssertLevel(light.Info, Color.FromRgb(0x33, 0x33, 0x33), Colors.Transparent); + AssertLevel(light.Warning, Color.FromRgb(0x8A, 0x4D, 0x00), Color.FromRgb(0xFF, 0xF3, 0xD6)); + AssertLevel(light.Error, Color.FromRgb(0xB3, 0x26, 0x1E), Color.FromRgb(0xFD, 0xEC, 0xEA)); + AssertLevel(light.Fatal, Color.FromRgb(0xFF, 0xFF, 0xFF), Color.FromRgb(0xC5, 0x22, 0x1F)); + } + + [Test] + public void TestDarkPalette() + { + var dark = LogLevelDefaults.Dark; + AssertLevel(dark.Other, Color.FromRgb(0x8E, 0x7F, 0xAE), Colors.Transparent); + AssertLevel(dark.Trace, Color.FromRgb(0x5B, 0x64, 0x72), Colors.Transparent); + AssertLevel(dark.Debug, Color.FromRgb(0x7E, 0x8C, 0x9E), Colors.Transparent); + AssertLevel(dark.Info, Color.FromRgb(0xDC, 0xDC, 0xDC), Colors.Transparent); + AssertLevel(dark.Warning, Color.FromRgb(0xF5, 0xD7, 0x7E), Color.FromRgb(0x5A, 0x47, 0x00)); + AssertLevel(dark.Error, Color.FromRgb(0xFF, 0xB4, 0xAB), Color.FromRgb(0x6B, 0x1F, 0x1F)); + AssertLevel(dark.Fatal, Color.FromRgb(0xFF, 0xFF, 0xFF), Color.FromRgb(0x9E, 0x1B, 0x1B)); + } + + [Test] + public void TestFor() + { + LogLevelDefaults.For(false).Should().BeSameAs(LogLevelDefaults.Light); + LogLevelDefaults.For(true).Should().BeSameAs(LogLevelDefaults.Dark); + } + + [Test] + public void TestGet() + { + LogLevelDefaults.Light.Get(LevelFlags.Other).Should().BeSameAs(LogLevelDefaults.Light.Other); + LogLevelDefaults.Light.Get(LevelFlags.Trace).Should().BeSameAs(LogLevelDefaults.Light.Trace); + LogLevelDefaults.Light.Get(LevelFlags.Debug).Should().BeSameAs(LogLevelDefaults.Light.Debug); + LogLevelDefaults.Light.Get(LevelFlags.Info).Should().BeSameAs(LogLevelDefaults.Light.Info); + LogLevelDefaults.Light.Get(LevelFlags.Warning).Should().BeSameAs(LogLevelDefaults.Light.Warning); + LogLevelDefaults.Light.Get(LevelFlags.Error).Should().BeSameAs(LogLevelDefaults.Light.Error); + LogLevelDefaults.Light.Get(LevelFlags.Fatal).Should().BeSameAs(LogLevelDefaults.Light.Fatal); + LogLevelDefaults.Light.Get(LevelFlags.All).Should().BeSameAs(LogLevelDefaults.Light.Other); + LogLevelDefaults.Light.Get(LevelFlags.None).Should().BeSameAs(LogLevelDefaults.Light.Other); + } + + private static void AssertLevel(LogLevelSettings level, Color foreground, Color background) + { + level.ForegroundColor.Should().Be(foreground); + level.BackgroundColor.Should().Be(background); + } + } +} \ No newline at end of file diff --git a/src/Tailviewer.Tests/Settings/LogLevelSettingsTest.cs b/src/Tailviewer.Tests/Settings/LogLevelSettingsTest.cs index f0ec3d27..d63e263e 100644 --- a/src/Tailviewer.Tests/Settings/LogLevelSettingsTest.cs +++ b/src/Tailviewer.Tests/Settings/LogLevelSettingsTest.cs @@ -19,7 +19,7 @@ public sealed class LogLevelSettingsTest Colors.Magenta, Colors.Teal }; - + [Pure] private static LogLevelSettings Restore(string file) { @@ -60,6 +60,45 @@ public void TestConstruction() var settings = new LogLevelSettings(); settings.ForegroundColor.Should().Be(Colors.Black); settings.BackgroundColor.Should().Be(Colors.Transparent); + settings.IsCustom.Should().BeFalse(); + } + + [Test] + public void TestIsCustomRoundtrip() + { + var settings = new LogLevelSettings + { + ForegroundColor = Colors.Red, + BackgroundColor = Colors.Blue, + IsCustom = true + }; + var restored = Restore(Save(settings)); + restored.ForegroundColor.Should().Be(Colors.Red); + restored.BackgroundColor.Should().Be(Colors.Blue); + restored.IsCustom.Should().BeTrue(); + } + + [Test] + public void TestIsCustomNotPresentRestoresFalse() + { + var restored = Restore(""); + restored.ForegroundColor.Should().Be(Colors.Red); + restored.BackgroundColor.Should().Be(Colors.Blue); + restored.IsCustom.Should().BeFalse(); + } + + [Test] + public void TestMatches() + { + var settings = new LogLevelSettings + { + ForegroundColor = Color.FromRgb(0x01, 0x02, 0x03), + BackgroundColor = Color.FromRgb(0x04, 0x05, 0x06) + }; + + settings.Matches(Color.FromRgb(0x01, 0x02, 0x03), Color.FromRgb(0x04, 0x05, 0x06)).Should().BeTrue(); + settings.Matches(Color.FromRgb(0x09, 0x09, 0x09), Color.FromRgb(0x04, 0x05, 0x06)).Should().BeFalse(); + settings.Matches(Color.FromRgb(0x01, 0x02, 0x03), Color.FromRgb(0x09, 0x09, 0x09)).Should().BeFalse(); } [Test] diff --git a/src/Tailviewer.Tests/Settings/LogViewerSettingsTest.cs b/src/Tailviewer.Tests/Settings/LogViewerSettingsTest.cs index 68f93805..d582a422 100644 --- a/src/Tailviewer.Tests/Settings/LogViewerSettingsTest.cs +++ b/src/Tailviewer.Tests/Settings/LogViewerSettingsTest.cs @@ -55,26 +55,13 @@ public void TestConstruction() settings.FontSize.Should().Be(12); settings.TabWidth.Should().Be(4); - settings.Other.ForegroundColor.Should().Be(Colors.Black); - settings.Other.BackgroundColor.Should().Be(Colors.Transparent); - - settings.Trace.ForegroundColor.Should().Be(Color.FromRgb(128, 128, 128)); - settings.Trace.BackgroundColor.Should().Be(Colors.Transparent); - - settings.Debug.ForegroundColor.Should().Be(Color.FromRgb(128, 128, 128)); - settings.Debug.BackgroundColor.Should().Be(Colors.Transparent); - - settings.Info.ForegroundColor.Should().Be(Colors.Black); - settings.Info.BackgroundColor.Should().Be(Colors.Transparent); - - settings.Warning.ForegroundColor.Should().Be(Colors.White); - settings.Warning.BackgroundColor.Should().Be(Color.FromRgb(255, 195, 0)); - - settings.Error.ForegroundColor.Should().Be(Colors.White); - settings.Error.BackgroundColor.Should().Be(Color.FromRgb(232, 17, 35)); - - settings.Fatal.ForegroundColor.Should().Be(Colors.White); - settings.Fatal.BackgroundColor.Should().Be(Color.FromRgb(232, 17, 35)); + AssertMatches(settings.Other, LogLevelDefaults.Light.Other); + AssertMatches(settings.Trace, LogLevelDefaults.Light.Trace); + AssertMatches(settings.Debug, LogLevelDefaults.Light.Debug); + AssertMatches(settings.Info, LogLevelDefaults.Light.Info); + AssertMatches(settings.Warning, LogLevelDefaults.Light.Warning); + AssertMatches(settings.Error, LogLevelDefaults.Light.Error); + AssertMatches(settings.Fatal, LogLevelDefaults.Light.Fatal); } [Test] @@ -130,7 +117,7 @@ public void TestRestoreFromEmpty() writer.WriteEndDocument(); } - empty = Encoding.UTF8.GetString(stream.ToArray()); + empty = Encoding.UTF8.GetString(stream.ToArray()); } var settings = Restore(empty); @@ -140,26 +127,66 @@ public void TestRestoreFromEmpty() settings.FontSize.Should().Be(12, reason); settings.TabWidth.Should().Be(4, reason); - settings.Other.ForegroundColor.Should().Be(Colors.Black); - settings.Other.BackgroundColor.Should().Be(Colors.Transparent); + AssertMatches(settings.Other, LogLevelDefaults.Light.Other); + AssertMatches(settings.Trace, LogLevelDefaults.Light.Trace); + AssertMatches(settings.Debug, LogLevelDefaults.Light.Debug); + AssertMatches(settings.Info, LogLevelDefaults.Light.Info); + AssertMatches(settings.Warning, LogLevelDefaults.Light.Warning); + AssertMatches(settings.Error, LogLevelDefaults.Light.Error); + AssertMatches(settings.Fatal, LogLevelDefaults.Light.Fatal); + } - settings.Trace.ForegroundColor.Should().Be(Color.FromRgb(128, 128, 128), reason); - settings.Trace.BackgroundColor.Should().Be(Colors.Transparent, reason); + [Test] + [Description("Legacy files without iscustom that hold non-default colors are treated as custom")] + public void TestRestoreLegacyCustomLevel() + { + var settings = Restore(""); + settings.Warning.IsCustom.Should().BeTrue(); + settings.Warning.ForegroundColor.Should().Be(Color.FromRgb(0x11, 0x22, 0x33)); + settings.Warning.BackgroundColor.Should().Be(Color.FromRgb(0x44, 0x55, 0x66)); + } - settings.Debug.ForegroundColor.Should().Be(Color.FromRgb(128, 128, 128)); - settings.Debug.BackgroundColor.Should().Be(Colors.Transparent); + [Test] + [Description("Legacy files without iscustom that hold the light default colors stay non-custom")] + public void TestRestoreLegacyDefaultLevel() + { + var lightInfo = LogLevelDefaults.Light.Info; + var xml = string.Format( + "", + lightInfo.ForegroundColor, + lightInfo.BackgroundColor); + var settings = Restore(xml); + settings.Info.IsCustom.Should().BeFalse(); + } - settings.Info.ForegroundColor.Should().Be(Colors.Black); - settings.Info.BackgroundColor.Should().Be(Colors.Transparent); + [Test] + [Description("Explicit iscustom flags are honored regardless of the stored colors")] + public void TestRestoreExplicitCustomFlag() + { + var settings = Restore(""); + settings.Info.IsCustom.Should().BeTrue(); + } - settings.Warning.ForegroundColor.Should().Be(Colors.White); - settings.Warning.BackgroundColor.Should().Be(Color.FromRgb(255, 195, 0)); + [Test] + [Description("ApplyThemeDefaults only rewrites non-custom levels")] + public void TestApplyThemeDefaultsOnlyChangesNonCustomLevels() + { + var settings = new LogViewerSettings(); + settings.Warning.IsCustom = true; + settings.Warning.ForegroundColor = Colors.Pink; + settings.Warning.BackgroundColor = Colors.Blue; + + settings.ApplyThemeDefaults(darkMode: true); - settings.Error.ForegroundColor.Should().Be(Colors.White); - settings.Error.BackgroundColor.Should().Be(Color.FromRgb(232, 17, 35)); + settings.Warning.IsCustom.Should().BeTrue(); + settings.Warning.ForegroundColor.Should().Be(Colors.Pink); + settings.Warning.BackgroundColor.Should().Be(Colors.Blue); - settings.Fatal.ForegroundColor.Should().Be(Colors.White); - settings.Fatal.BackgroundColor.Should().Be(Color.FromRgb(232, 17, 35)); + settings.Info.IsCustom.Should().BeFalse(); + settings.Info.ForegroundColor.Should().Be(LogLevelDefaults.Dark.Info.ForegroundColor); + settings.Info.BackgroundColor.Should().Be(LogLevelDefaults.Dark.Info.BackgroundColor); + settings.Fatal.ForegroundColor.Should().Be(LogLevelDefaults.Dark.Fatal.ForegroundColor); + settings.Fatal.BackgroundColor.Should().Be(LogLevelDefaults.Dark.Fatal.BackgroundColor); } [Test] @@ -241,5 +268,11 @@ public void TestRoundtrip([Values(1, 2)] int linesScrolledPerWheelTick, actualSettings.Fatal.BackgroundColor.Should().Be(Colors.Coral); actualSettings.Fatal.ForegroundColor.Should().Be(Colors.HotPink); } + + private static void AssertMatches(LogLevelSettings actual, LogLevelSettings expected) + { + actual.ForegroundColor.Should().Be(expected.ForegroundColor); + actual.BackgroundColor.Should().Be(expected.BackgroundColor); + } } } \ No newline at end of file diff --git a/src/Tailviewer.Tests/Settings/UISettingsTest.cs b/src/Tailviewer.Tests/Settings/UISettingsTest.cs index 0a2a600d..9e5f8c6b 100644 --- a/src/Tailviewer.Tests/Settings/UISettingsTest.cs +++ b/src/Tailviewer.Tests/Settings/UISettingsTest.cs @@ -45,13 +45,13 @@ public void TestConstruction() var settings = new UISettings(); settings.Language.Should().Be(UISettings.DefaultLanguage); settings.ThemeColor.Should().Be(UISettings.DefaultThemeColor); - settings.DarkMode.Should().BeFalse(); + settings.ThemeMode.Should().Be(ThemeMode.Light); } [Test] - public void TestDefaultThemeColorIs0047AB() + public void TestDefaultThemeColorIs0F62FE() { - UISettings.DefaultThemeColor.Should().Be(Color.FromRgb(0x00, 0x47, 0xAB)); + UISettings.DefaultThemeColor.Should().Be(Color.FromRgb(0x0F, 0x62, 0xFE)); } [Test] @@ -61,14 +61,14 @@ public void TestClone() { Language = "zh-CN", ThemeColor = Colors.Red, - DarkMode = true + ThemeMode = ThemeMode.System }; var clone = settings.Clone(); clone.Should().NotBeSameAs(settings); clone.Language.Should().Be("zh-CN"); clone.ThemeColor.Should().Be(Colors.Red); - clone.DarkMode.Should().BeTrue(); + clone.ThemeMode.Should().Be(ThemeMode.System); } [Test] @@ -78,13 +78,13 @@ public void TestRoundtrip() { Language = "zh-CN", ThemeColor = Color.FromRgb(0x12, 0x34, 0x56), - DarkMode = true + ThemeMode = ThemeMode.Dark }; var restored = Restore(Save(settings)); restored.Language.Should().Be("zh-CN"); restored.ThemeColor.Should().Be(Color.FromRgb(0x12, 0x34, 0x56)); - restored.DarkMode.Should().BeTrue(); + restored.ThemeMode.Should().Be(ThemeMode.Dark); } [Test] @@ -93,7 +93,7 @@ public void TestRestoreFromEmpty() var restored = Restore(""); restored.Language.Should().Be(UISettings.DefaultLanguage); restored.ThemeColor.Should().Be(UISettings.DefaultThemeColor); - restored.DarkMode.Should().BeFalse(); + restored.ThemeMode.Should().Be(ThemeMode.Light); } [Test] @@ -104,17 +104,40 @@ public void TestRestoreFromInvalidColor() } [Test] - public void TestRestoreDarkMode() + public void TestRestoreThemeMode() { - var restored = Restore(""); - restored.DarkMode.Should().BeTrue(); + Restore("").ThemeMode.Should().Be(ThemeMode.Dark); + Restore("").ThemeMode.Should().Be(ThemeMode.System); + Restore("").ThemeMode.Should().Be(ThemeMode.Light); } [Test] - public void TestRestoreFromInvalidDarkMode() + public void TestRestoreFromInvalidThemeMode() { - var restored = Restore(""); - restored.DarkMode.Should().BeFalse(); + Restore("").ThemeMode.Should().Be(ThemeMode.Light); + Restore("").ThemeMode.Should().Be(ThemeMode.Light); + } + + [Test] + public void TestRestoreInvalidThemeModeDoesNotFallBackToLegacyDarkMode() + { + // An invalid thememode attribute must NOT trigger the legacy darkmode migration, + // even when a valid darkmode attribute is also present. + Restore("").ThemeMode.Should().Be(ThemeMode.Light); + Restore("").ThemeMode.Should().Be(ThemeMode.Light); + } + + [Test] + public void TestRestoreFromLegacyDarkMode() + { + Restore("").ThemeMode.Should().Be(ThemeMode.Dark); + Restore("").ThemeMode.Should().Be(ThemeMode.Light); + } + + [Test] + public void TestRestoreFromInvalidLegacyDarkMode() + { + Restore("").ThemeMode.Should().Be(ThemeMode.Light); } } } diff --git a/src/Tailviewer.Tests/Tailviewer.Tests.csproj b/src/Tailviewer.Tests/Tailviewer.Tests.csproj index 4e390059..432bccb5 100644 --- a/src/Tailviewer.Tests/Tailviewer.Tests.csproj +++ b/src/Tailviewer.Tests/Tailviewer.Tests.csproj @@ -129,6 +129,7 @@ + @@ -179,9 +180,11 @@ + + diff --git a/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs b/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs new file mode 100644 index 00000000..176e76ab --- /dev/null +++ b/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs @@ -0,0 +1,128 @@ +using System.Threading; +using System.Windows.Media; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Tailviewer.Api; +using Tailviewer.Settings; +using Tailviewer.Ui; +using Tailviewer.Ui.Settings; + +namespace Tailviewer.Tests.Ui.Settings +{ + [TestFixture] + [Apartment(ApartmentState.STA)] + public sealed class LogLevelSettingsViewModelTest + { + [Test] + public void TestForegroundOnlyChangeInDarkSeedsDarkDefaults() + { + ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: true); + try + { + var levelSettings = new LogLevelSettings(); + var settings = new Mock(); + var vm = new LogLevelSettingsViewModel(settings.Object, levelSettings, LevelFlags.Warning); + + vm.ForegroundColor.Should().Be(LogLevelDefaults.Dark.Warning.ForegroundColor); + vm.BackgroundColor.Should().Be(LogLevelDefaults.Dark.Warning.BackgroundColor); + + var customForeground = Color.FromRgb(0x01, 0x02, 0x03); + vm.ForegroundColor = customForeground; + + levelSettings.IsCustom.Should().BeTrue(); + levelSettings.ForegroundColor.Should().Be(customForeground); + levelSettings.BackgroundColor.Should().Be(LogLevelDefaults.Dark.Warning.BackgroundColor); + } + finally + { + ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + } + } + + [Test] + public void TestBackgroundOnlyChangeInDarkSeedsDarkDefaults() + { + ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: true); + try + { + var levelSettings = new LogLevelSettings(); + var settings = new Mock(); + var vm = new LogLevelSettingsViewModel(settings.Object, levelSettings, LevelFlags.Warning); + + var customBackground = Color.FromRgb(0x04, 0x05, 0x06); + vm.BackgroundColor = customBackground; + + levelSettings.IsCustom.Should().BeTrue(); + levelSettings.BackgroundColor.Should().Be(customBackground); + levelSettings.ForegroundColor.Should().Be(LogLevelDefaults.Dark.Warning.ForegroundColor); + } + finally + { + ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + } + } + + [Test] + public void TestForegroundOnlyChangeInLightSeedsLightDefaults() + { + ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + try + { + var levelSettings = new LogLevelSettings(); + var settings = new Mock(); + var vm = new LogLevelSettingsViewModel(settings.Object, levelSettings, LevelFlags.Warning); + + var customForeground = Color.FromRgb(0x07, 0x08, 0x09); + vm.ForegroundColor = customForeground; + + levelSettings.IsCustom.Should().BeTrue(); + levelSettings.ForegroundColor.Should().Be(customForeground); + levelSettings.BackgroundColor.Should().Be(LogLevelDefaults.Light.Warning.BackgroundColor); + } + finally + { + ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + } + } + + [Test] + public void TestAlreadyCustomSetForegroundPreservesBackground() + { + var levelSettings = new LogLevelSettings + { + ForegroundColor = Color.FromRgb(0x11, 0x22, 0x33), + BackgroundColor = Color.FromRgb(0x44, 0x55, 0x66), + IsCustom = true + }; + var settings = new Mock(); + var vm = new LogLevelSettingsViewModel(settings.Object, levelSettings, LevelFlags.Warning); + + var newForeground = Color.FromRgb(0x77, 0x88, 0x99); + vm.ForegroundColor = newForeground; + + levelSettings.ForegroundColor.Should().Be(newForeground); + levelSettings.BackgroundColor.Should().Be(Color.FromRgb(0x44, 0x55, 0x66)); + } + + [Test] + public void TestTransitionCallsSaveAsync() + { + ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: true); + try + { + var levelSettings = new LogLevelSettings(); + var settings = new Mock(); + var vm = new LogLevelSettingsViewModel(settings.Object, levelSettings, LevelFlags.Warning); + + vm.ForegroundColor = Color.FromRgb(0xAA, 0xBB, 0xCC); + + settings.Verify(x => x.SaveAsync(), Times.Once()); + } + finally + { + ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + } + } + } +} \ No newline at end of file diff --git a/src/Tailviewer.Tests/Ui/TextBrushesTest.cs b/src/Tailviewer.Tests/Ui/TextBrushesTest.cs index b2b558e2..713b22e3 100644 --- a/src/Tailviewer.Tests/Ui/TextBrushesTest.cs +++ b/src/Tailviewer.Tests/Ui/TextBrushesTest.cs @@ -83,6 +83,7 @@ public void TestSelectedUnfocusedForegroundUsesDefaultBrush() public void TestAlternateBackgroundFollowsNeutralTheme() { var settings = new LogViewerSettings(); + settings.Warning.IsCustom = true; settings.Warning.BackgroundColor = Color.FromRgb(0x10, 0x20, 0x30); var brushes = new TextBrushes(settings); @@ -106,5 +107,51 @@ public void TestAlternateBackgroundFollowsNeutralTheme() TextBrushes.UpdateNeutral(false); } } + + [Test] + public void TestNonCustomLevelBrushFollowsTheme() + { + var brushes = new TextBrushes(new LogViewerSettings()); + + try + { + TextBrushes.UpdateLevelDefaults(false); + var info = (SolidColorBrush) brushes.ForegroundBrush(false, false, true, LevelFlags.Info); + info.Color.Should().Be(LogLevelDefaults.Light.Info.ForegroundColor); + + TextBrushes.UpdateLevelDefaults(true); + info.Color.Should().Be(LogLevelDefaults.Dark.Info.ForegroundColor); + + brushes.ForegroundBrush(false, false, true, LevelFlags.Info).Should().BeSameAs(info); + } + finally + { + TextBrushes.UpdateLevelDefaults(false); + } + } + + [Test] + public void TestCustomLevelBrushIsPreservedAcrossTheme() + { + var settings = new LogViewerSettings(); + settings.Info.IsCustom = true; + settings.Info.ForegroundColor = Color.FromRgb(0x11, 0x22, 0x33); + settings.Info.BackgroundColor = Color.FromRgb(0x44, 0x55, 0x66); + var brushes = new TextBrushes(settings); + + try + { + TextBrushes.UpdateLevelDefaults(false); + var info = (SolidColorBrush) brushes.ForegroundBrush(false, false, true, LevelFlags.Info); + info.Color.Should().Be(Color.FromRgb(0x11, 0x22, 0x33)); + + TextBrushes.UpdateLevelDefaults(true); + info.Color.Should().Be(Color.FromRgb(0x11, 0x22, 0x33)); + } + finally + { + TextBrushes.UpdateLevelDefaults(false); + } + } } -} +} \ No newline at end of file diff --git a/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs b/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs new file mode 100644 index 00000000..4033fa4a --- /dev/null +++ b/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs @@ -0,0 +1,83 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Tailviewer.Settings; +using Tailviewer.Ui; + +namespace Tailviewer.Tests.Ui +{ + [TestFixture] + public sealed class ThemeManagerTest + { + private sealed class FakeSystemThemeProvider + : ISystemThemeProvider + { + private readonly bool _isDark; + + public FakeSystemThemeProvider(bool isDark) + { + _isDark = isDark; + } + + public bool IsDark() + { + return _isDark; + } + + #pragma warning disable 67 + public event Action Changed; + #pragma warning restore 67 + } + + [Test] + public void TestResolveDarkModeLight() + { + ThemeManager.ResolveDarkMode(ThemeMode.Light).Should().BeFalse(); + } + + [Test] + public void TestResolveDarkModeDark() + { + ThemeManager.ResolveDarkMode(ThemeMode.Dark).Should().BeTrue(); + } + + [Test] + public void TestResolveDarkModeSystemFollowsProvider() + { + var previous = ThemeManager.SystemThemeProvider; + try + { + ThemeManager.SystemThemeProvider = new FakeSystemThemeProvider(true); + ThemeManager.ResolveDarkMode(ThemeMode.System).Should().BeTrue(); + + ThemeManager.SystemThemeProvider = new FakeSystemThemeProvider(false); + ThemeManager.ResolveDarkMode(ThemeMode.System).Should().BeFalse(); + } + finally + { + ThemeManager.SystemThemeProvider = previous; + } + } + + [Test] + public void TestResolveDarkModeSystemWithoutProviderIsLight() + { + var previous = ThemeManager.SystemThemeProvider; + try + { + ThemeManager.SystemThemeProvider = null; + ThemeManager.ResolveDarkMode(ThemeMode.System).Should().BeFalse(); + } + finally + { + ThemeManager.SystemThemeProvider = previous; + } + } + + [Test] + public void TestResolveDarkModeUndefinedValueFallsBackToLight() + { + ThemeManager.ResolveDarkMode((ThemeMode)99).Should().BeFalse(); + } + } +} diff --git a/src/Tailviewer/App.cs b/src/Tailviewer/App.cs index 210789ed..a5a057f1 100644 --- a/src/Tailviewer/App.cs +++ b/src/Tailviewer/App.cs @@ -249,7 +249,21 @@ private static int StartApplication(SingleApplicationHelper.IMutex mutex, string actionCenter.Add(Build.Current); var application = new App(); - ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.DarkMode); + + var systemThemeProvider = new WindowsSystemThemeProvider(); + ThemeManager.SystemThemeProvider = systemThemeProvider; + systemThemeProvider.Changed += () => + { + if (ThemeManager.CurrentThemeMode != ThemeMode.System) + return; + + var darkMode = ThemeManager.ResolveDarkMode(ThemeMode.System); + if (darkMode != ThemeManager.CurrentDarkMode) + ThemeManager.Apply(settings.Ui.ThemeColor, ThemeMode.System); + }; + application.Exit += (sender, e) => systemThemeProvider.Dispose(); + + ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.ThemeMode); var dispatcher = Dispatcher.CurrentDispatcher; var uiDispatcher = new UiDispatcher(dispatcher); services.RegisterInstance(uiDispatcher); diff --git a/src/Tailviewer/Localization/Strings.cs b/src/Tailviewer/Localization/Strings.cs index db02278c..feb56adb 100644 --- a/src/Tailviewer/Localization/Strings.cs +++ b/src/Tailviewer/Localization/Strings.cs @@ -43,7 +43,6 @@ public static class Strings public static string Created => _rm.GetString("Created") ?? "Created"; public static string CurrentDataSource => _rm.GetString("CurrentDataSource") ?? "CurrentDataSource"; public static string CustomFormatsGroup => _rm.GetString("CustomFormatsGroup") ?? "CustomFormatsGroup"; - public static string DarkMode => _rm.GetString("DarkMode") ?? "DarkMode"; public static string DataSourceDescription => _rm.GetString("DataSourceDescription") ?? "DataSourceDescription"; public static string DataSourceExcluded => _rm.GetString("DataSourceExcluded") ?? "DataSourceExcluded"; public static string DataSourceFilter => _rm.GetString("DataSourceFilter") ?? "DataSourceFilter"; @@ -254,6 +253,9 @@ public static class Strings public static string Tailviewer => _rm.GetString("Tailviewer") ?? "Tailviewer"; public static string ThemeColor => _rm.GetString("ThemeColor") ?? "ThemeColor"; public static string ThemeGroup => _rm.GetString("ThemeGroup") ?? "ThemeGroup"; + public static string ThemeModeDark => _rm.GetString("ThemeModeDark") ?? "ThemeModeDark"; + public static string ThemeModeLight => _rm.GetString("ThemeModeLight") ?? "ThemeModeLight"; + public static string ThemeModeSystem => _rm.GetString("ThemeModeSystem") ?? "ThemeModeSystem"; public static string TheyDo => _rm.GetString("TheyDo") ?? "TheyDo"; public static string ThisMonth => _rm.GetString("ThisMonth") ?? "ThisMonth"; public static string ThisWeek => _rm.GetString("ThisWeek") ?? "ThisWeek"; diff --git a/src/Tailviewer/Localization/Strings.resx b/src/Tailviewer/Localization/Strings.resx index 852112a2..13c29294 100644 --- a/src/Tailviewer/Localization/Strings.resx +++ b/src/Tailviewer/Localization/Strings.resx @@ -111,9 +111,6 @@ Custom Log file Formats - - Dark mode - Identifies this data source amongst all others in this group - also displayed next to each log line @@ -747,6 +744,15 @@ Try changing your filter(s) or disable them again Theme + + Dark + + + Light + + + System + they do diff --git a/src/Tailviewer/Localization/Strings.zh-CN.resx b/src/Tailviewer/Localization/Strings.zh-CN.resx index a30a93dd..9aac8a11 100644 --- a/src/Tailviewer/Localization/Strings.zh-CN.resx +++ b/src/Tailviewer/Localization/Strings.zh-CN.resx @@ -111,9 +111,6 @@ 自定义日志文件格式 - - 深色模式 - 用于在该组的所有数据源中标识此数据源——同时会显示在每一行日志旁边 @@ -747,6 +744,15 @@ 主题 + + 深色 + + + 浅色 + + + 跟随系统 + 它们 diff --git a/src/Tailviewer/Settings/LogLevelDefaults.cs b/src/Tailviewer/Settings/LogLevelDefaults.cs new file mode 100644 index 00000000..2d6802ed --- /dev/null +++ b/src/Tailviewer/Settings/LogLevelDefaults.cs @@ -0,0 +1,102 @@ +using System.Windows.Media; +using Tailviewer.Api; + +namespace Tailviewer.Settings +{ + /// + /// The light and dark default palettes for the individual log levels. + /// + public sealed class LogLevelDefaults + { + public LogLevelSettings Other { get; } + public LogLevelSettings Trace { get; } + public LogLevelSettings Debug { get; } + public LogLevelSettings Info { get; } + public LogLevelSettings Warning { get; } + public LogLevelSettings Error { get; } + public LogLevelSettings Fatal { get; } + + private LogLevelDefaults( + LogLevelSettings other, + LogLevelSettings trace, + LogLevelSettings debug, + LogLevelSettings info, + LogLevelSettings warning, + LogLevelSettings error, + LogLevelSettings fatal) + { + Other = other; + Trace = trace; + Debug = debug; + Info = info; + Warning = warning; + Error = error; + Fatal = fatal; + } + + public static LogLevelDefaults Light { get; } = CreateLight(); + + public static LogLevelDefaults Dark { get; } = CreateDark(); + + public static LogLevelDefaults For(bool darkMode) + { + return darkMode ? Dark : Light; + } + + public LogLevelSettings Get(LevelFlags level) + { + switch (level) + { + case LevelFlags.Trace: return Trace; + case LevelFlags.Debug: return Debug; + case LevelFlags.Info: return Info; + case LevelFlags.Warning: return Warning; + case LevelFlags.Error: return Error; + case LevelFlags.Fatal: return Fatal; + default: return Other; + } + } + + private static LogLevelSettings Level(Color foreground) + { + return new LogLevelSettings + { + ForegroundColor = foreground, + BackgroundColor = Colors.Transparent + }; + } + + private static LogLevelSettings Level(Color foreground, Color background) + { + return new LogLevelSettings + { + ForegroundColor = foreground, + BackgroundColor = background + }; + } + + private static LogLevelDefaults CreateLight() + { + return new LogLevelDefaults( + Level(Color.FromRgb(0x7C, 0x6E, 0x9E)), + Level(Color.FromRgb(0x9A, 0xA5, 0xB1)), + Level(Color.FromRgb(0x6B, 0x7A, 0x8F)), + Level(Color.FromRgb(0x33, 0x33, 0x33)), + Level(Color.FromRgb(0x8A, 0x4D, 0x00), Color.FromRgb(0xFF, 0xF3, 0xD6)), + Level(Color.FromRgb(0xB3, 0x26, 0x1E), Color.FromRgb(0xFD, 0xEC, 0xEA)), + Level(Color.FromRgb(0xFF, 0xFF, 0xFF), Color.FromRgb(0xC5, 0x22, 0x1F))); + } + + private static LogLevelDefaults CreateDark() + { + return new LogLevelDefaults( + Level(Color.FromRgb(0x8E, 0x7F, 0xAE)), + Level(Color.FromRgb(0x5B, 0x64, 0x72)), + Level(Color.FromRgb(0x7E, 0x8C, 0x9E)), + Level(Color.FromRgb(0xDC, 0xDC, 0xDC)), + Level(Color.FromRgb(0xF5, 0xD7, 0x7E), Color.FromRgb(0x5A, 0x47, 0x00)), + Level(Color.FromRgb(0xFF, 0xB4, 0xAB), Color.FromRgb(0x6B, 0x1F, 0x1F)), + Level(Color.FromRgb(0xFF, 0xFF, 0xFF), Color.FromRgb(0x9E, 0x1B, 0x1B))); + } + } +} \ No newline at end of file diff --git a/src/Tailviewer/Settings/LogLevelSettings.cs b/src/Tailviewer/Settings/LogLevelSettings.cs index fd96b65f..083cfc45 100644 --- a/src/Tailviewer/Settings/LogLevelSettings.cs +++ b/src/Tailviewer/Settings/LogLevelSettings.cs @@ -15,6 +15,7 @@ public sealed class LogLevelSettings private Color _foregroundColor; private Color _backgroundColor; + private bool _isCustom; public LogLevelSettings() { @@ -40,12 +41,31 @@ public Color BackgroundColor set { _backgroundColor = value; } } + /// + /// Whether the user has explicitly configured this level's colors. When + /// false, the level follows the current theme's default palette. + /// + public bool IsCustom + { + get { return _isCustom; } + set { _isCustom = value; } + } + + /// + /// Compares this level's colors against the given foreground/background pair. + /// + public bool Matches(Color foregroundColor, Color backgroundColor) + { + return _foregroundColor == foregroundColor && _backgroundColor == backgroundColor; + } + public LogLevelSettings Clone() { return new LogLevelSettings { ForegroundColor = _foregroundColor, - BackgroundColor = _backgroundColor + BackgroundColor = _backgroundColor, + IsCustom = _isCustom }; } @@ -53,12 +73,24 @@ public void Save(XmlWriter writer) { writer.WriteAttributeColor("foregroundcolor", _foregroundColor); writer.WriteAttributeColor("backgroundcolor", _backgroundColor); + writer.WriteAttributeString("iscustom", _isCustom ? "true" : "false"); } - public void Restore(XmlReader reader) + /// + /// Restores this level's colors and custom flag from the given reader. + /// Returns true when an explicit iscustom attribute was present, + /// false for legacy files that only contain the two colors. + /// + public bool Restore(XmlReader reader) { reader.ReadAttributeAsColor("foregroundcolor", Log, _foregroundColor, out _foregroundColor); reader.ReadAttributeAsColor("backgroundcolor", Log, _backgroundColor, out _backgroundColor); + + if (!reader.MoveToAttribute("iscustom")) + return false; + + _isCustom = bool.TryParse(reader.ReadContentAsString(), out var isCustom) && isCustom; + return true; } } } \ No newline at end of file diff --git a/src/Tailviewer/Settings/LogViewerSettings.cs b/src/Tailviewer/Settings/LogViewerSettings.cs index b824ca18..618d44f2 100644 --- a/src/Tailviewer/Settings/LogViewerSettings.cs +++ b/src/Tailviewer/Settings/LogViewerSettings.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Windows.Media; using System.Xml; +using Tailviewer.Api; using Tailviewer.Core; namespace Tailviewer.Settings @@ -17,47 +18,13 @@ public sealed class LogViewerSettings public const int DefaultFontSize = 12; public const int DefaultTabWidth = 4; - public static LogLevelSettings DefaultOther => new LogLevelSettings - { - ForegroundColor = Colors.Black, - BackgroundColor = Colors.Transparent - }; - - public static LogLevelSettings DefaultTrace => new LogLevelSettings - { - ForegroundColor = Color.FromRgb(128, 128, 128), - BackgroundColor = Colors.Transparent - }; - - public static LogLevelSettings DefaultDebug => new LogLevelSettings - { - ForegroundColor = Color.FromRgb(128, 128, 128), - BackgroundColor = Colors.Transparent - }; - - public static LogLevelSettings DefaultInfo => new LogLevelSettings - { - ForegroundColor = Colors.Black, - BackgroundColor = Colors.Transparent - }; - - public static LogLevelSettings DefaultWarning => new LogLevelSettings - { - ForegroundColor = Colors.White, - BackgroundColor = Color.FromRgb(255, 195, 0) - }; - - public static LogLevelSettings DefaultError => new LogLevelSettings - { - ForegroundColor = Colors.White, - BackgroundColor = Color.FromRgb(232, 17, 35) - }; - - public static LogLevelSettings DefaultFatal => new LogLevelSettings - { - ForegroundColor = Colors.White, - BackgroundColor = Color.FromRgb(232, 17, 35) - }; + public static LogLevelSettings DefaultOther => LogLevelDefaults.Light.Get(LevelFlags.Other).Clone(); + public static LogLevelSettings DefaultTrace => LogLevelDefaults.Light.Get(LevelFlags.Trace).Clone(); + public static LogLevelSettings DefaultDebug => LogLevelDefaults.Light.Get(LevelFlags.Debug).Clone(); + public static LogLevelSettings DefaultInfo => LogLevelDefaults.Light.Get(LevelFlags.Info).Clone(); + public static LogLevelSettings DefaultWarning => LogLevelDefaults.Light.Get(LevelFlags.Warning).Clone(); + public static LogLevelSettings DefaultError => LogLevelDefaults.Light.Get(LevelFlags.Error).Clone(); + public static LogLevelSettings DefaultFatal => LogLevelDefaults.Light.Get(LevelFlags.Fatal).Clone(); private static readonly ILog Log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); @@ -204,36 +171,74 @@ public void Restore(XmlReader reader) switch (subtree.Name) { case "other": - _other.Restore(subtree); + RestoreLevel(_other, subtree, LevelFlags.Other); break; case "trace": - _trace.Restore(subtree); + RestoreLevel(_trace, subtree, LevelFlags.Trace); break; case "debug": - _debug.Restore(subtree); + RestoreLevel(_debug, subtree, LevelFlags.Debug); break; case "info": - _info.Restore(subtree); + RestoreLevel(_info, subtree, LevelFlags.Info); break; case "warning": - _warning.Restore(subtree); + RestoreLevel(_warning, subtree, LevelFlags.Warning); break; case "error": - _error.Restore(subtree); + RestoreLevel(_error, subtree, LevelFlags.Error); break; case "fatal": - _fatal.Restore(subtree); + RestoreLevel(_fatal, subtree, LevelFlags.Fatal); break; } } } + /// + /// Restores a single level, inferring the legacy custom flag when the + /// settings file predates the iscustom attribute. + /// + private void RestoreLevel(LogLevelSettings level, XmlReader reader, LevelFlags flag) + { + if (!level.Restore(reader)) + { + var light = LogLevelDefaults.Light.Get(flag); + level.IsCustom = !level.Matches(light.ForegroundColor, light.BackgroundColor); + } + } + + /// + /// Applies the given theme's default palette to every level that has not + /// been customized by the user. Custom levels are left untouched. + /// + public void ApplyThemeDefaults(bool darkMode) + { + var defaults = LogLevelDefaults.For(darkMode); + ApplyLevelDefault(_other, defaults.Other); + ApplyLevelDefault(_trace, defaults.Trace); + ApplyLevelDefault(_debug, defaults.Debug); + ApplyLevelDefault(_info, defaults.Info); + ApplyLevelDefault(_warning, defaults.Warning); + ApplyLevelDefault(_error, defaults.Error); + ApplyLevelDefault(_fatal, defaults.Fatal); + } + + private static void ApplyLevelDefault(LogLevelSettings level, LogLevelSettings defaults) + { + if (level.IsCustom) + return; + + level.ForegroundColor = defaults.ForegroundColor; + level.BackgroundColor = defaults.BackgroundColor; + } + [Pure] public LogViewerSettings Clone() { diff --git a/src/Tailviewer/Settings/ThemeMode.cs b/src/Tailviewer/Settings/ThemeMode.cs new file mode 100644 index 00000000..437a1b45 --- /dev/null +++ b/src/Tailviewer/Settings/ThemeMode.cs @@ -0,0 +1,24 @@ +namespace Tailviewer.Settings +{ + /// + /// Determines whether the application renders in light or dark colors, or + /// whether it follows the operating system's current theme. + /// + public enum ThemeMode + { + /// + /// Always render in light mode. + /// + Light = 0, + + /// + /// Always render in dark mode. + /// + Dark = 1, + + /// + /// Follow the operating system's theme. + /// + System = 2 + } +} diff --git a/src/Tailviewer/Settings/UISettings.cs b/src/Tailviewer/Settings/UISettings.cs index 3307ad9c..71238657 100644 --- a/src/Tailviewer/Settings/UISettings.cs +++ b/src/Tailviewer/Settings/UISettings.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics.Contracts; using System.Reflection; using System.Windows.Media; @@ -13,18 +14,19 @@ public sealed class UISettings public const string DefaultLanguage = "en"; - public static readonly Color DefaultThemeColor = Color.FromRgb(0x00, 0x47, 0xAB); + public static readonly Color DefaultThemeColor = Color.FromRgb(0x0F, 0x62, 0xFE); public string Language { get; set; } public Color ThemeColor { get; set; } - public bool DarkMode { get; set; } + public ThemeMode ThemeMode { get; set; } public UISettings() { Language = DefaultLanguage; ThemeColor = DefaultThemeColor; + ThemeMode = ThemeMode.Light; } [Pure] @@ -34,7 +36,7 @@ public UISettings Clone() { Language = Language, ThemeColor = ThemeColor, - DarkMode = DarkMode + ThemeMode = ThemeMode }; } @@ -42,11 +44,13 @@ public void Save(XmlWriter writer) { writer.WriteAttributeString("language", Language ?? DefaultLanguage); writer.WriteAttributeColor("themecolor", ThemeColor); - writer.WriteAttributeString("darkmode", XmlConvert.ToString(DarkMode)); + writer.WriteAttributeString("thememode", ThemeMode.ToString()); } public void Restore(XmlReader reader) { + bool themeModeAttributeSeen = false; + for (int i = 0; i < reader.AttributeCount; ++i) { reader.MoveToAttribute(i); @@ -63,9 +67,30 @@ public void Restore(XmlReader reader) ThemeColor = themeColor; break; - case "darkmode": - DarkMode = bool.TryParse(reader.ReadContentAsString(), out var darkMode) && darkMode; + case "thememode": + themeModeAttributeSeen = true; + if (Enum.TryParse(reader.ReadContentAsString(), true, out ThemeMode parsedMode) && + Enum.IsDefined(typeof(ThemeMode), parsedMode)) + { + ThemeMode = parsedMode; + } + break; + } + } + + if (!themeModeAttributeSeen) + { + reader.MoveToElement(); + for (int i = 0; i < reader.AttributeCount; ++i) + { + reader.MoveToAttribute(i); + if (reader.Name == "darkmode") + { + ThemeMode = bool.TryParse(reader.ReadContentAsString(), out var darkMode) && darkMode + ? ThemeMode.Dark + : ThemeMode.Light; break; + } } } } diff --git a/src/Tailviewer/Ui/LogView/AbstractDataSourceViewModel.cs b/src/Tailviewer/Ui/LogView/AbstractDataSourceViewModel.cs index 7dd1f39c..5bf9fe4f 100644 --- a/src/Tailviewer/Ui/LogView/AbstractDataSourceViewModel.cs +++ b/src/Tailviewer/Ui/LogView/AbstractDataSourceViewModel.cs @@ -513,6 +513,7 @@ sealed class FindAllViewModel private bool _isEmpty; private string _errorMessage; private IEnumerable _selectedFindAllLogLines; + private int _count; public FindAllViewModel(AbstractDataSourceViewModel dataSourceViewModel, IDataSource dataSource) { @@ -551,6 +552,24 @@ public IEnumerable SelectedLogLines public ILogSourceSearch Search => _dataSource.FindAllSearch; + public int Count + { + get { return _count; } + private set + { + if (value == _count) + return; + + _count = value; + EmitPropertyChanged(); + } + } + + public void Update() + { + Count = _dataSource.FindAllSearch?.Count ?? 0; + } + public string SearchTerm { get { return _dataSource.FindAllFilter; } @@ -799,6 +818,7 @@ public virtual void Update() NoTimestampCount = _dataSource.NoTimestampCount; LastWrittenAge = DateTime.Now - _dataSource.LastModified; _search.Update(); + _findAll.Update(); Progress = _dataSource.FilteredLogSource?.GetProperty(Properties.PercentageProcessed).RelativeValue ?? 1; if (NewLogLineCount != newBefore) diff --git a/src/Tailviewer/Ui/LogView/IFindAllViewModel.cs b/src/Tailviewer/Ui/LogView/IFindAllViewModel.cs index 017f341f..3359dc60 100644 --- a/src/Tailviewer/Ui/LogView/IFindAllViewModel.cs +++ b/src/Tailviewer/Ui/LogView/IFindAllViewModel.cs @@ -13,6 +13,7 @@ public interface IFindAllViewModel ILogSource LogSource { get; } ILogSourceSearch Search { get; } string SearchTerm { get; set; } + int Count { get; } bool Show { get; } string ErrorMessage { get; } bool IsEmpty { get; } diff --git a/src/Tailviewer/Ui/LogView/LogViewerControl.xaml b/src/Tailviewer/Ui/LogView/LogViewerControl.xaml index 9f210260..b0b6abe8 100644 --- a/src/Tailviewer/Ui/LogView/LogViewerControl.xaml +++ b/src/Tailviewer/Ui/LogView/LogViewerControl.xaml @@ -56,13 +56,21 @@ + + - @@ -73,6 +81,7 @@ + @@ -190,51 +199,35 @@ + + + + + + - - - - - - - - - - - - - - - + @@ -357,7 +350,7 @@ - - - diff --git a/src/Tailviewer/Ui/LogView/TextBrushes.cs b/src/Tailviewer/Ui/LogView/TextBrushes.cs index d2eebadd..7a8e3a4a 100644 --- a/src/Tailviewer/Ui/LogView/TextBrushes.cs +++ b/src/Tailviewer/Ui/LogView/TextBrushes.cs @@ -25,6 +25,21 @@ public sealed class TextBrushes public static readonly SolidColorBrush AlternatingBackgroundBrush; public static readonly SolidColorBrush SeparatorBrush; + private static readonly IReadOnlyList Levels = new[] + { + LevelFlags.Other, + LevelFlags.Trace, + LevelFlags.Debug, + LevelFlags.Info, + LevelFlags.Warning, + LevelFlags.Error, + LevelFlags.Fatal + }; + + private static readonly Dictionary LevelDefaultForegroundBrushes; + private static readonly Dictionary LevelDefaultBackgroundBrushes; + private static readonly Dictionary LevelDefaultAlternateBrushes; + private readonly Dictionary _foregroundBrushes; private readonly Dictionary _backgroundBrushes; private readonly Dictionary _alternateBackgroundBrushes; @@ -42,7 +57,7 @@ static TextBrushes() HighlightedSelectedForegroundBrush = Brushes.Black; HighlightedSelectedBackgroundBrush = CreateBrush(Color.FromRgb(255, 150, 50)); - + LineNumberForegroundBrush = CreateMutableBrush(UISettings.DefaultThemeColor); DataSourceFilenameForegroundBrush = CreateMutableBrush(Color.FromRgb(128, 128, 128)); @@ -53,6 +68,21 @@ static TextBrushes() DefaultBackgroundBrush = CreateMutableBrush(Colors.Transparent); AlternatingBackgroundBrush = CreateMutableBrush(Color.FromRgb(0xE8, 0xF1, 0xF7)); SeparatorBrush = CreateMutableBrush(Color.FromRgb(0xE1, 0xE4, 0xE8)); + + LevelDefaultForegroundBrushes = new Dictionary(); + LevelDefaultBackgroundBrushes = new Dictionary(); + LevelDefaultAlternateBrushes = new Dictionary(); + + var light = LogLevelDefaults.Light; + foreach (var level in Levels) + { + var defaults = light.Get(level); + LevelDefaultForegroundBrushes.Add(level, CreateMutableBrush(defaults.ForegroundColor)); + LevelDefaultBackgroundBrushes.Add(level, CreateMutableBrush(defaults.BackgroundColor)); + LevelDefaultAlternateBrushes.Add(level, defaults.BackgroundColor.A == 0 + ? AlternatingBackgroundBrush + : (Brush) LevelDefaultBackgroundBrushes[level]); + } } /// @@ -93,48 +123,45 @@ public static void UpdateNeutral(bool darkMode) } } + /// + /// Updates the mutable static default brushes used for non-custom levels to + /// the given theme's default palette. Custom levels are unaffected because + /// they never reference these brushes. + /// + public static void UpdateLevelDefaults(bool darkMode) + { + var defaults = LogLevelDefaults.For(darkMode); + foreach (var level in Levels) + { + var levelDefaults = defaults.Get(level); + LevelDefaultForegroundBrushes[level].Color = levelDefaults.ForegroundColor; + LevelDefaultBackgroundBrushes[level].Color = levelDefaults.BackgroundColor; + LevelDefaultAlternateBrushes[level] = levelDefaults.BackgroundColor.A == 0 + ? AlternatingBackgroundBrush + : (Brush) LevelDefaultBackgroundBrushes[level]; + } + } + public TextBrushes(ILogViewerSettings settings) { _foregroundBrushes = new Dictionary(); _backgroundBrushes = new Dictionary(); _alternateBackgroundBrushes = new Dictionary(); - if (settings != null) - { - _foregroundBrushes.Add(LevelFlags.Other, CreateBrush(settings.Other.ForegroundColor)); - _backgroundBrushes.Add(LevelFlags.Other, CreateBrush(settings.Other.BackgroundColor)); - _alternateBackgroundBrushes.Add(LevelFlags.Other, GetAlternatingBrush(settings.Other.BackgroundColor)); - _foregroundBrushes.Add(LevelFlags.Trace, CreateBrush(settings.Trace.ForegroundColor)); - _backgroundBrushes.Add(LevelFlags.Trace, CreateBrush(settings.Trace.BackgroundColor)); - _alternateBackgroundBrushes.Add(LevelFlags.Trace, GetAlternatingBrush(settings.Trace.BackgroundColor)); - - _foregroundBrushes.Add(LevelFlags.Debug, CreateBrush(settings.Debug.ForegroundColor)); - _backgroundBrushes.Add(LevelFlags.Debug, CreateBrush(settings.Debug.BackgroundColor)); - _alternateBackgroundBrushes.Add(LevelFlags.Debug, GetAlternatingBrush(settings.Debug.BackgroundColor)); - - _foregroundBrushes.Add(LevelFlags.Info, CreateBrush(settings.Info.ForegroundColor)); - _backgroundBrushes.Add(LevelFlags.Info, CreateBrush(settings.Info.BackgroundColor)); - _alternateBackgroundBrushes.Add(LevelFlags.Info, GetAlternatingBrush(settings.Info.BackgroundColor)); - - _foregroundBrushes.Add(LevelFlags.Warning, CreateBrush(settings.Warning.ForegroundColor)); - _backgroundBrushes.Add(LevelFlags.Warning, CreateBrush(settings.Warning.BackgroundColor)); - _alternateBackgroundBrushes.Add(LevelFlags.Warning, GetAlternatingBrush(settings.Warning.BackgroundColor)); - - _foregroundBrushes.Add(LevelFlags.Error, CreateBrush(settings.Error.ForegroundColor)); - _backgroundBrushes.Add(LevelFlags.Error, CreateBrush(settings.Error.BackgroundColor)); - _alternateBackgroundBrushes.Add(LevelFlags.Error, GetAlternatingBrush(settings.Error.BackgroundColor)); - - _foregroundBrushes.Add(LevelFlags.Fatal, CreateBrush(settings.Fatal.ForegroundColor)); - _backgroundBrushes.Add(LevelFlags.Fatal, CreateBrush(settings.Fatal.BackgroundColor)); - _alternateBackgroundBrushes.Add(LevelFlags.Fatal, GetAlternatingBrush(settings.Fatal.BackgroundColor)); - } - else + foreach (var level in Levels) { - foreach (LevelFlags level in Enum.GetValues(typeof(LevelFlags))) + var levelSettings = settings != null ? GetLevelSettings(settings, level) : null; + if (levelSettings != null && levelSettings.IsCustom) { - _foregroundBrushes.Add(level, Brushes.Black); - _backgroundBrushes.Add(level, Brushes.White); - _alternateBackgroundBrushes.Add(level, Brushes.White); + _foregroundBrushes.Add(level, CreateBrush(levelSettings.ForegroundColor)); + _backgroundBrushes.Add(level, CreateBrush(levelSettings.BackgroundColor)); + _alternateBackgroundBrushes.Add(level, GetAlternatingBrush(levelSettings.BackgroundColor)); + } + else + { + _foregroundBrushes.Add(level, LevelDefaultForegroundBrushes[level]); + _backgroundBrushes.Add(level, LevelDefaultBackgroundBrushes[level]); + _alternateBackgroundBrushes.Add(level, LevelDefaultAlternateBrushes[level]); } } } @@ -211,5 +238,19 @@ private static Brush GetAlternatingBrush(Color color) return CreateBrush(color); } + + private static LogLevelSettings GetLevelSettings(ILogViewerSettings settings, LevelFlags level) + { + switch (level) + { + case LevelFlags.Trace: return settings.Trace; + case LevelFlags.Debug: return settings.Debug; + case LevelFlags.Info: return settings.Info; + case LevelFlags.Warning: return settings.Warning; + case LevelFlags.Error: return settings.Error; + case LevelFlags.Fatal: return settings.Fatal; + default: return settings.Other; + } + } } } \ No newline at end of file diff --git a/src/Tailviewer/Ui/Settings/LogLevelSettingsViewModel.cs b/src/Tailviewer/Ui/Settings/LogLevelSettingsViewModel.cs index 17ff04ac..d321d70e 100644 --- a/src/Tailviewer/Ui/Settings/LogLevelSettingsViewModel.cs +++ b/src/Tailviewer/Ui/Settings/LogLevelSettingsViewModel.cs @@ -1,7 +1,9 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Windows.Media; +using Tailviewer.Api; using Tailviewer.Settings; +using Tailviewer.Ui; namespace Tailviewer.Ui.Settings { @@ -10,44 +12,89 @@ public sealed class LogLevelSettingsViewModel { private readonly IApplicationSettings _settings; private readonly LogLevelSettings _logLevelSettings; + private readonly LevelFlags _level; - public LogLevelSettingsViewModel(IApplicationSettings settings, LogLevelSettings logLevelSettings) + public LogLevelSettingsViewModel(IApplicationSettings settings, LogLevelSettings logLevelSettings, LevelFlags level) { _settings = settings; _logLevelSettings = logLevelSettings; + _level = level; + + ThemeManager.ThemeChanged += OnThemeChanged; } public event PropertyChangedEventHandler PropertyChanged; public Color ForegroundColor { - get { return _logLevelSettings.ForegroundColor; } + get + { + return _logLevelSettings.IsCustom + ? _logLevelSettings.ForegroundColor + : LogLevelDefaults.For(ThemeManager.CurrentDarkMode).Get(_level).ForegroundColor; + } set { - if (value == _logLevelSettings.ForegroundColor) - return; + if (_logLevelSettings.IsCustom) + { + if (value == _logLevelSettings.ForegroundColor) + return; - _logLevelSettings.ForegroundColor = value; - EmitPropertyChanged(); - - _settings.SaveAsync(); + _logLevelSettings.ForegroundColor = value; + EmitPropertyChanged(); + _settings.SaveAsync(); + } + else + { + var defaults = LogLevelDefaults.For(ThemeManager.CurrentDarkMode).Get(_level); + _logLevelSettings.ForegroundColor = defaults.ForegroundColor; + _logLevelSettings.BackgroundColor = defaults.BackgroundColor; + _logLevelSettings.ForegroundColor = value; + _logLevelSettings.IsCustom = true; + EmitPropertyChanged(); + _settings.SaveAsync(); + } } } + public Color BackgroundColor { - get { return _logLevelSettings.BackgroundColor; } + get + { + return _logLevelSettings.IsCustom + ? _logLevelSettings.BackgroundColor + : LogLevelDefaults.For(ThemeManager.CurrentDarkMode).Get(_level).BackgroundColor; + } set { - if (value == _logLevelSettings.BackgroundColor) - return; - - _logLevelSettings.BackgroundColor = value; - EmitPropertyChanged(); + if (_logLevelSettings.IsCustom) + { + if (value == _logLevelSettings.BackgroundColor) + return; - _settings.SaveAsync(); + _logLevelSettings.BackgroundColor = value; + EmitPropertyChanged(); + _settings.SaveAsync(); + } + else + { + var defaults = LogLevelDefaults.For(ThemeManager.CurrentDarkMode).Get(_level); + _logLevelSettings.ForegroundColor = defaults.ForegroundColor; + _logLevelSettings.BackgroundColor = defaults.BackgroundColor; + _logLevelSettings.BackgroundColor = value; + _logLevelSettings.IsCustom = true; + EmitPropertyChanged(); + _settings.SaveAsync(); + } } } + private void OnThemeChanged(bool darkMode) + { + EmitPropertyChanged(nameof(ForegroundColor)); + EmitPropertyChanged(nameof(BackgroundColor)); + } + private void EmitPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); diff --git a/src/Tailviewer/Ui/Settings/SettingsControl.xaml b/src/Tailviewer/Ui/Settings/SettingsControl.xaml index e29975d2..7a1e5f28 100644 --- a/src/Tailviewer/Ui/Settings/SettingsControl.xaml +++ b/src/Tailviewer/Ui/Settings/SettingsControl.xaml @@ -332,13 +332,15 @@ Margin="0,6,6,6" SelectedColor="{Binding ThemeColor}" AvailableColorsSortingMode="HueSaturationBrightness" /> - + ItemsSource="{Binding ThemeModes}" + DisplayMemberPath="DisplayName" + SelectedValuePath="Value" + SelectedValue="{Binding ThemeMode, Mode=TwoWay}" /> diff --git a/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs b/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs index cbf8c460..a9ff58c4 100644 --- a/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs +++ b/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs @@ -78,13 +78,13 @@ public SettingsFlyoutViewModel(IApplicationSettings applicationSettings, _defaultTextFileEncoding = @default; } - _otherLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Other); - _traceLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Trace); - _debugLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Debug); - _infoLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Info); - _warnLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Warning); - _errorLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Error); - _fatalLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Fatal); + _otherLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Other, LevelFlags.Other); + _traceLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Trace, LevelFlags.Trace); + _debugLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Debug, LevelFlags.Debug); + _infoLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Info, LevelFlags.Info); + _warnLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Warning, LevelFlags.Warning); + _errorLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Error, LevelFlags.Error); + _fatalLevel = new LogLevelSettingsViewModel(_settings, applicationSettings.LogViewer.Fatal, LevelFlags.Fatal); _customFormats = new CustomFormatsSettingsViewModel(_settings, serviceContainer, Encodings); _language = Languages.FirstOrDefault(x => string.Equals(x.Code, applicationSettings.Ui.Language, StringComparison.InvariantCultureIgnoreCase)) ?? Languages.First(); } @@ -243,20 +243,27 @@ public int TabWidth } } - public bool DarkMode + public IReadOnlyList ThemeModes { get; } = new[] { - get { return _settings.Ui.DarkMode; } + new ThemeModeOption(ThemeMode.Light, Strings.ThemeModeLight), + new ThemeModeOption(ThemeMode.Dark, Strings.ThemeModeDark), + new ThemeModeOption(ThemeMode.System, Strings.ThemeModeSystem) + }; + + public ThemeMode ThemeMode + { + get { return _settings.Ui.ThemeMode; } set { - if (value == _settings.Ui.DarkMode) + if (value == _settings.Ui.ThemeMode) return; - _settings.Ui.DarkMode = value; + _settings.Ui.ThemeMode = value; EmitPropertyChanged(); _settings.SaveAsync(); - // Defer the theme application until the toggle has finished updating, + // Defer the theme application until the selection has finished updating, // otherwise mutating the application resources during the toggle's own // callback re-enters the layout/rendering pass. var dispatcher = Application.Current?.Dispatcher; @@ -290,11 +297,11 @@ public Color ThemeColor var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null) { - dispatcher.BeginInvoke(new Action(() => ThemeManager.Apply(value, _settings.Ui.DarkMode))); + dispatcher.BeginInvoke(new Action(() => ThemeManager.Apply(value, _settings.Ui.ThemeMode))); } else { - ThemeManager.Apply(value, _settings.Ui.DarkMode); + ThemeManager.Apply(value, _settings.Ui.ThemeMode); } } } diff --git a/src/Tailviewer/Ui/Settings/ThemeModeOption.cs b/src/Tailviewer/Ui/Settings/ThemeModeOption.cs new file mode 100644 index 00000000..27b4aedc --- /dev/null +++ b/src/Tailviewer/Ui/Settings/ThemeModeOption.cs @@ -0,0 +1,20 @@ +using Tailviewer.Settings; + +namespace Tailviewer.Ui.Settings +{ + /// + /// A single selectable theme mode in the settings flyout. + /// + public sealed class ThemeModeOption + { + public ThemeModeOption(ThemeMode value, string displayName) + { + Value = value; + DisplayName = displayName; + } + + public ThemeMode Value { get; } + + public string DisplayName { get; } + } +} diff --git a/src/Tailviewer/Ui/SidePanel/Property/PropertiesSidePanelDataTemplate.xaml b/src/Tailviewer/Ui/SidePanel/Property/PropertiesSidePanelDataTemplate.xaml index ca806f34..e4569e89 100644 --- a/src/Tailviewer/Ui/SidePanel/Property/PropertiesSidePanelDataTemplate.xaml +++ b/src/Tailviewer/Ui/SidePanel/Property/PropertiesSidePanelDataTemplate.xaml @@ -10,7 +10,7 @@ - + @@ -19,7 +19,7 @@ @@ -37,11 +37,13 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 10 +- **Total Sessions**: 11 - **Last Active**: 2026-08-23 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~350 | Active | +| `journal-1.md` | ~383 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 11 | 2026-08-23 | 重做主题、日志等级配色与日志布局 | `77d7d632` | `ui` | | 10 | 2026-08-23 | 完成 Fluent 控件视觉化 | `80f3e582` | `ui` | | 9 | 2026-08-23 | 完成深色模式主题管线 | `b5999cdb`, `beddd227` | `ui` | | 8 | 2026-08-23 | 完成语义化 UI 与主界面重排 | `712ef7d1` | `ui` | diff --git a/.trellis/workspace/brofea/journal-1.md b/.trellis/workspace/brofea/journal-1.md index bbf79f63..a06662f2 100644 --- a/.trellis/workspace/brofea/journal-1.md +++ b/.trellis/workspace/brofea/journal-1.md @@ -348,3 +348,36 @@ Re-themed all remaining hardcoded Metrolib blue accents (#3998D6 family) to the ### Next Steps - None - task complete + + +## Session 11: 重做主题、日志等级配色与日志布局 + +**Date**: 2026-08-23 +**Task**: 重做主题、日志等级配色与日志布局 +**Branch**: `ui` + +### Summary + +完成浅色/深色/跟随系统三态主题、双套日志等级配色与自定义保留、默认强调色重设计、属性页文字修复及顶部统一搜索框布局。OpenCode DeepSeek V4 Pro Max 完成最终审查;构建 0 warning/0 error,受影响测试 189 通过、0 失败、3 个既有忽略。归档父任务及三个子任务。 + +### Main Changes + +- Detailed change bullets were not supplied; see the summary above. + +### Git Commits + +| Hash | Message | +|------|---------| +| `77d7d632` | (see git log) | + +### Testing + +- Validation was not recorded for this session. + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From f75f44f86abed07b9b371c492e626149cc8d3e77 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 12:34:31 +0800 Subject: [PATCH 18/28] feat(ui): add independent log level palettes --- .trellis/spec/ui/theming.md | 85 ++++++---- .../Settings/UISettingsTest.cs | 62 ++++++- .../Ui/Controls/TextLineTest.cs | 4 +- .../Settings/LogLevelSettingsViewModelTest.cs | 18 +- src/Tailviewer.Tests/Ui/TextBrushesTest.cs | 132 +++++++-------- src/Tailviewer.Tests/Ui/ThemeManagerTest.cs | 53 ++++++ src/Tailviewer/App.cs | 2 +- src/Tailviewer/Localization/Strings.cs | 1 + src/Tailviewer/Localization/Strings.resx | 3 + .../Localization/Strings.zh-CN.resx | 3 + src/Tailviewer/Settings/LogLevelDefaults.cs | 5 + src/Tailviewer/Settings/LogLevelPalette.cs | 19 +++ src/Tailviewer/Settings/UISettings.cs | 26 ++- src/Tailviewer/Ui/LogView/LogEntryListView.cs | 31 +++- src/Tailviewer/Ui/LogView/TextBrushes.cs | 159 +++++++----------- .../Ui/Settings/LogLevelPaletteOption.cs | 20 +++ .../Ui/Settings/LogLevelSettingsViewModel.cs | 10 +- .../Ui/Settings/SettingsControl.xaml | 14 ++ .../Ui/Settings/SettingsFlyoutViewModel.cs | 31 ++++ src/Tailviewer/Ui/ThemeManager.cs | 47 ++++-- tools/generate_localization.py | 1 + 21 files changed, 479 insertions(+), 247 deletions(-) create mode 100644 src/Tailviewer/Settings/LogLevelPalette.cs create mode 100644 src/Tailviewer/Ui/Settings/LogLevelPaletteOption.cs diff --git a/.trellis/spec/ui/theming.md b/.trellis/spec/ui/theming.md index 1041e665..4e55a8d5 100644 --- a/.trellis/spec/ui/theming.md +++ b/.trellis/spec/ui/theming.md @@ -8,36 +8,44 @@ Tailviewer's accent/theme color is user-configurable and updates the whole UI li ## Architecture ``` -UISettings.ThemeColor + ThemeMode -> ThemeManager.Apply(Color, ThemeMode) - | ResolveDarkMode (System -> provider) - v - Apply(Color, bool) -> Application.Current.Resources - | ["Primary", "PrimaryLight", ...] - +--> TextBrushes.UpdateTheme/UpdateNeutral - +--> TextBrushes.UpdateLevelDefaults - +--> ThemeChanged -> settings ColorPickers - | - v - XAML brushes bind via {DynamicResource} +UISettings.ThemeColor + ThemeMode + LogLevelPalette + -> ThemeManager.Apply(Color, ThemeMode, LogLevelPalette) + | ResolveDarkMode (System -> provider) + v + ApplyCore(Color, bool) -> Application.Current.Resources + | ["Primary", "PrimaryLight", ...] + +--> TextBrushes.UpdateTheme/UpdateNeutral + +--> ThemeChanged + | | + v v + LogEntryListView rebuilds settings ColorPickers + palette-aware TextBrushes + | + v + XAML brushes bind via {DynamicResource} ``` - `src/Tailviewer/Ui/ThemePalette.cs` — pure helper `ThemePalette.Compute(Color)` derives the shade palette (light / lighter / dark / separator) by blending the base toward white/black. Pure & unit-testable (`ThemePaletteTest`). -- `src/Tailviewer/Ui/ThemeManager.cs` — `Apply(Color, ThemeMode)` resolves the - effective light/dark state and delegates to `Apply(Color, bool)`. The bool overload - writes the palette into - `Application.Current.Resources` under the well-known `Color` keys, then calls - `TextBrushes.UpdateTheme`, `UpdateNeutral`, and `UpdateLevelDefaults`, then raises - `ThemeChanged`. Resource publication is a no-op when `Application.Current` is null - (tests / design time), but brush updates and the event still run. +- `src/Tailviewer/Ui/ThemeManager.cs` — `Apply(Color, ThemeMode, LogLevelPalette)` is + the startup and palette-selection entry point. `ThemeMode` controls the neutral UI + (Light / Dark / System), while `LogLevelPalette` independently selects the log-level + defaults (Light / Dark). `Apply(Color, ThemeMode)` preserves the current log palette; + this is used for accent changes and system-theme transitions. All overloads publish + the semantic resources, update the shared brushes, and raise `ThemeChanged`. + Resource publication is a no-op when `Application.Current` is null (tests / design + time), but brush updates and the event still run. - `src/Tailviewer/Themes/Constants.xaml` — defines `SolidColorBrush` keys (`PrimaryBrush`, `SecondaryBrush`, …) whose `Color` is `{DynamicResource Primary}` etc. The `Color` keys themselves live **only** at application scope, set by `ThemeManager`. - `src/Tailviewer/Ui/LogView/TextBrushes.cs` — static brushes used by `FormattedText` - (line numbers, character code, selection). The three theme-derived brushes are mutable - `SolidColorBrush`es updated by `UpdateTheme(Color)`. Non-custom log-level brushes are - another static mutable group updated by `UpdateLevelDefaults(bool)`. + (line numbers, character code, selection). Every shared brush is frozen and + `UpdateTheme(Color)` / `UpdateNeutral(bool)` replace the references instead of + mutating a `SolidColorBrush`; this prevents WPF cross-thread ownership failures. + Each `TextBrushes` instance receives the active `LogLevelPalette` and owns frozen + level brushes. `LogEntryListView` rebuilds the instance and visible lines after + `ThemeChanged`. ## Theme mode and system provider @@ -67,10 +75,22 @@ The exact pairs are: `LogLevelSettings.IsCustom` is persisted as `iscustom`. Legacy level nodes without that attribute are custom when their stored pair differs from the new Light pair. A non-custom -level references the static mutable default brush, while a custom level owns frozen brushes. -When a settings ColorPicker changes only one field for the first time, its ViewModel must -seed the other stored field from the current effective theme default before setting -`IsCustom=true`; otherwise a stale Light value can leak into Dark mode. +level gets frozen brushes from the active `LogLevelPalette`; a custom level owns frozen +brushes from its stored colors. When a settings ColorPicker changes only one field for +the first time, its ViewModel must seed the other stored field from the current effective +palette default before setting `IsCustom=true`; otherwise a stale Light value can leak +into the selected palette. + +### Independent log-level palette contract + +- `UISettings.Save` writes `loglevelpalette` as the defined enum name (`Light` or `Dark`). +- `UISettings.Restore` accepts only defined values; an invalid present value falls back + to the constructor default (`Light`). If the attribute is absent, an explicitly dark + `thememode` migrates to `LogLevelPalette.Dark`; Light and System migrate to Light. +- `ThemeManager.Apply(Color, ThemeMode, LogLevelPalette)` updates both selectors. + `ThemeManager.Apply(Color, ThemeMode)` must not overwrite `CurrentLogLevelPalette`. +- Changing the palette updates an existing log viewer through `ThemeChanged`; changing + the neutral theme does not silently change the selected log palette. ## Rules @@ -126,13 +146,14 @@ those resource keys at app scope does **not** reach them. To re-theme an externa > **Warning: `{x:Static TextBrushes.Xxx}` freezes the brush.** > > WPF freezes a `Freezable` (SolidColorBrush) when it is assigned through a -> `{x:Static}` reference on a dependency property. A frozen brush is read-only, so a -> later `TextBrushes.UpdateTheme` that sets `.Color` throws -> `InvalidOperationException` ("cannot set a property on a read-only object"). +> `{x:Static}` reference on a dependency property. A frozen brush must never be +> mutated in place. All shared `TextBrushes` updates therefore create and assign a +> new frozen brush, so a later theme change cannot read or write a brush owned by +> another thread. > > - Theme-derived brushes that must change at runtime: bind in XAML via -> `{DynamicResource PrimaryBrush}` (application-scope resource), OR keep them as -> C#-only mutable brushes consumed by `FormattedText` (which does not freeze them). +> `{DynamicResource PrimaryBrush}` (application-scope resource), or replace the +> C# brush reference with a newly created frozen brush before redrawing. > - Never expose a theme-derived brush through `{x:Static}` in XAML. > **Warning: `DynamicResource` on a brush `Color` only works if the `Color` key is not @@ -144,8 +165,8 @@ those resource keys at app scope does **not** reach them. To re-theme an externa - `Ui/ThemePaletteTest` — palette derivation (base == primary, fixed foregrounds, deterministic shades). - `Ui/TextBrushesTest` — defaults follow `UISettings.DefaultThemeColor`; `UpdateTheme` - recolors the three derived brushes; level defaults flip in place and custom brushes - remain unchanged. + replaces frozen derived brushes; light/dark level palette selection creates the + expected frozen defaults and custom brushes remain unchanged. - `Settings/UISettingsTest` — `#0F62FE` default, three-mode save/restore, legacy `darkmode` migration, invalid-value fallback, clone. - `Settings/LogLevelDefaultsTest` and `Settings/LogViewerSettingsTest` — exact Light/Dark diff --git a/src/Tailviewer.Tests/Settings/UISettingsTest.cs b/src/Tailviewer.Tests/Settings/UISettingsTest.cs index 9e5f8c6b..a0bed9a4 100644 --- a/src/Tailviewer.Tests/Settings/UISettingsTest.cs +++ b/src/Tailviewer.Tests/Settings/UISettingsTest.cs @@ -46,6 +46,7 @@ public void TestConstruction() settings.Language.Should().Be(UISettings.DefaultLanguage); settings.ThemeColor.Should().Be(UISettings.DefaultThemeColor); settings.ThemeMode.Should().Be(ThemeMode.Light); + settings.LogLevelPalette.Should().Be(LogLevelPalette.Light); } [Test] @@ -61,7 +62,8 @@ public void TestClone() { Language = "zh-CN", ThemeColor = Colors.Red, - ThemeMode = ThemeMode.System + ThemeMode = ThemeMode.System, + LogLevelPalette = LogLevelPalette.Dark }; var clone = settings.Clone(); @@ -69,6 +71,7 @@ public void TestClone() clone.Language.Should().Be("zh-CN"); clone.ThemeColor.Should().Be(Colors.Red); clone.ThemeMode.Should().Be(ThemeMode.System); + clone.LogLevelPalette.Should().Be(LogLevelPalette.Dark); } [Test] @@ -78,13 +81,15 @@ public void TestRoundtrip() { Language = "zh-CN", ThemeColor = Color.FromRgb(0x12, 0x34, 0x56), - ThemeMode = ThemeMode.Dark + ThemeMode = ThemeMode.Dark, + LogLevelPalette = LogLevelPalette.Dark }; var restored = Restore(Save(settings)); restored.Language.Should().Be("zh-CN"); restored.ThemeColor.Should().Be(Color.FromRgb(0x12, 0x34, 0x56)); restored.ThemeMode.Should().Be(ThemeMode.Dark); + restored.LogLevelPalette.Should().Be(LogLevelPalette.Dark); } [Test] @@ -94,6 +99,7 @@ public void TestRestoreFromEmpty() restored.Language.Should().Be(UISettings.DefaultLanguage); restored.ThemeColor.Should().Be(UISettings.DefaultThemeColor); restored.ThemeMode.Should().Be(ThemeMode.Light); + restored.LogLevelPalette.Should().Be(LogLevelPalette.Light); } [Test] @@ -121,23 +127,63 @@ public void TestRestoreFromInvalidThemeMode() [Test] public void TestRestoreInvalidThemeModeDoesNotFallBackToLegacyDarkMode() { - // An invalid thememode attribute must NOT trigger the legacy darkmode migration, - // even when a valid darkmode attribute is also present. Restore("").ThemeMode.Should().Be(ThemeMode.Light); Restore("").ThemeMode.Should().Be(ThemeMode.Light); } [Test] - public void TestRestoreFromLegacyDarkMode() + public void TestRestoreLogLevelPalette() { - Restore("").ThemeMode.Should().Be(ThemeMode.Dark); - Restore("").ThemeMode.Should().Be(ThemeMode.Light); + Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Dark); + Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Light); + Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Light); + } + + [Test] + public void TestRestoreFromInvalidLogLevelPalette() + { + Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Light); + Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Light); + } + + [Test] + public void TestRestoreFromLegacyDarkModeSelectsDarkPalette() + { + var settings = Restore(""); + settings.ThemeMode.Should().Be(ThemeMode.Dark); + settings.LogLevelPalette.Should().Be(LogLevelPalette.Dark); + } + + [Test] + public void TestRestoreFromLegacyLightModeSelectsLightPalette() + { + var settings = Restore(""); + settings.ThemeMode.Should().Be(ThemeMode.Light); + settings.LogLevelPalette.Should().Be(LogLevelPalette.Light); } [Test] public void TestRestoreFromInvalidLegacyDarkMode() { - Restore("").ThemeMode.Should().Be(ThemeMode.Light); + var settings = Restore(""); + settings.ThemeMode.Should().Be(ThemeMode.Light); + settings.LogLevelPalette.Should().Be(LogLevelPalette.Light); + } + + [Test] + public void TestRestoreMissingPaletteWithDarkThemeSelectsDarkPalette() + { + var settings = Restore(""); + settings.ThemeMode.Should().Be(ThemeMode.Dark); + settings.LogLevelPalette.Should().Be(LogLevelPalette.Dark); + } + + [Test] + public void TestRestoreMissingPaletteWithSystemThemeSelectsLightPalette() + { + var settings = Restore(""); + settings.ThemeMode.Should().Be(ThemeMode.System); + settings.LogLevelPalette.Should().Be(LogLevelPalette.Light); } } } diff --git a/src/Tailviewer.Tests/Ui/Controls/TextLineTest.cs b/src/Tailviewer.Tests/Ui/Controls/TextLineTest.cs index e4795725..0605b505 100644 --- a/src/Tailviewer.Tests/Ui/Controls/TextLineTest.cs +++ b/src/Tailviewer.Tests/Ui/Controls/TextLineTest.cs @@ -191,7 +191,7 @@ public void TestForegroundBrush1() BrushColor(textLine.ForegroundBrush).Should().Be(LogViewerSettings.DefaultTrace.ForegroundColor); textLine = new TextLine(CreateLogEntry(0, 0, "foobar", LevelFlags.Other), _hovered, _selected, true, textSettings, textBrushes); - BrushColor(textLine.ForegroundBrush).Should().Be(LogViewerSettings.DefaultInfo.ForegroundColor); + BrushColor(textLine.ForegroundBrush).Should().Be(LogViewerSettings.DefaultOther.ForegroundColor); } [Test] @@ -231,7 +231,7 @@ public void TestBackgroundBrush1() BrushColor(textLine.BackgroundBrush).Should().Be(LogViewerSettings.DefaultDebug.BackgroundColor); textLine = new TextLine(CreateLogEntry(0, 0, "foobar", LevelFlags.Other), _hovered, _selected, true, textSettings, textBrushes); - BrushColor(textLine.BackgroundBrush).Should().Be(LogViewerSettings.DefaultTrace.BackgroundColor); + BrushColor(textLine.BackgroundBrush).Should().Be(LogViewerSettings.DefaultOther.BackgroundColor); } /* [Test] diff --git a/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs b/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs index 176e76ab..999058c2 100644 --- a/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs +++ b/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs @@ -17,7 +17,7 @@ public sealed class LogLevelSettingsViewModelTest [Test] public void TestForegroundOnlyChangeInDarkSeedsDarkDefaults() { - ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: true); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); try { var levelSettings = new LogLevelSettings(); @@ -36,14 +36,14 @@ public void TestForegroundOnlyChangeInDarkSeedsDarkDefaults() } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); } } [Test] public void TestBackgroundOnlyChangeInDarkSeedsDarkDefaults() { - ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: true); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); try { var levelSettings = new LogLevelSettings(); @@ -59,14 +59,14 @@ public void TestBackgroundOnlyChangeInDarkSeedsDarkDefaults() } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); } } [Test] public void TestForegroundOnlyChangeInLightSeedsLightDefaults() { - ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); try { var levelSettings = new LogLevelSettings(); @@ -82,7 +82,7 @@ public void TestForegroundOnlyChangeInLightSeedsLightDefaults() } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); } } @@ -108,7 +108,7 @@ public void TestAlreadyCustomSetForegroundPreservesBackground() [Test] public void TestTransitionCallsSaveAsync() { - ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: true); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); try { var levelSettings = new LogLevelSettings(); @@ -121,8 +121,8 @@ public void TestTransitionCallsSaveAsync() } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, darkMode: false); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); } } } -} \ No newline at end of file +} diff --git a/src/Tailviewer.Tests/Ui/TextBrushesTest.cs b/src/Tailviewer.Tests/Ui/TextBrushesTest.cs index 713b22e3..f5be1375 100644 --- a/src/Tailviewer.Tests/Ui/TextBrushesTest.cs +++ b/src/Tailviewer.Tests/Ui/TextBrushesTest.cs @@ -53,30 +53,38 @@ public void TestUpdateNeutral() TextBrushes.DataSourceFilenameForegroundBrush.Color.Should().Be(Color.FromRgb(0x80, 0x80, 0x80)); } + [Test] + public void TestDefaultBrushesAreFrozen() + { + TextBrushes.CanvasBackgroundBrush.IsFrozen.Should().BeTrue(); + TextBrushes.DefaultForegroundBrush.IsFrozen.Should().BeTrue(); + TextBrushes.SelectedBackgroundBrush.IsFrozen.Should().BeTrue(); + + var brushes = new TextBrushes(new LogViewerSettings(), LogLevelPalette.Dark); + brushes.ForegroundBrush(false, false, true, LevelFlags.Warning).IsFrozen.Should().BeTrue(); + brushes.BackgroundBrush(false, false, true, LevelFlags.Warning, 0).IsFrozen.Should().BeTrue(); + } + [Test] public void TestSelectedUnfocusedForegroundUsesDefaultBrush() { + TextBrushes.UpdateNeutral(false); var brushes = new TextBrushes(new LogViewerSettings()); - try - { - TextBrushes.UpdateNeutral(false); - var light = (SolidColorBrush) brushes.ForegroundBrush(true, false, false, LevelFlags.Info); - light.Should().BeSameAs(TextBrushes.DefaultForegroundBrush); - light.Color.Should().Be(Colors.Black); - - TextBrushes.UpdateNeutral(true); - light.Should().BeSameAs(TextBrushes.DefaultForegroundBrush); - light.Color.Should().Be(Color.FromRgb(0xDC, 0xDC, 0xDC)); - - var info = (SolidColorBrush) brushes.ForegroundBrush(true, false, true, LevelFlags.Info); - info.Should().NotBeSameAs(TextBrushes.DefaultForegroundBrush); - info.Color.Should().Be(LogViewerSettings.DefaultInfo.ForegroundColor); - } - finally - { - TextBrushes.UpdateNeutral(false); - } + var light = (SolidColorBrush) brushes.ForegroundBrush(true, false, false, LevelFlags.Info); + light.Should().BeSameAs(TextBrushes.DefaultForegroundBrush); + light.Color.Should().Be(Colors.Black); + + TextBrushes.UpdateNeutral(true); + var dark = (SolidColorBrush) brushes.ForegroundBrush(true, false, false, LevelFlags.Info); + dark.Should().BeSameAs(TextBrushes.DefaultForegroundBrush); + dark.Color.Should().Be(Color.FromRgb(0xDC, 0xDC, 0xDC)); + + var info = (SolidColorBrush) brushes.ForegroundBrush(true, false, true, LevelFlags.Info); + info.Should().NotBeSameAs(TextBrushes.DefaultForegroundBrush); + info.Color.Should().Be(LogViewerSettings.DefaultInfo.ForegroundColor); + + TextBrushes.UpdateNeutral(false); } [Test] @@ -85,73 +93,59 @@ public void TestAlternateBackgroundFollowsNeutralTheme() var settings = new LogViewerSettings(); settings.Warning.IsCustom = true; settings.Warning.BackgroundColor = Color.FromRgb(0x10, 0x20, 0x30); - var brushes = new TextBrushes(settings); - try - { - TextBrushes.UpdateNeutral(false); + TextBrushes.UpdateNeutral(false); + var brushesLight = new TextBrushes(settings); + + var defaultAlternating = brushesLight.BackgroundBrush(false, false, true, LevelFlags.Info, 1); + defaultAlternating.Should().BeSameAs(TextBrushes.AlternatingBackgroundBrush); + ((SolidColorBrush) defaultAlternating).Color.Should().Be(Color.FromRgb(0xE8, 0xF1, 0xF7)); - var defaultAlternating = brushes.BackgroundBrush(false, false, true, LevelFlags.Info, 1); - defaultAlternating.Should().BeSameAs(TextBrushes.AlternatingBackgroundBrush); - ((SolidColorBrush) defaultAlternating).Color.Should().Be(Color.FromRgb(0xE8, 0xF1, 0xF7)); + var userAlternating = brushesLight.BackgroundBrush(false, false, true, LevelFlags.Warning, 1); + + TextBrushes.UpdateNeutral(true); + var brushesDark = new TextBrushes(settings); - var userAlternating = brushes.BackgroundBrush(false, false, true, LevelFlags.Warning, 1); + var defaultAlternatingDark = brushesDark.BackgroundBrush(false, false, true, LevelFlags.Info, 1); + defaultAlternatingDark.Should().BeSameAs(TextBrushes.AlternatingBackgroundBrush); + ((SolidColorBrush) defaultAlternatingDark).Color.Should().Be(Color.FromRgb(0x25, 0x25, 0x26)); - TextBrushes.UpdateNeutral(true); + ((SolidColorBrush) userAlternating).Color.Should().Be(Color.FromRgb(0x10, 0x20, 0x30)); - ((SolidColorBrush) defaultAlternating).Color.Should().Be(Color.FromRgb(0x25, 0x25, 0x26)); - ((SolidColorBrush) userAlternating).Color.Should().Be(Color.FromRgb(0x10, 0x20, 0x30)); - } - finally - { - TextBrushes.UpdateNeutral(false); - } + TextBrushes.UpdateNeutral(false); } [Test] - public void TestNonCustomLevelBrushFollowsTheme() + public void TestNonCustomLevelBrushUsesPalette() { - var brushes = new TextBrushes(new LogViewerSettings()); + var lightBrushes = new TextBrushes(new LogViewerSettings(), LogLevelPalette.Light); + ((SolidColorBrush) lightBrushes.ForegroundBrush(false, false, true, LevelFlags.Info)).Color + .Should().Be(LogLevelDefaults.Light.Info.ForegroundColor); + + var darkBrushes = new TextBrushes(new LogViewerSettings(), LogLevelPalette.Dark); + ((SolidColorBrush) darkBrushes.ForegroundBrush(false, false, true, LevelFlags.Info)).Color + .Should().Be(LogLevelDefaults.Dark.Info.ForegroundColor); - try - { - TextBrushes.UpdateLevelDefaults(false); - var info = (SolidColorBrush) brushes.ForegroundBrush(false, false, true, LevelFlags.Info); - info.Color.Should().Be(LogLevelDefaults.Light.Info.ForegroundColor); - - TextBrushes.UpdateLevelDefaults(true); - info.Color.Should().Be(LogLevelDefaults.Dark.Info.ForegroundColor); - - brushes.ForegroundBrush(false, false, true, LevelFlags.Info).Should().BeSameAs(info); - } - finally - { - TextBrushes.UpdateLevelDefaults(false); - } + var defaultBrushes = new TextBrushes(new LogViewerSettings()); + ((SolidColorBrush) defaultBrushes.ForegroundBrush(false, false, true, LevelFlags.Info)).Color + .Should().Be(LogLevelDefaults.Light.Info.ForegroundColor); } [Test] - public void TestCustomLevelBrushIsPreservedAcrossTheme() + public void TestCustomLevelBrushIsPreservedAcrossPalette() { var settings = new LogViewerSettings(); settings.Info.IsCustom = true; settings.Info.ForegroundColor = Color.FromRgb(0x11, 0x22, 0x33); settings.Info.BackgroundColor = Color.FromRgb(0x44, 0x55, 0x66); - var brushes = new TextBrushes(settings); - - try - { - TextBrushes.UpdateLevelDefaults(false); - var info = (SolidColorBrush) brushes.ForegroundBrush(false, false, true, LevelFlags.Info); - info.Color.Should().Be(Color.FromRgb(0x11, 0x22, 0x33)); - - TextBrushes.UpdateLevelDefaults(true); - info.Color.Should().Be(Color.FromRgb(0x11, 0x22, 0x33)); - } - finally - { - TextBrushes.UpdateLevelDefaults(false); - } + + var lightBrushes = new TextBrushes(settings, LogLevelPalette.Light); + ((SolidColorBrush) lightBrushes.ForegroundBrush(false, false, true, LevelFlags.Info)).Color + .Should().Be(Color.FromRgb(0x11, 0x22, 0x33)); + + var darkBrushes = new TextBrushes(settings, LogLevelPalette.Dark); + ((SolidColorBrush) darkBrushes.ForegroundBrush(false, false, true, LevelFlags.Info)).Color + .Should().Be(Color.FromRgb(0x11, 0x22, 0x33)); } } -} \ No newline at end of file +} diff --git a/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs b/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs index 4033fa4a..9c0063bd 100644 --- a/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs +++ b/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs @@ -79,5 +79,58 @@ public void TestResolveDarkModeUndefinedValueFallsBackToLight() { ThemeManager.ResolveDarkMode((ThemeMode)99).Should().BeFalse(); } + + [Test] + public void TestApplyThreeArgumentSetsPalette() + { + try + { + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Dark, LogLevelPalette.Dark); + ThemeManager.CurrentDarkMode.Should().BeTrue(); + ThemeManager.CurrentThemeMode.Should().Be(ThemeMode.Dark); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); + + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Light); + } + finally + { + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + } + } + + [Test] + public void TestApplyThemeModeDoesNotChangePalette() + { + try + { + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); + + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Dark); + ThemeManager.CurrentDarkMode.Should().BeTrue(); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); + } + finally + { + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + } + } + + [Test] + public void TestApplyBoolOverloadKeepsPalette() + { + try + { + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); + ThemeManager.Apply(UISettings.DefaultThemeColor, false); + ThemeManager.CurrentDarkMode.Should().BeFalse(); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); + } + finally + { + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + } + } } } diff --git a/src/Tailviewer/App.cs b/src/Tailviewer/App.cs index a5a057f1..3b767fe9 100644 --- a/src/Tailviewer/App.cs +++ b/src/Tailviewer/App.cs @@ -263,7 +263,7 @@ private static int StartApplication(SingleApplicationHelper.IMutex mutex, string }; application.Exit += (sender, e) => systemThemeProvider.Dispose(); - ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.ThemeMode); + ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.ThemeMode, settings.Ui.LogLevelPalette); var dispatcher = Dispatcher.CurrentDispatcher; var uiDispatcher = new UiDispatcher(dispatcher); services.RegisterInstance(uiDispatcher); diff --git a/src/Tailviewer/Localization/Strings.cs b/src/Tailviewer/Localization/Strings.cs index feb56adb..91a17c16 100644 --- a/src/Tailviewer/Localization/Strings.cs +++ b/src/Tailviewer/Localization/Strings.cs @@ -256,6 +256,7 @@ public static class Strings public static string ThemeModeDark => _rm.GetString("ThemeModeDark") ?? "ThemeModeDark"; public static string ThemeModeLight => _rm.GetString("ThemeModeLight") ?? "ThemeModeLight"; public static string ThemeModeSystem => _rm.GetString("ThemeModeSystem") ?? "ThemeModeSystem"; + public static string LogLevelPaletteGroup => _rm.GetString("LogLevelPaletteGroup") ?? "LogLevelPaletteGroup"; public static string TheyDo => _rm.GetString("TheyDo") ?? "TheyDo"; public static string ThisMonth => _rm.GetString("ThisMonth") ?? "ThisMonth"; public static string ThisWeek => _rm.GetString("ThisWeek") ?? "ThisWeek"; diff --git a/src/Tailviewer/Localization/Strings.resx b/src/Tailviewer/Localization/Strings.resx index 13c29294..ed6d104f 100644 --- a/src/Tailviewer/Localization/Strings.resx +++ b/src/Tailviewer/Localization/Strings.resx @@ -753,6 +753,9 @@ Try changing your filter(s) or disable them again System + + Log level palette + they do diff --git a/src/Tailviewer/Localization/Strings.zh-CN.resx b/src/Tailviewer/Localization/Strings.zh-CN.resx index 9aac8a11..b0a96a17 100644 --- a/src/Tailviewer/Localization/Strings.zh-CN.resx +++ b/src/Tailviewer/Localization/Strings.zh-CN.resx @@ -753,6 +753,9 @@ 跟随系统 + + 日志级别配色 + 它们 diff --git a/src/Tailviewer/Settings/LogLevelDefaults.cs b/src/Tailviewer/Settings/LogLevelDefaults.cs index 2d6802ed..f156eed4 100644 --- a/src/Tailviewer/Settings/LogLevelDefaults.cs +++ b/src/Tailviewer/Settings/LogLevelDefaults.cs @@ -38,6 +38,11 @@ private LogLevelDefaults( public static LogLevelDefaults Dark { get; } = CreateDark(); + public static LogLevelDefaults For(LogLevelPalette palette) + { + return palette == LogLevelPalette.Dark ? Dark : Light; + } + public static LogLevelDefaults For(bool darkMode) { return darkMode ? Dark : Light; diff --git a/src/Tailviewer/Settings/LogLevelPalette.cs b/src/Tailviewer/Settings/LogLevelPalette.cs new file mode 100644 index 00000000..fca5f8d7 --- /dev/null +++ b/src/Tailviewer/Settings/LogLevelPalette.cs @@ -0,0 +1,19 @@ +namespace Tailviewer.Settings +{ + /// + /// Determines which default palette is used for log levels that have not + /// been customized by the user. + /// + public enum LogLevelPalette + { + /// + /// Use the light log level palette. + /// + Light = 0, + + /// + /// Use the dark log level palette. + /// + Dark = 1 + } +} diff --git a/src/Tailviewer/Settings/UISettings.cs b/src/Tailviewer/Settings/UISettings.cs index 71238657..54f8763d 100644 --- a/src/Tailviewer/Settings/UISettings.cs +++ b/src/Tailviewer/Settings/UISettings.cs @@ -22,11 +22,14 @@ public sealed class UISettings public ThemeMode ThemeMode { get; set; } + public LogLevelPalette LogLevelPalette { get; set; } + public UISettings() { Language = DefaultLanguage; ThemeColor = DefaultThemeColor; ThemeMode = ThemeMode.Light; + LogLevelPalette = LogLevelPalette.Light; } [Pure] @@ -36,7 +39,8 @@ public UISettings Clone() { Language = Language, ThemeColor = ThemeColor, - ThemeMode = ThemeMode + ThemeMode = ThemeMode, + LogLevelPalette = LogLevelPalette }; } @@ -45,11 +49,13 @@ public void Save(XmlWriter writer) writer.WriteAttributeString("language", Language ?? DefaultLanguage); writer.WriteAttributeColor("themecolor", ThemeColor); writer.WriteAttributeString("thememode", ThemeMode.ToString()); + writer.WriteAttributeString("loglevelpalette", LogLevelPalette.ToString()); } public void Restore(XmlReader reader) { bool themeModeAttributeSeen = false; + bool logLevelPaletteSeen = false; for (int i = 0; i < reader.AttributeCount; ++i) { @@ -75,6 +81,15 @@ public void Restore(XmlReader reader) ThemeMode = parsedMode; } break; + + case "loglevelpalette": + logLevelPaletteSeen = true; + if (Enum.TryParse(reader.ReadContentAsString(), true, out LogLevelPalette parsedPalette) && + Enum.IsDefined(typeof(LogLevelPalette), parsedPalette)) + { + LogLevelPalette = parsedPalette; + } + break; } } @@ -93,6 +108,15 @@ public void Restore(XmlReader reader) } } } + + // Legacy migration: when the new attribute is absent, preserve an + // explicitly dark legacy theme by selecting the dark log palette. + if (!logLevelPaletteSeen) + { + LogLevelPalette = ThemeMode == ThemeMode.Dark + ? LogLevelPalette.Dark + : LogLevelPalette.Light; + } } } } diff --git a/src/Tailviewer/Ui/LogView/LogEntryListView.cs b/src/Tailviewer/Ui/LogView/LogEntryListView.cs index 9a3eeaa1..8aa54e2f 100644 --- a/src/Tailviewer/Ui/LogView/LogEntryListView.cs +++ b/src/Tailviewer/Ui/LogView/LogEntryListView.cs @@ -25,6 +25,7 @@ using Tailviewer.Ui.LogView.LogLevels; using Tailviewer.Ui.LogView.Messages; using Tailviewer.Ui.LogView.Timestamps; +using Tailviewer.Ui; using Properties = Tailviewer.Core.Properties; namespace Tailviewer.Ui.LogView @@ -105,6 +106,7 @@ private readonly IReadOnlyDictionary Levels = new[] { LevelFlags.Other, @@ -36,9 +20,21 @@ public sealed class TextBrushes LevelFlags.Fatal }; - private static readonly Dictionary LevelDefaultForegroundBrushes; - private static readonly Dictionary LevelDefaultBackgroundBrushes; - private static readonly Dictionary LevelDefaultAlternateBrushes; + public static Brush SelectedForegroundBrush { get; private set; } + public static SolidColorBrush SelectedBackgroundBrush { get; private set; } + public static SolidColorBrush SelectedUnfocusedBackgroundBrush { get; private set; } + public static Brush HighlightedForegroundBrush { get; private set; } + public static Brush HighlightedBackgroundBrush { get; private set; } + public static Brush HighlightedSelectedForegroundBrush { get; private set; } + public static Brush HighlightedSelectedBackgroundBrush { get; private set; } + public static SolidColorBrush LineNumberForegroundBrush { get; private set; } + public static SolidColorBrush DataSourceFilenameForegroundBrush { get; private set; } + public static SolidColorBrush DataSourceCharacterCodeForegroundBrush { get; private set; } + public static SolidColorBrush CanvasBackgroundBrush { get; private set; } + public static SolidColorBrush DefaultForegroundBrush { get; private set; } + public static SolidColorBrush DefaultBackgroundBrush { get; private set; } + public static SolidColorBrush AlternatingBackgroundBrush { get; private set; } + public static SolidColorBrush SeparatorBrush { get; private set; } private readonly Dictionary _foregroundBrushes; private readonly Dictionary _backgroundBrushes; @@ -46,108 +42,74 @@ public sealed class TextBrushes static TextBrushes() { - SelectedBackgroundBrush = CreateMutableBrush(UISettings.DefaultThemeColor); - + SelectedBackgroundBrush = CreateBrush(UISettings.DefaultThemeColor); SelectedForegroundBrush = Brushes.White; - - SelectedUnfocusedBackgroundBrush = CreateMutableBrush(Color.FromRgb(215, 215, 215)); - + SelectedUnfocusedBackgroundBrush = CreateBrush(Color.FromRgb(215, 215, 215)); HighlightedForegroundBrush = Brushes.Black; HighlightedBackgroundBrush = CreateBrush(Color.FromRgb(255, 255, 77)); - HighlightedSelectedForegroundBrush = Brushes.Black; HighlightedSelectedBackgroundBrush = CreateBrush(Color.FromRgb(255, 150, 50)); - - LineNumberForegroundBrush = CreateMutableBrush(UISettings.DefaultThemeColor); - - DataSourceFilenameForegroundBrush = CreateMutableBrush(Color.FromRgb(128, 128, 128)); - DataSourceCharacterCodeForegroundBrush = CreateMutableBrush(UISettings.DefaultThemeColor); - - CanvasBackgroundBrush = CreateMutableBrush(Colors.White); - DefaultForegroundBrush = CreateMutableBrush(Colors.Black); - DefaultBackgroundBrush = CreateMutableBrush(Colors.Transparent); - AlternatingBackgroundBrush = CreateMutableBrush(Color.FromRgb(0xE8, 0xF1, 0xF7)); - SeparatorBrush = CreateMutableBrush(Color.FromRgb(0xE1, 0xE4, 0xE8)); - - LevelDefaultForegroundBrushes = new Dictionary(); - LevelDefaultBackgroundBrushes = new Dictionary(); - LevelDefaultAlternateBrushes = new Dictionary(); - - var light = LogLevelDefaults.Light; - foreach (var level in Levels) - { - var defaults = light.Get(level); - LevelDefaultForegroundBrushes.Add(level, CreateMutableBrush(defaults.ForegroundColor)); - LevelDefaultBackgroundBrushes.Add(level, CreateMutableBrush(defaults.BackgroundColor)); - LevelDefaultAlternateBrushes.Add(level, defaults.BackgroundColor.A == 0 - ? AlternatingBackgroundBrush - : (Brush) LevelDefaultBackgroundBrushes[level]); - } + LineNumberForegroundBrush = CreateBrush(UISettings.DefaultThemeColor); + DataSourceFilenameForegroundBrush = CreateBrush(Color.FromRgb(128, 128, 128)); + DataSourceCharacterCodeForegroundBrush = CreateBrush(UISettings.DefaultThemeColor); + CanvasBackgroundBrush = CreateBrush(Colors.White); + DefaultForegroundBrush = CreateBrush(Colors.Black); + DefaultBackgroundBrush = CreateBrush(Colors.Transparent); + AlternatingBackgroundBrush = CreateBrush(Color.FromRgb(0xE8, 0xF1, 0xF7)); + SeparatorBrush = CreateBrush(Color.FromRgb(0xE1, 0xE4, 0xE8)); } /// - /// Updates the colors of those brushes which are derived from the theme's accent color. + /// Replaces the accent-derived brushes with new (frozen) instances for the + /// given accent color. Replacing references (rather than mutating a shared + /// Freezable) keeps every brush thread-safe. /// public static void UpdateTheme(Color primary) { - SelectedBackgroundBrush.Color = primary; - LineNumberForegroundBrush.Color = primary; - DataSourceCharacterCodeForegroundBrush.Color = primary; + SelectedBackgroundBrush = CreateBrush(primary); + LineNumberForegroundBrush = CreateBrush(primary); + DataSourceCharacterCodeForegroundBrush = CreateBrush(primary); } /// - /// Updates the colors of those brushes which depend on whether the application - /// is currently rendered in light or dark mode. + /// Replaces the neutral brushes with new (frozen) instances for the given + /// light/dark mode. /// public static void UpdateNeutral(bool darkMode) { if (darkMode) { - CanvasBackgroundBrush.Color = Color.FromRgb(0x1E, 0x1E, 0x1E); - DefaultForegroundBrush.Color = Color.FromRgb(0xDC, 0xDC, 0xDC); - DefaultBackgroundBrush.Color = Colors.Transparent; - SelectedUnfocusedBackgroundBrush.Color = Color.FromRgb(0x3F, 0x3F, 0x46); - AlternatingBackgroundBrush.Color = Color.FromRgb(0x25, 0x25, 0x26); - DataSourceFilenameForegroundBrush.Color = Color.FromRgb(0x9E, 0x9E, 0x9E); - SeparatorBrush.Color = Color.FromRgb(0x3F, 0x3F, 0x46); + CanvasBackgroundBrush = CreateBrush(Color.FromRgb(0x1E, 0x1E, 0x1E)); + DefaultForegroundBrush = CreateBrush(Color.FromRgb(0xDC, 0xDC, 0xDC)); + DefaultBackgroundBrush = CreateBrush(Colors.Transparent); + SelectedUnfocusedBackgroundBrush = CreateBrush(Color.FromRgb(0x3F, 0x3F, 0x46)); + AlternatingBackgroundBrush = CreateBrush(Color.FromRgb(0x25, 0x25, 0x26)); + DataSourceFilenameForegroundBrush = CreateBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); + SeparatorBrush = CreateBrush(Color.FromRgb(0x3F, 0x3F, 0x46)); } else { - CanvasBackgroundBrush.Color = Colors.White; - DefaultForegroundBrush.Color = Colors.Black; - DefaultBackgroundBrush.Color = Colors.Transparent; - SelectedUnfocusedBackgroundBrush.Color = Color.FromRgb(0xD7, 0xD7, 0xD7); - AlternatingBackgroundBrush.Color = Color.FromRgb(0xE8, 0xF1, 0xF7); - DataSourceFilenameForegroundBrush.Color = Color.FromRgb(0x80, 0x80, 0x80); - SeparatorBrush.Color = Color.FromRgb(0xE1, 0xE4, 0xE8); - } - } - - /// - /// Updates the mutable static default brushes used for non-custom levels to - /// the given theme's default palette. Custom levels are unaffected because - /// they never reference these brushes. - /// - public static void UpdateLevelDefaults(bool darkMode) - { - var defaults = LogLevelDefaults.For(darkMode); - foreach (var level in Levels) - { - var levelDefaults = defaults.Get(level); - LevelDefaultForegroundBrushes[level].Color = levelDefaults.ForegroundColor; - LevelDefaultBackgroundBrushes[level].Color = levelDefaults.BackgroundColor; - LevelDefaultAlternateBrushes[level] = levelDefaults.BackgroundColor.A == 0 - ? AlternatingBackgroundBrush - : (Brush) LevelDefaultBackgroundBrushes[level]; + CanvasBackgroundBrush = CreateBrush(Colors.White); + DefaultForegroundBrush = CreateBrush(Colors.Black); + DefaultBackgroundBrush = CreateBrush(Colors.Transparent); + SelectedUnfocusedBackgroundBrush = CreateBrush(Color.FromRgb(0xD7, 0xD7, 0xD7)); + AlternatingBackgroundBrush = CreateBrush(Color.FromRgb(0xE8, 0xF1, 0xF7)); + DataSourceFilenameForegroundBrush = CreateBrush(Color.FromRgb(0x80, 0x80, 0x80)); + SeparatorBrush = CreateBrush(Color.FromRgb(0xE1, 0xE4, 0xE8)); } } public TextBrushes(ILogViewerSettings settings) + : this(settings, LogLevelPalette.Light) + {} + + public TextBrushes(ILogViewerSettings settings, LogLevelPalette palette) { _foregroundBrushes = new Dictionary(); _backgroundBrushes = new Dictionary(); _alternateBackgroundBrushes = new Dictionary(); + var defaults = LogLevelDefaults.For(palette); foreach (var level in Levels) { var levelSettings = settings != null ? GetLevelSettings(settings, level) : null; @@ -159,9 +121,10 @@ public TextBrushes(ILogViewerSettings settings) } else { - _foregroundBrushes.Add(level, LevelDefaultForegroundBrushes[level]); - _backgroundBrushes.Add(level, LevelDefaultBackgroundBrushes[level]); - _alternateBackgroundBrushes.Add(level, LevelDefaultAlternateBrushes[level]); + var levelDefaults = defaults.Get(level); + _foregroundBrushes.Add(level, CreateBrush(levelDefaults.ForegroundColor)); + _backgroundBrushes.Add(level, CreateBrush(levelDefaults.BackgroundColor)); + _alternateBackgroundBrushes.Add(level, GetAlternatingBrush(levelDefaults.BackgroundColor)); } } } @@ -216,19 +179,13 @@ public Brush BackgroundBrush(bool isSelected, bool isFocused, bool colorByLevel, } [Pure] - private static Brush CreateBrush(Color color) + private static SolidColorBrush CreateBrush(Color color) { var brush = new SolidColorBrush(color); brush.Freeze(); return brush; } - [Pure] - private static SolidColorBrush CreateMutableBrush(Color color) - { - return new SolidColorBrush(color); - } - private static Brush GetAlternatingBrush(Color color) { if (color.A == 0 || Colors.White.Equals(color)) @@ -253,4 +210,4 @@ private static LogLevelSettings GetLevelSettings(ILogViewerSettings settings, Le } } } -} \ No newline at end of file +} diff --git a/src/Tailviewer/Ui/Settings/LogLevelPaletteOption.cs b/src/Tailviewer/Ui/Settings/LogLevelPaletteOption.cs new file mode 100644 index 00000000..88df79b5 --- /dev/null +++ b/src/Tailviewer/Ui/Settings/LogLevelPaletteOption.cs @@ -0,0 +1,20 @@ +using Tailviewer.Settings; + +namespace Tailviewer.Ui.Settings +{ + /// + /// A single selectable log level palette in the settings flyout. + /// + public sealed class LogLevelPaletteOption + { + public LogLevelPaletteOption(LogLevelPalette value, string displayName) + { + Value = value; + DisplayName = displayName; + } + + public LogLevelPalette Value { get; } + + public string DisplayName { get; } + } +} diff --git a/src/Tailviewer/Ui/Settings/LogLevelSettingsViewModel.cs b/src/Tailviewer/Ui/Settings/LogLevelSettingsViewModel.cs index d321d70e..68d9fe81 100644 --- a/src/Tailviewer/Ui/Settings/LogLevelSettingsViewModel.cs +++ b/src/Tailviewer/Ui/Settings/LogLevelSettingsViewModel.cs @@ -31,7 +31,7 @@ public Color ForegroundColor { return _logLevelSettings.IsCustom ? _logLevelSettings.ForegroundColor - : LogLevelDefaults.For(ThemeManager.CurrentDarkMode).Get(_level).ForegroundColor; + : LogLevelDefaults.For(ThemeManager.CurrentLogLevelPalette).Get(_level).ForegroundColor; } set { @@ -46,7 +46,7 @@ public Color ForegroundColor } else { - var defaults = LogLevelDefaults.For(ThemeManager.CurrentDarkMode).Get(_level); + var defaults = LogLevelDefaults.For(ThemeManager.CurrentLogLevelPalette).Get(_level); _logLevelSettings.ForegroundColor = defaults.ForegroundColor; _logLevelSettings.BackgroundColor = defaults.BackgroundColor; _logLevelSettings.ForegroundColor = value; @@ -63,7 +63,7 @@ public Color BackgroundColor { return _logLevelSettings.IsCustom ? _logLevelSettings.BackgroundColor - : LogLevelDefaults.For(ThemeManager.CurrentDarkMode).Get(_level).BackgroundColor; + : LogLevelDefaults.For(ThemeManager.CurrentLogLevelPalette).Get(_level).BackgroundColor; } set { @@ -78,7 +78,7 @@ public Color BackgroundColor } else { - var defaults = LogLevelDefaults.For(ThemeManager.CurrentDarkMode).Get(_level); + var defaults = LogLevelDefaults.For(ThemeManager.CurrentLogLevelPalette).Get(_level); _logLevelSettings.ForegroundColor = defaults.ForegroundColor; _logLevelSettings.BackgroundColor = defaults.BackgroundColor; _logLevelSettings.BackgroundColor = value; @@ -100,4 +100,4 @@ private void EmitPropertyChanged([CallerMemberName] string propertyName = null) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } -} \ No newline at end of file +} diff --git a/src/Tailviewer/Ui/Settings/SettingsControl.xaml b/src/Tailviewer/Ui/Settings/SettingsControl.xaml index 7a1e5f28..f253e74a 100644 --- a/src/Tailviewer/Ui/Settings/SettingsControl.xaml +++ b/src/Tailviewer/Ui/Settings/SettingsControl.xaml @@ -317,6 +317,7 @@ + @@ -341,6 +342,19 @@ DisplayMemberPath="DisplayName" SelectedValuePath="Value" SelectedValue="{Binding ThemeMode, Mode=TwoWay}" /> + + diff --git a/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs b/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs index a9ff58c4..c572d8ec 100644 --- a/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs +++ b/src/Tailviewer/Ui/Settings/SettingsFlyoutViewModel.cs @@ -243,6 +243,12 @@ public int TabWidth } } + public IReadOnlyList LogLevelPalettes { get; } = new[] + { + new LogLevelPaletteOption(LogLevelPalette.Light, Strings.ThemeModeLight), + new LogLevelPaletteOption(LogLevelPalette.Dark, Strings.ThemeModeDark) + }; + public IReadOnlyList ThemeModes { get; } = new[] { new ThemeModeOption(ThemeMode.Light, Strings.ThemeModeLight), @@ -250,6 +256,31 @@ public int TabWidth new ThemeModeOption(ThemeMode.System, Strings.ThemeModeSystem) }; + public LogLevelPalette LogLevelPalette + { + get { return _settings.Ui.LogLevelPalette; } + set + { + if (value == _settings.Ui.LogLevelPalette) + return; + + _settings.Ui.LogLevelPalette = value; + EmitPropertyChanged(); + + _settings.SaveAsync(); + + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null) + { + dispatcher.BeginInvoke(new Action(() => ThemeManager.Apply(ThemeColor, _settings.Ui.ThemeMode, value))); + } + else + { + ThemeManager.Apply(ThemeColor, _settings.Ui.ThemeMode, value); + } + } + } + public ThemeMode ThemeMode { get { return _settings.Ui.ThemeMode; } diff --git a/src/Tailviewer/Ui/ThemeManager.cs b/src/Tailviewer/Ui/ThemeManager.cs index 0e178d6e..791107e6 100644 --- a/src/Tailviewer/Ui/ThemeManager.cs +++ b/src/Tailviewer/Ui/ThemeManager.cs @@ -16,13 +16,21 @@ public static class ThemeManager public static bool CurrentDarkMode { get; private set; } + /// + /// Raised on the UI thread whenever the accent/neutral theme or the log + /// level palette has been applied. Consumers rebuild their brushes so the + /// new (frozen) references are picked up. + /// public static event Action ThemeChanged; + public static ThemeMode CurrentThemeMode { get; private set; } = ThemeMode.Light; + /// - /// The currently selected theme mode, recorded on every - /// call. + /// The currently selected log level palette. Independent from + /// and only changed through the + /// three-argument . /// - public static ThemeMode CurrentThemeMode { get; private set; } = ThemeMode.Light; + public static LogLevelPalette CurrentLogLevelPalette { get; private set; } = LogLevelPalette.Light; /// /// The provider used to resolve the operating system's theme when @@ -55,30 +63,43 @@ public static bool ResolveDarkMode(ThemeMode mode) } /// - /// Resolves the effective dark-mode flag for the given theme mode and then - /// applies it together with the accent color. + /// Applies the accent color, the effective theme mode and the log level + /// palette. This is the primary entry point used at startup and when the + /// log level palette is changed. + /// + public static void Apply(Color primary, ThemeMode mode, LogLevelPalette palette) + { + CurrentThemeMode = mode; + CurrentLogLevelPalette = palette; + ApplyCore(primary, ResolveDarkMode(mode)); + } + + /// + /// Applies the accent color and theme mode only; the log level palette is + /// left unchanged so that changing the application theme never overwrites + /// an explicitly selected log palette. /// public static void Apply(Color primary, ThemeMode mode) { CurrentThemeMode = mode; - Apply(primary, ResolveDarkMode(mode)); + ApplyCore(primary, ResolveDarkMode(mode)); } /// - /// Computes the accent palette and the neutral (light/dark) palette for the - /// given base color and dark mode flag, then publishes them to - /// 's resources. Does nothing when there - /// is no current application (e.g. unit tests or design time). + /// Legacy convenience overload. Applies the accent color and the neutral + /// light/dark colors directly. The log level palette is not changed. /// - /// - /// public static void Apply(Color primary, bool darkMode) + { + ApplyCore(primary, darkMode); + } + + private static void ApplyCore(Color primary, bool darkMode) { CurrentPrimary = primary; CurrentDarkMode = darkMode; TextBrushes.UpdateTheme(primary); TextBrushes.UpdateNeutral(darkMode); - TextBrushes.UpdateLevelDefaults(darkMode); ThemeChanged?.Invoke(darkMode); var resources = Application.Current?.Resources; diff --git a/tools/generate_localization.py b/tools/generate_localization.py index f7ace98e..65fc67d1 100644 --- a/tools/generate_localization.py +++ b/tools/generate_localization.py @@ -38,6 +38,7 @@ "ThemeModeLight": ("Light", "浅色"), "ThemeModeDark": ("Dark", "深色"), "ThemeModeSystem": ("System", "跟随系统"), + "LogLevelPaletteGroup": ("Log level palette", "日志级别配色"), # --- Menus (MainWindow.xaml) --- "MenuFile": ("_File", "文件(_F)"), From 268085f96760c717e5b3775a01fdf1cd5015ed15 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 12:34:38 +0800 Subject: [PATCH 19/28] chore(task): archive 08-23-settings-log-palette-selector --- .../check.jsonl | 2 ++ .../implement.jsonl | 4 +++ .../prd.md | 25 ++++++++++++++++++ .../task.json | 26 +++++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 .trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/check.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/prd.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/task.json diff --git a/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/check.jsonl b/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/check.jsonl new file mode 100644 index 00000000..14e8bc4b --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/check.jsonl @@ -0,0 +1,2 @@ +{"file":".trellis/spec/ui/theming.md","reason":"Check palette independence and exact default pairs."} +{"file":".trellis/spec/testing/index.md","reason":"Check persistence and VM regression coverage."} diff --git a/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/implement.jsonl b/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/implement.jsonl new file mode 100644 index 00000000..0cb603e9 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/implement.jsonl @@ -0,0 +1,4 @@ +{"file":".trellis/spec/ui/mvvm.md","reason":"SettingsFlyoutViewModel and binding patterns."} +{"file":".trellis/spec/ui/project-structure.md","reason":"UISettings ownership and localization generation rules."} +{"file":".trellis/spec/ui/theming.md","reason":"Palette/default-color interaction with ThemeManager and custom levels."} +{"file":".trellis/spec/testing/index.md","reason":"Settings and VM regression-test conventions."} diff --git a/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/prd.md b/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/prd.md new file mode 100644 index 00000000..d115f43d --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/prd.md @@ -0,0 +1,25 @@ +# 设置菜单显式提供浅色与深色日志配色 + +## Goal + +新增持久化的日志配色方案选择,并让日志级别默认颜色根据所选浅色/深色方案生效;设置页面需要明确展示两个方案。 + +## Requirements + +- 新增独立的日志默认配色选择,选项只有浅色和深色;不复用或改变已有应用主题模式的三选项语义。 +- 将选择保存到 UI 设置并在启动时恢复;缺失字段默认浅色,旧的深色主题配置尽量迁移为深色日志配色,非法值回退浅色。 +- 非自定义级别从所选方案读取默认前景/背景色;用户自定义级别保持自定义值。 +- 设置选择生效时更新日志查看器和设置页的有效颜色,并覆盖必要的本地化文本与测试。 + +## Acceptance Criteria + +- [x] Settings XAML 明确显示“应用主题模式”和“日志配色方案”两个独立选择器,日志配色方案恰好含浅色/深色。 +- [x] Light/Dark 选择分别对应 `LogLevelDefaults.Light/Dark`,且与应用 `ThemeMode` 解耦。 +- [x] UISettings 的构造、Clone、Save、Restore、旧配置兼容及 VM setter 持久化均有测试或可审查证据。 +- [x] 自定义颜色不被方案切换覆盖,相关 UI/日志视图能实时使用新方案。 + +## Notes + +- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. +- Lightweight tasks can remain PRD-only. +- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. diff --git a/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/task.json b/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/task.json new file mode 100644 index 00000000..23f69c50 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-settings-log-palette-selector/task.json @@ -0,0 +1,26 @@ +{ + "id": "settings-log-palette-selector", + "name": "settings-log-palette-selector", + "title": "设置菜单显式提供浅色与深色日志配色", + "description": "新增持久化的日志配色方案选择,并让日志级别默认颜色根据所选浅色/深色方案生效;设置页面需要明确展示两个方案。", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "brofea", + "assignee": "brofea", + "createdAt": "2026-08-23", + "completedAt": "2026-08-23", + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-23-fix-log-palette-and-ci", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file From 73efb0661f6f498acdb2387ea752bc41975a4844 Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 12:34:41 +0800 Subject: [PATCH 20/28] chore(task): archive 08-23-fix-textline-brush-threading --- .../check.jsonl | 2 ++ .../implement.jsonl | 3 +++ .../08-23-fix-textline-brush-threading/prd.md | 23 ++++++++++++++++ .../task.json | 26 +++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/check.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/prd.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/task.json diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/check.jsonl b/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/check.jsonl new file mode 100644 index 00000000..f09bbe1b --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/check.jsonl @@ -0,0 +1,2 @@ +{"file":".trellis/spec/ui/theming.md","reason":"Check frozen brush replacement and live theme refresh."} +{"file":".trellis/spec/testing/index.md","reason":"Check full TextLineTest and non-suppressed CI verification."} diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/implement.jsonl b/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/implement.jsonl new file mode 100644 index 00000000..f3b67043 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/implement.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/ui/theming.md","reason":"Freezable/thread-affinity and TextBrushes runtime-update constraints."} +{"file":".trellis/spec/testing/index.md","reason":"NUnit parallel regression-test requirements."} +{"file":".trellis/spec/build/index.md","reason":"Debug build and .NET Framework verification constraints."} diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/prd.md b/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/prd.md new file mode 100644 index 00000000..55e810df --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/prd.md @@ -0,0 +1,23 @@ +# 修复 TextLineTest WPF 画刷跨线程 CI 失败 + +## Goal + +修复 CI Build & Test 中 TextLineTest.TestBackgroundBrush1 和 TestForegroundBrush1 的跨线程 SolidColorBrush 访问失败,保持主题切换和日志渲染行为正确。 + +## Requirements + +- 找出并修复 `TextBrushes` 共享可变 `SolidColorBrush` 导致的 WPF Freezable 跨线程访问异常。 +- 主题或日志配色更新后,已有日志查看器必须获得新画刷并重绘;不能以 `[Apartment]`、串行化或排除测试作为唯一修复。 +- 共享默认画刷应可安全跨线程读取,自定义级别颜色和现有渲染行为保持不变。 + +## Acceptance Criteria + +- [x] `TextLineTest.TestForegroundBrush1` 与 `TestBackgroundBrush1` 在 NUnit 并行 worker 环境通过。 +- [x] 画刷线程归属问题有回归验证;主题/配色切换后现有视图仍能刷新。 +- [x] 相关 `TextBrushes`/日志视图测试通过,未引入禁用测试或无关 CI 改动。 + +## Notes + +- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. +- Lightweight tasks can remain PRD-only. +- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/task.json b/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/task.json new file mode 100644 index 00000000..51c3115d --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-textline-brush-threading/task.json @@ -0,0 +1,26 @@ +{ + "id": "fix-textline-brush-threading", + "name": "fix-textline-brush-threading", + "title": "修复 TextLineTest WPF 画刷跨线程 CI 失败", + "description": "修复 CI Build & Test 中 TextLineTest.TestBackgroundBrush1 和 TestForegroundBrush1 的跨线程 SolidColorBrush 访问失败,保持主题切换和日志渲染行为正确。", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "brofea", + "assignee": "brofea", + "createdAt": "2026-08-23", + "completedAt": "2026-08-23", + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-23-fix-log-palette-and-ci", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file From 52bd3800581517e47713347e127f2dd2ffc2c41a Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 12:34:45 +0800 Subject: [PATCH 21/28] chore(task): archive 08-23-fix-log-palette-and-ci --- .../08-23-fix-log-palette-and-ci/check.jsonl | 3 ++ .../08-23-fix-log-palette-and-ci/design.md | 20 +++++++++++++ .../implement.jsonl | 6 ++++ .../08-23-fix-log-palette-and-ci/implement.md | 12 ++++++++ .../08-23-fix-log-palette-and-ci/prd.md | 29 +++++++++++++++++++ .../08-23-fix-log-palette-and-ci/task.json | 29 +++++++++++++++++++ 6 files changed, 99 insertions(+) create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/check.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/design.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/implement.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/prd.md create mode 100644 .trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/task.json diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/check.jsonl b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/check.jsonl new file mode 100644 index 00000000..e646d511 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/ui/theming.md","reason":"Verify explicit palette and frozen-brush changes against theming contracts."} +{"file":".trellis/spec/testing/index.md","reason":"Verify NUnit and regression-test requirements."} +{"file":".trellis/spec/build/index.md","reason":"Verify build/test commands and classic project constraints."} diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/design.md b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/design.md new file mode 100644 index 00000000..99a5eba9 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/design.md @@ -0,0 +1,20 @@ +# 设计 + +## 已批准方向 + +OpenCode 只读规划确认了两个根因:设置模型目前只有 `ThemeMode`,而日志默认色由应用的有效深色状态隐式驱动;`TextBrushes` 则把非冻结的静态 `SolidColorBrush` 交给不同 NUnit worker/渲染线程共享。 + +本任务采用一个独立的 `LogLevelPalette`(Light/Dark)持久化字段。`ThemeMode` 继续只负责应用中性/强调色和 `System` 跟随逻辑;日志级别默认色由显式日志配色决定。缺失新字段的旧配置按 Light 默认,已明确选择 Dark 的旧配置迁移为 Dark,以减少既有深色用户的视觉回退;之后两者保持独立。 + +画刷采用“冻结后替换”模型:共享默认画刷全部使用冻结 `Freezable`,更新时发布新引用而非跨线程修改 `.Color`;日志视图通过已有设置/重绘入口重建 `TextBrushes` 并使画布失效。自定义颜色继续使用实例级冻结画刷。 + +## 关键约束 + +- 3 参数主题应用入口应同时接收应用主题模式与日志配色;已有便捷入口需保持兼容并明确其默认语义。 +- 不通过 NUnit apartment 属性、跳过测试或修改 CI 测试集合规避问题。 +- 任何新的设置文本遵循 `tools/generate_localization.py` 的单一来源;避免生成与本任务无关的本地化漂移。 +- 修改 XAML 时保持 `DynamicResource` 主题绑定、现有 MVVM 和 .NET Framework 4.8 约束。 + +## 验收重点 + +父 Agent 将分别审阅:设置中是否真的有两个可见选择器、调色板是否独立持久化、旧配置行为、冻结画刷与日志视图刷新链路,以及完整 `Tailviewer.Tests.dll` 的 CI 等价结果。 diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/implement.jsonl b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/implement.jsonl new file mode 100644 index 00000000..75fa07b1 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/implement.jsonl @@ -0,0 +1,6 @@ +{"file":".trellis/spec/ui/index.md","reason":"UI layer scope, WPF startup and theming architecture."} +{"file":".trellis/spec/ui/mvvm.md","reason":"View-model and binding conventions for the settings selector."} +{"file":".trellis/spec/ui/project-structure.md","reason":"Settings ownership and localization source rules."} +{"file":".trellis/spec/ui/theming.md","reason":"ThemeManager, LogLevelDefaults, DynamicResource and Freezable constraints."} +{"file":".trellis/spec/testing/index.md","reason":"NUnit, regression-test and assertion conventions."} +{"file":".trellis/spec/build/index.md","reason":".NET Framework build and project conventions."} diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/implement.md b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/implement.md new file mode 100644 index 00000000..5e3851d2 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/implement.md @@ -0,0 +1,12 @@ +# 实施计划 + +1. 在 `UISettings`/设置枚举中加入独立的日志配色状态、克隆/保存/恢复/兼容默认;补充 Light/Dark 选项的 ViewModel 与 Settings XAML 显示。 +2. 让 `ThemeManager`、`LogLevelSettingsViewModel`、`App` 和 `TextBrushes` 以显式日志配色驱动非自定义级别,保留应用主题模式的独立语义。 +3. 将共享画刷改为可跨线程读取的冻结引用并在主题/配色变化时替换;通过现有日志视图入口重建/刷新画刷,保留自定义画刷。 +4. 添加/更新设置、配色、画刷和日志视图回归测试;必要时只更新本任务需要的本地化生成源和产物。 +5. 运行聚焦 NUnit、Debug 构建、`git diff --check`,再运行 Trellis 质量检查和尽可能接近 CI 的完整测试集合。 + +## 不做 + +- 不修改 search box、页面布局或其他前序任务内容。 +- 不修改 CI 来过滤失败测试,不做大范围画刷/渲染重构。 diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/prd.md b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/prd.md new file mode 100644 index 00000000..b69473f8 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/prd.md @@ -0,0 +1,29 @@ +# 修正日志配色选择与 CI 画刷稳定性 + +## Goal + +在设置菜单中提供可见的浅色与深色日志配色选项,并修复 Build & Test 中 TextLineTest 的 WPF 画刷跨线程失败;完成验证后归档并推送。 + +## Requirements + +- 在设置菜单中同时提供两类互相独立的选择:应用主题模式 `浅色 / 深色 / 跟随系统`,以及日志级别默认配色 `浅色 / 深色`。用户必须能在界面上区分这两个选择。 +- 日志配色方案必须持久化;新配置默认使用浅色方案,已有深色主题配置在没有新字段时应尽量保持原有深色日志观感。无效值必须安全回退到浅色方案。 +- 非自定义日志级别使用所选方案的 `LogLevelDefaults.Light` 或 `LogLevelDefaults.Dark`;自定义级别颜色不因应用主题或日志配色切换而被覆盖。 +- 切换日志配色后,设置页预览/颜色编辑器和已经打开的日志查看器都要使用新方案,不需要重启应用。 +- 修复 GitHub Actions `Build & Test` 中 `TextLineTest.TestForegroundBrush1` 与 `TestBackgroundBrush1` 的 WPF `SolidColorBrush` 跨线程异常。修复必须解决共享画刷的线程归属问题,不得跳过、禁用或弱化测试。 +- 保持 .NET Framework 4.8、现有 MVVM/XAML、本地化和主题切换约定,不引入新的 UI 框架或无关重构。 + +## Acceptance Criteria + +- [x] 设置菜单能同时看到应用主题模式和日志配色方案两个控件;前者有三个选项,后者恰好有浅色和深色两个选项。 +- [x] 两套日志默认色值与 `LogLevelDefaults.Light/Dark` 一致,选择日志配色不改变应用中性主题,切换应用主题也不会隐式覆盖用户选择的日志配色。 +- [x] 新字段可保存、恢复、克隆;缺失字段和非法值的兼容行为有测试覆盖;自定义日志级别仍保持原值。 +- [x] 主题/日志配色切换后,现有日志视图可继续渲染并显示最新画刷,相关画刷可安全地从其他 NUnit worker 线程读取。 +- [x] `Tailviewer.Tests.dll` 中原先失败的两个 `TextLineTest` 已通过,未通过项未通过排除测试来隐藏;相关项目 Debug 构建无错误。 +- [x] `git diff --check` 与 Trellis 质量检查通过,工作区只包含本任务范围内的变更。 + +## Notes + +- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. +- Lightweight tasks can remain PRD-only. +- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. diff --git a/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/task.json b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/task.json new file mode 100644 index 00000000..ea6b86c6 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-23-fix-log-palette-and-ci/task.json @@ -0,0 +1,29 @@ +{ + "id": "fix-log-palette-and-ci", + "name": "fix-log-palette-and-ci", + "title": "修正日志配色选择与 CI 画刷稳定性", + "description": "在设置菜单中提供可见的浅色与深色日志配色选项,并修复 Build & Test 中 TextLineTest 的 WPF 画刷跨线程失败;完成验证后归档并推送。", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P1", + "creator": "brofea", + "assignee": "brofea", + "createdAt": "2026-08-23", + "completedAt": "2026-08-23", + "branch": null, + "base_branch": "master", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [ + "08-23-settings-log-palette-selector", + "08-23-fix-textline-brush-threading" + ], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file From 38b79f08d74e68a1ad611200f3afc3111e58e1ea Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 12:35:00 +0800 Subject: [PATCH 22/28] chore: record journal --- .trellis/workspace/brofea/index.md | 5 ++-- .trellis/workspace/brofea/journal-1.md | 33 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.trellis/workspace/brofea/index.md b/.trellis/workspace/brofea/index.md index 54efae17..0e8a9aa8 100644 --- a/.trellis/workspace/brofea/index.md +++ b/.trellis/workspace/brofea/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 11 +- **Total Sessions**: 12 - **Last Active**: 2026-08-23 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~383 | Active | +| `journal-1.md` | ~416 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 12 | 2026-08-23 | 修复独立日志配色与 CI 画刷线程问题 | `f75f44f8` | `ui` | | 11 | 2026-08-23 | 重做主题、日志等级配色与日志布局 | `77d7d632` | `ui` | | 10 | 2026-08-23 | 完成 Fluent 控件视觉化 | `80f3e582` | `ui` | | 9 | 2026-08-23 | 完成深色模式主题管线 | `b5999cdb`, `beddd227` | `ui` | diff --git a/.trellis/workspace/brofea/journal-1.md b/.trellis/workspace/brofea/journal-1.md index a06662f2..65ef6ac9 100644 --- a/.trellis/workspace/brofea/journal-1.md +++ b/.trellis/workspace/brofea/journal-1.md @@ -381,3 +381,36 @@ Re-themed all remaining hardcoded Metrolib blue accents (#3998D6 family) to the ### Next Steps - None - task complete + + +## Session 12: 修复独立日志配色与 CI 画刷线程问题 + +**Date**: 2026-08-23 +**Task**: 修复独立日志配色与 CI 画刷线程问题 +**Branch**: `ui` + +### Summary + +在设置中增加独立的浅色/深色日志级别配色选择,保留应用主题浅色/深色/跟随系统三选项;完成持久化迁移、实时日志视图刷新和冻结画刷线程安全修复。应用及全部 CI 测试项目 Debug 编译通过,相关 NUnit 聚焦测试 54/54 通过;已归档父任务及两个子任务。 + +### Main Changes + +- Detailed change bullets were not supplied; see the summary above. + +### Git Commits + +| Hash | Message | +|------|---------| +| `f75f44f8` | (see git log) | + +### Testing + +- Validation was not recorded for this session. + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete From b5bc2ca5e9b3b40d652d91a2d2b47806082736fd Mon Sep 17 00:00:00 2001 From: brofea Date: Sun, 23 Aug 2026 15:04:07 +0800 Subject: [PATCH 23/28] feat(ui): unify theme and log-level color settings Derive Light, Dark, and System log-level defaults from the active theme. Remove the persisted independent palette selector. Add a settings action that restores #FF222280 and all seven default log colors. Update localization, theming guidance, and regression coverage. --- .trellis/spec/ui/theming.md | 55 ++++++++------- .../Settings/LogViewerSettingsTest.cs | 44 +++++++++++- .../Settings/UISettingsTest.cs | 68 ++----------------- .../Settings/LogLevelSettingsViewModelTest.cs | 16 ++--- .../Ui/SettingsMainPanelViewModelTest.cs | 59 +++++++++++++++- src/Tailviewer.Tests/Ui/ThemeManagerTest.cs | 42 +++++++----- src/Tailviewer/App.cs | 4 +- src/Tailviewer/Localization/Strings.cs | 2 +- src/Tailviewer/Localization/Strings.resx | 6 +- .../Localization/Strings.zh-CN.resx | 34 +++++----- src/Tailviewer/Settings/ILogViewerSettings.cs | 7 +- src/Tailviewer/Settings/LogViewerSettings.cs | 24 ++++++- src/Tailviewer/Settings/UISettings.cs | 26 +------ .../Ui/Settings/LogLevelPaletteOption.cs | 20 ------ .../Ui/Settings/SettingsControl.xaml | 26 ++++--- .../Ui/Settings/SettingsFlyoutViewModel.cs | 62 ++++++++--------- src/Tailviewer/Ui/ThemeManager.cs | 33 +++------ tools/generate_localization.py | 2 +- 18 files changed, 276 insertions(+), 254 deletions(-) delete mode 100644 src/Tailviewer/Ui/Settings/LogLevelPaletteOption.cs diff --git a/.trellis/spec/ui/theming.md b/.trellis/spec/ui/theming.md index 4e55a8d5..d459e81c 100644 --- a/.trellis/spec/ui/theming.md +++ b/.trellis/spec/ui/theming.md @@ -1,15 +1,15 @@ # Theming & Accent Color Tailviewer's accent/theme color is user-configurable and updates the whole UI live -(no restart). All accent colors derive from **one** base color (default `#0F62FE`, +(no restart). All accent colors derive from **one** base color (default `#FF222280`, `UISettings.DefaultThemeColor`). The UI theme mode is persisted independently as `Light`, `Dark`, or `System`. ## Architecture ``` -UISettings.ThemeColor + ThemeMode + LogLevelPalette - -> ThemeManager.Apply(Color, ThemeMode, LogLevelPalette) +UISettings.ThemeColor + ThemeMode + -> ThemeManager.Apply(Color, ThemeMode) | ResolveDarkMode (System -> provider) v ApplyCore(Color, bool) -> Application.Current.Resources @@ -19,7 +19,7 @@ UISettings.ThemeColor + ThemeMode + LogLevelPalette | | v v LogEntryListView rebuilds settings ColorPickers - palette-aware TextBrushes + theme-aware TextBrushes | v XAML brushes bind via {DynamicResource} @@ -28,12 +28,11 @@ UISettings.ThemeColor + ThemeMode + LogLevelPalette - `src/Tailviewer/Ui/ThemePalette.cs` — pure helper `ThemePalette.Compute(Color)` derives the shade palette (light / lighter / dark / separator) by blending the base toward white/black. Pure & unit-testable (`ThemePaletteTest`). -- `src/Tailviewer/Ui/ThemeManager.cs` — `Apply(Color, ThemeMode, LogLevelPalette)` is - the startup and palette-selection entry point. `ThemeMode` controls the neutral UI - (Light / Dark / System), while `LogLevelPalette` independently selects the log-level - defaults (Light / Dark). `Apply(Color, ThemeMode)` preserves the current log palette; - this is used for accent changes and system-theme transitions. All overloads publish - the semantic resources, update the shared brushes, and raise `ThemeChanged`. +- `src/Tailviewer/Ui/ThemeManager.cs` — `Apply(Color, ThemeMode)` is the startup and + live-update entry point. `ThemeMode` controls the neutral UI (Light / Dark / System), + and the effective light/dark result selects the matching log-level defaults. All + callers publish the semantic resources, update the shared brushes, and raise + `ThemeChanged`. Resource publication is a no-op when `Application.Current` is null (tests / design time), but brush updates and the event still run. - `src/Tailviewer/Themes/Constants.xaml` — defines `SolidColorBrush` keys @@ -43,9 +42,9 @@ UISettings.ThemeColor + ThemeMode + LogLevelPalette (line numbers, character code, selection). Every shared brush is frozen and `UpdateTheme(Color)` / `UpdateNeutral(bool)` replace the references instead of mutating a `SolidColorBrush`; this prevents WPF cross-thread ownership failures. - Each `TextBrushes` instance receives the active `LogLevelPalette` and owns frozen - level brushes. `LogEntryListView` rebuilds the instance and visible lines after - `ThemeChanged`. + Each `TextBrushes` instance receives the active internal `LogLevelPalette` and owns + frozen level brushes. `LogEntryListView` rebuilds the instance and visible lines + after `ThemeChanged`. ## Theme mode and system provider @@ -81,16 +80,20 @@ the first time, its ViewModel must seed the other stored field from the current palette default before setting `IsCustom=true`; otherwise a stale Light value can leak into the selected palette. -### Independent log-level palette contract - -- `UISettings.Save` writes `loglevelpalette` as the defined enum name (`Light` or `Dark`). -- `UISettings.Restore` accepts only defined values; an invalid present value falls back - to the constructor default (`Light`). If the attribute is absent, an explicitly dark - `thememode` migrates to `LogLevelPalette.Dark`; Light and System migrate to Light. -- `ThemeManager.Apply(Color, ThemeMode, LogLevelPalette)` updates both selectors. - `ThemeManager.Apply(Color, ThemeMode)` must not overwrite `CurrentLogLevelPalette`. -- Changing the palette updates an existing log viewer through `ThemeChanged`; changing - the neutral theme does not silently change the selected log palette. +### Theme-following log-level palette contract + +- The settings page exposes only the overall `ThemeMode` selector; there is no separate + log-level palette selector or persisted palette choice. +- `ThemeManager.Apply(Color, ThemeMode)` resolves `System` through + `ISystemThemeProvider`, then sets `CurrentLogLevelPalette` to `Dark` for an effective + dark theme and `Light` otherwise. +- Changing the overall theme updates an existing log viewer and log-level settings + through `ThemeChanged`, so default colors always follow the visible theme. +- The settings reset action restores `UISettings.DefaultThemeColor`, calls + `ILogViewerSettings.RestoreDefaultColors()` to clear every `IsCustom` flag and restore + the Light persisted baseline, saves the settings, and reapplies the current + `ThemeMode` on the Dispatcher so both Light and Dark defaults become visible + immediately. ## Rules @@ -167,9 +170,11 @@ those resource keys at app scope does **not** reach them. To re-theme an externa - `Ui/TextBrushesTest` — defaults follow `UISettings.DefaultThemeColor`; `UpdateTheme` replaces frozen derived brushes; light/dark level palette selection creates the expected frozen defaults and custom brushes remain unchanged. -- `Settings/UISettingsTest` — `#0F62FE` default, three-mode save/restore, legacy +- `Settings/UISettingsTest` — `#FF222280` default, three-mode save/restore, legacy `darkmode` migration, invalid-value fallback, clone. - `Settings/LogLevelDefaultsTest` and `Settings/LogViewerSettingsTest` — exact Light/Dark - pairs and legacy `iscustom` inference. + pairs, legacy `iscustom` inference, and restoration of all default colors. - `Ui/Settings/LogLevelSettingsViewModelTest` — first custom edit seeds the unmodified field from the active theme and calls `SaveAsync`. +- `Ui/SettingsMainPanelViewModelTest` — the settings reset command restores the accent + color and theme-following log-level state. diff --git a/src/Tailviewer.Tests/Settings/LogViewerSettingsTest.cs b/src/Tailviewer.Tests/Settings/LogViewerSettingsTest.cs index d582a422..d73b0cab 100644 --- a/src/Tailviewer.Tests/Settings/LogViewerSettingsTest.cs +++ b/src/Tailviewer.Tests/Settings/LogViewerSettingsTest.cs @@ -189,6 +189,48 @@ public void TestApplyThemeDefaultsOnlyChangesNonCustomLevels() settings.Fatal.BackgroundColor.Should().Be(LogLevelDefaults.Dark.Fatal.BackgroundColor); } + [Test] + [Description("Restoring default colors clears every custom level and restores the light baseline")] + public void TestRestoreDefaultColors() + { + var settings = new LogViewerSettings(); + var levels = new[] + { + settings.Other, + settings.Trace, + settings.Debug, + settings.Info, + settings.Warning, + settings.Error, + settings.Fatal + }; + var defaults = new[] + { + LogLevelDefaults.Light.Other, + LogLevelDefaults.Light.Trace, + LogLevelDefaults.Light.Debug, + LogLevelDefaults.Light.Info, + LogLevelDefaults.Light.Warning, + LogLevelDefaults.Light.Error, + LogLevelDefaults.Light.Fatal + }; + + foreach (var level in levels) + { + level.IsCustom = true; + level.ForegroundColor = Colors.Pink; + level.BackgroundColor = Colors.Blue; + } + + settings.RestoreDefaultColors(); + + for (var i = 0; i < levels.Length; ++i) + { + levels[i].IsCustom.Should().BeFalse("because restored levels must follow the active theme defaults"); + AssertMatches(levels[i], defaults[i]); + } + } + [Test] [Description("Verifies that upon restoration, invalid values are replaced with defaults")] public void TestRestoreFromInvalidValues([Values(-5, -2, -1, 0)] int linesScrolledPerWheelTick, @@ -275,4 +317,4 @@ private static void AssertMatches(LogLevelSettings actual, LogLevelSettings expe actual.BackgroundColor.Should().Be(expected.BackgroundColor); } } -} \ No newline at end of file +} diff --git a/src/Tailviewer.Tests/Settings/UISettingsTest.cs b/src/Tailviewer.Tests/Settings/UISettingsTest.cs index a0bed9a4..71620abe 100644 --- a/src/Tailviewer.Tests/Settings/UISettingsTest.cs +++ b/src/Tailviewer.Tests/Settings/UISettingsTest.cs @@ -46,13 +46,12 @@ public void TestConstruction() settings.Language.Should().Be(UISettings.DefaultLanguage); settings.ThemeColor.Should().Be(UISettings.DefaultThemeColor); settings.ThemeMode.Should().Be(ThemeMode.Light); - settings.LogLevelPalette.Should().Be(LogLevelPalette.Light); } [Test] - public void TestDefaultThemeColorIs0F62FE() + public void TestDefaultThemeColorIsFF222280() { - UISettings.DefaultThemeColor.Should().Be(Color.FromRgb(0x0F, 0x62, 0xFE)); + UISettings.DefaultThemeColor.Should().Be(Color.FromArgb(0xFF, 0x22, 0x22, 0x80)); } [Test] @@ -62,8 +61,7 @@ public void TestClone() { Language = "zh-CN", ThemeColor = Colors.Red, - ThemeMode = ThemeMode.System, - LogLevelPalette = LogLevelPalette.Dark + ThemeMode = ThemeMode.System }; var clone = settings.Clone(); @@ -71,7 +69,6 @@ public void TestClone() clone.Language.Should().Be("zh-CN"); clone.ThemeColor.Should().Be(Colors.Red); clone.ThemeMode.Should().Be(ThemeMode.System); - clone.LogLevelPalette.Should().Be(LogLevelPalette.Dark); } [Test] @@ -81,15 +78,13 @@ public void TestRoundtrip() { Language = "zh-CN", ThemeColor = Color.FromRgb(0x12, 0x34, 0x56), - ThemeMode = ThemeMode.Dark, - LogLevelPalette = LogLevelPalette.Dark + ThemeMode = ThemeMode.Dark }; var restored = Restore(Save(settings)); restored.Language.Should().Be("zh-CN"); restored.ThemeColor.Should().Be(Color.FromRgb(0x12, 0x34, 0x56)); restored.ThemeMode.Should().Be(ThemeMode.Dark); - restored.LogLevelPalette.Should().Be(LogLevelPalette.Dark); } [Test] @@ -99,7 +94,6 @@ public void TestRestoreFromEmpty() restored.Language.Should().Be(UISettings.DefaultLanguage); restored.ThemeColor.Should().Be(UISettings.DefaultThemeColor); restored.ThemeMode.Should().Be(ThemeMode.Light); - restored.LogLevelPalette.Should().Be(LogLevelPalette.Light); } [Test] @@ -131,59 +125,5 @@ public void TestRestoreInvalidThemeModeDoesNotFallBackToLegacyDarkMode() Restore("").ThemeMode.Should().Be(ThemeMode.Light); } - [Test] - public void TestRestoreLogLevelPalette() - { - Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Dark); - Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Light); - Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Light); - } - - [Test] - public void TestRestoreFromInvalidLogLevelPalette() - { - Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Light); - Restore("").LogLevelPalette.Should().Be(LogLevelPalette.Light); - } - - [Test] - public void TestRestoreFromLegacyDarkModeSelectsDarkPalette() - { - var settings = Restore(""); - settings.ThemeMode.Should().Be(ThemeMode.Dark); - settings.LogLevelPalette.Should().Be(LogLevelPalette.Dark); - } - - [Test] - public void TestRestoreFromLegacyLightModeSelectsLightPalette() - { - var settings = Restore(""); - settings.ThemeMode.Should().Be(ThemeMode.Light); - settings.LogLevelPalette.Should().Be(LogLevelPalette.Light); - } - - [Test] - public void TestRestoreFromInvalidLegacyDarkMode() - { - var settings = Restore(""); - settings.ThemeMode.Should().Be(ThemeMode.Light); - settings.LogLevelPalette.Should().Be(LogLevelPalette.Light); - } - - [Test] - public void TestRestoreMissingPaletteWithDarkThemeSelectsDarkPalette() - { - var settings = Restore(""); - settings.ThemeMode.Should().Be(ThemeMode.Dark); - settings.LogLevelPalette.Should().Be(LogLevelPalette.Dark); - } - - [Test] - public void TestRestoreMissingPaletteWithSystemThemeSelectsLightPalette() - { - var settings = Restore(""); - settings.ThemeMode.Should().Be(ThemeMode.System); - settings.LogLevelPalette.Should().Be(LogLevelPalette.Light); - } } } diff --git a/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs b/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs index 999058c2..f1ae0dc3 100644 --- a/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs +++ b/src/Tailviewer.Tests/Ui/Settings/LogLevelSettingsViewModelTest.cs @@ -17,7 +17,7 @@ public sealed class LogLevelSettingsViewModelTest [Test] public void TestForegroundOnlyChangeInDarkSeedsDarkDefaults() { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Dark); try { var levelSettings = new LogLevelSettings(); @@ -36,14 +36,14 @@ public void TestForegroundOnlyChangeInDarkSeedsDarkDefaults() } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); } } [Test] public void TestBackgroundOnlyChangeInDarkSeedsDarkDefaults() { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Dark); try { var levelSettings = new LogLevelSettings(); @@ -59,14 +59,14 @@ public void TestBackgroundOnlyChangeInDarkSeedsDarkDefaults() } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); } } [Test] public void TestForegroundOnlyChangeInLightSeedsLightDefaults() { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); try { var levelSettings = new LogLevelSettings(); @@ -82,7 +82,7 @@ public void TestForegroundOnlyChangeInLightSeedsLightDefaults() } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); } } @@ -108,7 +108,7 @@ public void TestAlreadyCustomSetForegroundPreservesBackground() [Test] public void TestTransitionCallsSaveAsync() { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Dark); try { var levelSettings = new LogLevelSettings(); @@ -121,7 +121,7 @@ public void TestTransitionCallsSaveAsync() } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); } } } diff --git a/src/Tailviewer.Tests/Ui/SettingsMainPanelViewModelTest.cs b/src/Tailviewer.Tests/Ui/SettingsMainPanelViewModelTest.cs index 4422b24c..4553b758 100644 --- a/src/Tailviewer.Tests/Ui/SettingsMainPanelViewModelTest.cs +++ b/src/Tailviewer.Tests/Ui/SettingsMainPanelViewModelTest.cs @@ -1,10 +1,12 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Windows.Media; using FluentAssertions; using NUnit.Framework; using Tailviewer.Core; using Tailviewer.Settings; +using Tailviewer.Ui; using Tailviewer.Ui.Settings; namespace Tailviewer.Tests.Ui @@ -25,6 +27,61 @@ public void TestConstruction1([Values(true, false)] bool recursive) model.FolderDataSourcePatterns.Should().Be("*.log;*.txt"); } + [Test] + public void TestRestoreDefaultColors() + { + var settings = new ApplicationSettings("foo"); + settings.AllowSave = false; + settings.Ui.ThemeColor = Colors.Red; + settings.Ui.ThemeMode = ThemeMode.Dark; + + foreach (var level in new[] + { + settings.LogViewer.Other, + settings.LogViewer.Trace, + settings.LogViewer.Debug, + settings.LogViewer.Info, + settings.LogViewer.Warning, + settings.LogViewer.Error, + settings.LogViewer.Fatal + }) + { + level.IsCustom = true; + level.ForegroundColor = Colors.Pink; + level.BackgroundColor = Colors.Blue; + } + + var model = new SettingsFlyoutViewModel(settings, new ServiceContainer()); + try + { + model.RestoreDefaultColorsCommand.Execute(null); + + settings.Ui.ThemeColor.Should().Be(UISettings.DefaultThemeColor); + + AssertReset(settings.LogViewer.Other, model.OtherLevel, LogLevelDefaults.Light.Other, LogLevelDefaults.Dark.Other); + AssertReset(settings.LogViewer.Trace, model.TraceLevel, LogLevelDefaults.Light.Trace, LogLevelDefaults.Dark.Trace); + AssertReset(settings.LogViewer.Debug, model.DebugLevel, LogLevelDefaults.Light.Debug, LogLevelDefaults.Dark.Debug); + AssertReset(settings.LogViewer.Info, model.InfoLevel, LogLevelDefaults.Light.Info, LogLevelDefaults.Dark.Info); + AssertReset(settings.LogViewer.Warning, model.WarningLevel, LogLevelDefaults.Light.Warning, LogLevelDefaults.Dark.Warning); + AssertReset(settings.LogViewer.Error, model.ErrorLevel, LogLevelDefaults.Light.Error, LogLevelDefaults.Dark.Error); + AssertReset(settings.LogViewer.Fatal, model.FatalLevel, LogLevelDefaults.Light.Fatal, LogLevelDefaults.Dark.Fatal); + } + finally + { + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); + } + } + + private static void AssertReset(LogLevelSettings settings, LogLevelSettingsViewModel viewModel, LogLevelSettings light, LogLevelSettings dark) + { + settings.IsCustom.Should().BeFalse("because the reset must restore the theme-following state"); + settings.ForegroundColor.Should().Be(light.ForegroundColor); + settings.BackgroundColor.Should().Be(light.BackgroundColor); + + viewModel.ForegroundColor.Should().Be(dark.ForegroundColor); + viewModel.BackgroundColor.Should().Be(dark.BackgroundColor); + } + public static IEnumerable Encodings => new[] {null, Encoding.Default, Encoding.UTF7, Encoding.UTF8}; [Test] @@ -137,4 +194,4 @@ public void TestTestChangeDefaultEncoding() settings.LogFile.DefaultEncoding.Should().Be(newEncoding.Encoding); } } -} \ No newline at end of file +} diff --git a/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs b/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs index 9c0063bd..dce25fa9 100644 --- a/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs +++ b/src/Tailviewer.Tests/Ui/ThemeManagerTest.cs @@ -81,55 +81,65 @@ public void TestResolveDarkModeUndefinedValueFallsBackToLight() } [Test] - public void TestApplyThreeArgumentSetsPalette() + public void TestApplyThemeModeSelectsMatchingPalette() { try { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Dark, LogLevelPalette.Dark); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); + ThemeManager.CurrentDarkMode.Should().BeFalse(); + ThemeManager.CurrentThemeMode.Should().Be(ThemeMode.Light); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Light); + + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Dark); ThemeManager.CurrentDarkMode.Should().BeTrue(); ThemeManager.CurrentThemeMode.Should().Be(ThemeMode.Dark); ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); - - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); - ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Light); } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); } } [Test] - public void TestApplyThemeModeDoesNotChangePalette() + public void TestApplySystemThemeSelectsResolvedPalette() { + var previous = ThemeManager.SystemThemeProvider; try { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); - ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); - - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Dark); + ThemeManager.SystemThemeProvider = new FakeSystemThemeProvider(true); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.System); ThemeManager.CurrentDarkMode.Should().BeTrue(); ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); + + ThemeManager.SystemThemeProvider = new FakeSystemThemeProvider(false); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.System); + ThemeManager.CurrentDarkMode.Should().BeFalse(); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Light); } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.SystemThemeProvider = previous; + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); } } [Test] - public void TestApplyBoolOverloadKeepsPalette() + public void TestApplyBoolOverloadSelectsMatchingPalette() { try { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Dark); + ThemeManager.Apply(UISettings.DefaultThemeColor, true); + ThemeManager.CurrentDarkMode.Should().BeTrue(); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); + ThemeManager.Apply(UISettings.DefaultThemeColor, false); ThemeManager.CurrentDarkMode.Should().BeFalse(); - ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Dark); + ThemeManager.CurrentLogLevelPalette.Should().Be(LogLevelPalette.Light); } finally { - ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light, LogLevelPalette.Light); + ThemeManager.Apply(UISettings.DefaultThemeColor, ThemeMode.Light); } } } diff --git a/src/Tailviewer/App.cs b/src/Tailviewer/App.cs index 3b767fe9..f97c296f 100644 --- a/src/Tailviewer/App.cs +++ b/src/Tailviewer/App.cs @@ -263,7 +263,7 @@ private static int StartApplication(SingleApplicationHelper.IMutex mutex, string }; application.Exit += (sender, e) => systemThemeProvider.Dispose(); - ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.ThemeMode, settings.Ui.LogLevelPalette); + ThemeManager.Apply(settings.Ui.ThemeColor, settings.Ui.ThemeMode); var dispatcher = Dispatcher.CurrentDispatcher; var uiDispatcher = new UiDispatcher(dispatcher); services.RegisterInstance(uiDispatcher); @@ -376,4 +376,4 @@ private static void CurrentDomainOnUnhandledException(object sender, UnhandledEx Constants.MainWindowTitle); } } -} \ No newline at end of file +} diff --git a/src/Tailviewer/Localization/Strings.cs b/src/Tailviewer/Localization/Strings.cs index 91a17c16..62a8fe99 100644 --- a/src/Tailviewer/Localization/Strings.cs +++ b/src/Tailviewer/Localization/Strings.cs @@ -221,6 +221,7 @@ public static class Strings public static string Regexp => _rm.GetString("Regexp") ?? "Regexp"; public static string RemoveMergedDataSource => _rm.GetString("RemoveMergedDataSource") ?? "RemoveMergedDataSource"; public static string ReportBug => _rm.GetString("ReportBug") ?? "ReportBug"; + public static string RestoreDefaultColors => _rm.GetString("RestoreDefaultColors") ?? "RestoreDefaultColors"; public static string RunningLatestVersion => _rm.GetString("RunningLatestVersion") ?? "RunningLatestVersion"; public static string ScreenWasCleared => _rm.GetString("ScreenWasCleared") ?? "ScreenWasCleared"; public static string ScrollSpeed => _rm.GetString("ScrollSpeed") ?? "ScrollSpeed"; @@ -256,7 +257,6 @@ public static class Strings public static string ThemeModeDark => _rm.GetString("ThemeModeDark") ?? "ThemeModeDark"; public static string ThemeModeLight => _rm.GetString("ThemeModeLight") ?? "ThemeModeLight"; public static string ThemeModeSystem => _rm.GetString("ThemeModeSystem") ?? "ThemeModeSystem"; - public static string LogLevelPaletteGroup => _rm.GetString("LogLevelPaletteGroup") ?? "LogLevelPaletteGroup"; public static string TheyDo => _rm.GetString("TheyDo") ?? "TheyDo"; public static string ThisMonth => _rm.GetString("ThisMonth") ?? "ThisMonth"; public static string ThisWeek => _rm.GetString("ThisWeek") ?? "ThisWeek"; diff --git a/src/Tailviewer/Localization/Strings.resx b/src/Tailviewer/Localization/Strings.resx index ed6d104f..7c2fa64f 100644 --- a/src/Tailviewer/Localization/Strings.resx +++ b/src/Tailviewer/Localization/Strings.resx @@ -648,6 +648,9 @@ Try changing your filter(s) or disable them again report a bug! + + Restore default colors + You are running the latest version! @@ -753,9 +756,6 @@ Try changing your filter(s) or disable them again System - - Log level palette - they do diff --git a/src/Tailviewer/Localization/Strings.zh-CN.resx b/src/Tailviewer/Localization/Strings.zh-CN.resx index b0a96a17..3c1429e8 100644 --- a/src/Tailviewer/Localization/Strings.zh-CN.resx +++ b/src/Tailviewer/Localization/Strings.zh-CN.resx @@ -130,7 +130,7 @@ 数据源 - DEBUG数量 + 调试数量 默认文本文件编码 @@ -166,7 +166,7 @@ 输入行号 - ERROR数量 + 错误数量 全部 @@ -187,7 +187,7 @@ 正在导出 - FATAL数量 + 致命数量 新功能: @@ -280,7 +280,7 @@ 包含在组中 - INFO数量 + 信息数量 安装 @@ -325,25 +325,25 @@ 最后时间戳 - DEBUG + 调试 - ERROR + 错误 - FATAL + 致命 - INFO + 信息 - OTHER + 其他 - TRACE + 跟踪 - WARNING + 警告 许可证 @@ -562,7 +562,7 @@ 打开插件文件夹 - OTHER数量 + 其他数量 大纲 @@ -648,6 +648,9 @@ 报告一个 bug! + + 恢复默认配色 + 你正在运行最新版本! @@ -753,9 +756,6 @@ 跟随系统 - - 日志级别配色 - 它们 @@ -835,7 +835,7 @@ 将每一行视为单独的日志条目 - TRACE数量 + 跟踪数量 请尝试更改筛选条件或添加所需的数据源 @@ -874,7 +874,7 @@ 版本: - WARNING数量 + 警告数量 网站 diff --git a/src/Tailviewer/Settings/ILogViewerSettings.cs b/src/Tailviewer/Settings/ILogViewerSettings.cs index eccdf428..0a68848f 100644 --- a/src/Tailviewer/Settings/ILogViewerSettings.cs +++ b/src/Tailviewer/Settings/ILogViewerSettings.cs @@ -54,5 +54,10 @@ public interface ILogViewerSettings /// The settings concerning fatal-level log entries. /// LogLevelSettings Fatal { get; } + + /// + /// Restores all log-level colors to the default theme-following state. + /// + void RestoreDefaultColors(); } -} \ No newline at end of file +} diff --git a/src/Tailviewer/Settings/LogViewerSettings.cs b/src/Tailviewer/Settings/LogViewerSettings.cs index 618d44f2..81decc63 100644 --- a/src/Tailviewer/Settings/LogViewerSettings.cs +++ b/src/Tailviewer/Settings/LogViewerSettings.cs @@ -230,6 +230,21 @@ public void ApplyThemeDefaults(bool darkMode) ApplyLevelDefault(_fatal, defaults.Fatal); } + /// + /// Restores the persisted light-palette baseline and makes every log level + /// follow the currently active light/dark theme again. + /// + public void RestoreDefaultColors() + { + RestoreLevelDefault(_other, LogLevelDefaults.Light.Other); + RestoreLevelDefault(_trace, LogLevelDefaults.Light.Trace); + RestoreLevelDefault(_debug, LogLevelDefaults.Light.Debug); + RestoreLevelDefault(_info, LogLevelDefaults.Light.Info); + RestoreLevelDefault(_warning, LogLevelDefaults.Light.Warning); + RestoreLevelDefault(_error, LogLevelDefaults.Light.Error); + RestoreLevelDefault(_fatal, LogLevelDefaults.Light.Fatal); + } + private static void ApplyLevelDefault(LogLevelSettings level, LogLevelSettings defaults) { if (level.IsCustom) @@ -239,6 +254,13 @@ private static void ApplyLevelDefault(LogLevelSettings level, LogLevelSettings d level.BackgroundColor = defaults.BackgroundColor; } + private static void RestoreLevelDefault(LogLevelSettings level, LogLevelSettings defaults) + { + level.ForegroundColor = defaults.ForegroundColor; + level.BackgroundColor = defaults.BackgroundColor; + level.IsCustom = false; + } + [Pure] public LogViewerSettings Clone() { @@ -262,4 +284,4 @@ object ICloneable.Clone() return Clone(); } } -} \ No newline at end of file +} diff --git a/src/Tailviewer/Settings/UISettings.cs b/src/Tailviewer/Settings/UISettings.cs index 54f8763d..bdc60770 100644 --- a/src/Tailviewer/Settings/UISettings.cs +++ b/src/Tailviewer/Settings/UISettings.cs @@ -14,7 +14,7 @@ public sealed class UISettings public const string DefaultLanguage = "en"; - public static readonly Color DefaultThemeColor = Color.FromRgb(0x0F, 0x62, 0xFE); + public static readonly Color DefaultThemeColor = Color.FromArgb(0xFF, 0x22, 0x22, 0x80); public string Language { get; set; } @@ -22,14 +22,11 @@ public sealed class UISettings public ThemeMode ThemeMode { get; set; } - public LogLevelPalette LogLevelPalette { get; set; } - public UISettings() { Language = DefaultLanguage; ThemeColor = DefaultThemeColor; ThemeMode = ThemeMode.Light; - LogLevelPalette = LogLevelPalette.Light; } [Pure] @@ -39,8 +36,7 @@ public UISettings Clone() { Language = Language, ThemeColor = ThemeColor, - ThemeMode = ThemeMode, - LogLevelPalette = LogLevelPalette + ThemeMode = ThemeMode }; } @@ -49,13 +45,11 @@ public void Save(XmlWriter writer) writer.WriteAttributeString("language", Language ?? DefaultLanguage); writer.WriteAttributeColor("themecolor", ThemeColor); writer.WriteAttributeString("thememode", ThemeMode.ToString()); - writer.WriteAttributeString("loglevelpalette", LogLevelPalette.ToString()); } public void Restore(XmlReader reader) { bool themeModeAttributeSeen = false; - bool logLevelPaletteSeen = false; for (int i = 0; i < reader.AttributeCount; ++i) { @@ -82,14 +76,6 @@ public void Restore(XmlReader reader) } break; - case "loglevelpalette": - logLevelPaletteSeen = true; - if (Enum.TryParse(reader.ReadContentAsString(), true, out LogLevelPalette parsedPalette) && - Enum.IsDefined(typeof(LogLevelPalette), parsedPalette)) - { - LogLevelPalette = parsedPalette; - } - break; } } @@ -109,14 +95,6 @@ public void Restore(XmlReader reader) } } - // Legacy migration: when the new attribute is absent, preserve an - // explicitly dark legacy theme by selecting the dark log palette. - if (!logLevelPaletteSeen) - { - LogLevelPalette = ThemeMode == ThemeMode.Dark - ? LogLevelPalette.Dark - : LogLevelPalette.Light; - } } } } diff --git a/src/Tailviewer/Ui/Settings/LogLevelPaletteOption.cs b/src/Tailviewer/Ui/Settings/LogLevelPaletteOption.cs deleted file mode 100644 index 88df79b5..00000000 --- a/src/Tailviewer/Ui/Settings/LogLevelPaletteOption.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Tailviewer.Settings; - -namespace Tailviewer.Ui.Settings -{ - /// - /// A single selectable log level palette in the settings flyout. - /// - public sealed class LogLevelPaletteOption - { - public LogLevelPaletteOption(LogLevelPalette value, string displayName) - { - Value = value; - DisplayName = displayName; - } - - public LogLevelPalette Value { get; } - - public string DisplayName { get; } - } -} diff --git a/src/Tailviewer/Ui/Settings/SettingsControl.xaml b/src/Tailviewer/Ui/Settings/SettingsControl.xaml index f253e74a..2c45d76c 100644 --- a/src/Tailviewer/Ui/Settings/SettingsControl.xaml +++ b/src/Tailviewer/Ui/Settings/SettingsControl.xaml @@ -342,19 +342,17 @@ DisplayMemberPath="DisplayName" SelectedValuePath="Value" SelectedValue="{Binding ThemeMode, Mode=TwoWay}" /> - - +