Skip to content

Commit e8bbe33

Browse files
TranscriptionFactoryzerx-lab
andauthored
feat: per-window themes ("This window" theme scope) (#279)
* feat: per-window themes via ambient render window Let a user set a theme for one window without affecting others, persisted across restart. The active theme stays global by default; a per-window override is resolved during that window's render pass. Mechanism: an ambient "current render window" thread-local in warpui_core, set by the 3 render chokepoints (build_scene/render_view/render_views) via an RAII guard. Appearance::theme()/ui_builder() consult it to resolve a per-window override from new HashMaps, with a zero-cost is_empty() fast path so the feature costs nothing when unused. (The plan called for setting the ambient from Appearance directly in warpui_core, but warpui_core cannot depend on warp_core/Appearance, so the ambient lives in warpui_core and Appearance reads it.) - Appearance: theme_overrides/ui_builder_overrides maps; set/clear_window_theme invalidate only the target window. - AppearanceManager: set/clear_window_theme; Workspace::on_window_closed clears the override so maps don't leak. - Theme chooser: "All windows"/"This window" scope toggle (default All); "This window" routes to the per-window path and records the override on the Workspace for persistence. - Persistence: theme_override TEXT column (migration + schema + model), saved via serde_json so ThemeKind::Custom round-trips, re-applied on window restore. Verification: warp_core, warpui_core and persistence compile cleanly and a new Appearance unit test covers override resolution + the fast path. The warp app crate could not be built/tested in this environment (xcrun "metal" toolchain unavailable; wasm fallback blocked by clang) -- run ./script/presubmit to verify the app crate, clippy/fmt, and the new test. (cherry picked from commit 80f5c59) * fix: restore per-window theme override when theme preview is cancelled (#279) revert_theme cleared the global transient preview but left a 'This window' scope override in place, since per-window previews write straight into the override map with no transient layer. Capture the window's override when the chooser opens and restore (or clear) it on the revert path, mirroring clear_transient_theme for the global preview. Addresses zerx-lab review on PR #279. Could not full-build to verify (Metal toolchain absent locally); change is type/borrow-checked against the API. * fix(theme): keep_theme 同步 per-window override,避免重开主题选择器后丢失窗口主题 --------- Co-authored-by: zerx-lab <bot@zerx-lab.dev>
1 parent 77bd970 commit e8bbe33

15 files changed

Lines changed: 426 additions & 12 deletions

File tree

app/src/app_state.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use crate::server::ids::SyncId;
1919
use crate::settings_view::SettingsSection;
2020
use crate::tab::SelectedTabColor;
2121
use crate::terminal::ShellLaunchData;
22-
use crate::themes::theme::AnsiColorIdentifier;
22+
use crate::themes::theme::{AnsiColorIdentifier, ThemeKind};
2323
use crate::workspace::view::left_panel::ToolPanelView;
2424
use crate::workspace::WorkspaceRegistry;
2525
use warpui::SingletonEntity as _;
@@ -57,6 +57,9 @@ pub struct WindowSnapshot {
5757
pub left_panel_width: Option<f32>,
5858
pub right_panel_width: Option<f32>,
5959
pub agent_management_filters: Option<PersistedAgentManagementFilters>,
60+
/// The per-window theme override for this window, if the user set one via the
61+
/// theme chooser's "This window" scope. Re-applied on restore.
62+
pub theme_override: Option<ThemeKind>,
6063
}
6164

6265
#[derive(Clone, Debug, PartialEq)]

app/src/appearance.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use settings::Setting as _;
22
use warpui::{
33
fonts::FamilyId, AddSingletonModel, AppContext, AssetProvider, Entity, ModelContext,
4-
SingletonEntity,
4+
SingletonEntity, WindowId,
55
};
66

77
#[cfg(target_os = "macos")]
@@ -202,6 +202,29 @@ impl AppearanceManager {
202202
self.refresh_theme_state(ctx);
203203
}
204204

205+
/// Applies a per-window theme override, affecting only `window_id`. Windows
206+
/// without an override continue to follow the global theme (including
207+
/// system-theme follow via `refresh_theme_state`).
208+
pub fn set_window_theme(
209+
&mut self,
210+
window_id: WindowId,
211+
theme_kind: ThemeKind,
212+
ctx: &mut ModelContext<Self>,
213+
) {
214+
let theme = Settings::theme_for_theme_kind(&theme_kind, ctx);
215+
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
216+
appearance.set_window_theme(window_id, theme, ctx);
217+
});
218+
}
219+
220+
/// Clears a per-window theme override, returning `window_id` to the global
221+
/// theme. No-op if the window has no override.
222+
pub fn clear_window_theme(&mut self, window_id: WindowId, ctx: &mut ModelContext<Self>) {
223+
Appearance::handle(ctx).update(ctx, |appearance, ctx| {
224+
appearance.clear_window_theme(window_id, ctx);
225+
});
226+
}
227+
205228
#[cfg(target_os = "macos")]
206229
pub fn app_icon_at_startup(&self) -> AppIcon {
207230
self.app_icon_at_startup

app/src/launch_configs/launch_config_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ fn single_tab_snapshot(root: PaneNodeSnapshot) -> AppState {
3535
left_panel_width: None,
3636
right_panel_width: None,
3737
agent_management_filters: None,
38+
theme_override: None,
3839
}],
3940
active_window_index: Some(0),
4041
block_lists: Default::default(),
@@ -59,6 +60,7 @@ fn multi_tab_snapshot(active_tab_index: usize, tabs: Vec<TabSnapshot>) -> AppSta
5960
left_panel_width: None,
6061
right_panel_width: None,
6162
agent_management_filters: None,
63+
theme_override: None,
6264
}],
6365
active_window_index: Some(0),
6466
block_lists: Default::default(),

app/src/persistence/sqlite.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,12 @@ fn save_app_state(conn: &mut SqliteConnection, app_state: &AppState) -> Result<(
10801080
.agent_management_filters
10811081
.as_ref()
10821082
.and_then(|f| serde_json::to_string(f).ok()),
1083+
// Serialize with serde (not Display) so the `ThemeKind::Custom`
1084+
// variant round-trips.
1085+
theme_override: window
1086+
.theme_override
1087+
.as_ref()
1088+
.and_then(|k| serde_json::to_string(k).ok()),
10831089
};
10841090
diesel::insert_into(schema::windows::dsl::windows)
10851091
.values(new_window)
@@ -2848,6 +2854,9 @@ fn read_sqlite_data(
28482854
agent_management_filters: window
28492855
.agent_management_filters
28502856
.and_then(|s| serde_json::from_str(&s).ok()),
2857+
theme_override: window
2858+
.theme_override
2859+
.and_then(|s| serde_json::from_str(&s).ok()),
28512860
}
28522861
})
28532862
.collect();

app/src/persistence/sqlite_tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ fn test_terminal_window_snapshot(vertical_tabs_panel_open: bool) -> WindowSnapsh
155155
left_panel_width: None,
156156
right_panel_width: None,
157157
agent_management_filters: None,
158+
theme_override: None,
158159
}
159160
}
160161

@@ -238,6 +239,7 @@ fn test_sqlite_round_trips_custom_vertical_tabs_title() {
238239
left_panel_width: None,
239240
right_panel_width: None,
240241
agent_management_filters: None,
242+
theme_override: None,
241243
}],
242244
active_window_index: Some(0),
243245
block_lists: Default::default(),
@@ -310,6 +312,7 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() {
310312
left_panel_width: None,
311313
right_panel_width: None,
312314
agent_management_filters: None,
315+
theme_override: None,
313316
}],
314317
active_window_index: Some(0),
315318
block_lists: Default::default(),

app/src/themes/theme_chooser.rs

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use warpui::{
2222
};
2323

2424
use crate::resource_center::{mark_feature_used_and_write_to_user_defaults, Tip, TipAction};
25+
use crate::ui_components::tab_selector::{render_tab_selector, SettingsTab};
2526
use crate::themes::theme::{RespectSystemTheme, ThemeKind, WarpTheme};
2627
use crate::util::traffic_lights::traffic_light_data;
2728
use crate::workspace::PANEL_HEADER_HEIGHT;
@@ -67,13 +68,51 @@ const THEME_CHOOSER_ITEM_PADDING: f32 = 16.;
6768
struct MouseStateHandles {
6869
create_theme_button_hover_state: MouseStateHandle,
6970
close_button_mouse_state: MouseStateHandle,
71+
scope_all_windows_mouse_state: MouseStateHandle,
72+
scope_this_window_mouse_state: MouseStateHandle,
7073
}
7174

7275
pub enum ThemeChooserEvent {
7376
Click,
7477
Close(ThemeChooserMode),
7578
OpenThemeCreatorModal,
7679
OpenThemeDeletionModal(ThemeKind),
80+
/// The per-window theme override for this window changed. `Some(kind)` when
81+
/// the user committed a "This window" theme; `None` when the window was
82+
/// returned to the global theme. The owning `Workspace` records this for
83+
/// persistence.
84+
WindowThemeOverride(Option<ThemeKind>),
85+
}
86+
87+
/// Whether a theme selection applies to every window (the global theme setting)
88+
/// or only the current window (a per-window override).
89+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
90+
pub enum ThemeScope {
91+
/// Change the global theme; affects all windows without an override.
92+
#[default]
93+
AllWindows,
94+
/// Change only the current window via a per-window override.
95+
ThisWindow,
96+
}
97+
98+
impl ThemeScope {
99+
const ALL_WINDOWS_LABEL: &'static str = "All windows";
100+
const THIS_WINDOW_LABEL: &'static str = "This window";
101+
102+
fn label(self) -> &'static str {
103+
match self {
104+
ThemeScope::AllWindows => Self::ALL_WINDOWS_LABEL,
105+
ThemeScope::ThisWindow => Self::THIS_WINDOW_LABEL,
106+
}
107+
}
108+
109+
fn from_label(label: &str) -> Option<Self> {
110+
match label {
111+
Self::ALL_WINDOWS_LABEL => Some(ThemeScope::AllWindows),
112+
Self::THIS_WINDOW_LABEL => Some(ThemeScope::ThisWindow),
113+
_ => None,
114+
}
115+
}
77116
}
78117

79118
#[derive(Clone, Copy, Debug)]
@@ -145,6 +184,8 @@ pub struct ThemeChooser {
145184
themes: Tracked<Vec<ThemeChooserItem>>,
146185
filtered_themes: Tracked<Option<Vec<ThemeChooserItem>>>,
147186
mode: ThemeChooserMode,
187+
/// Whether selections apply globally or only to this window.
188+
scope: ThemeScope,
148189
search_editor: ViewHandle<EditorView>,
149190
tips_completed: ModelHandle<TipsCompleted>,
150191
window_id: warpui::WindowId,
@@ -159,6 +200,7 @@ pub enum ThemeChooserAction {
159200
Down,
160201
OpenThemeCreator,
161202
OpenThemeDeletionModal(ThemeKind),
203+
SetScope(ThemeScope),
162204
}
163205

164206
pub fn init(app: &mut AppContext) {
@@ -234,6 +276,7 @@ impl ThemeChooser {
234276
selected_theme: Tracked::new(None),
235277
filtered_themes: Tracked::new(None),
236278
mode: ThemeChooserMode::for_active_theme(ctx),
279+
scope: ThemeScope::default(),
237280
search_editor,
238281
tips_completed,
239282
window_id: ctx.window_id(),
@@ -400,6 +443,17 @@ impl ThemeChooser {
400443
},
401444
ctx
402445
);
446+
447+
// In "This window" scope, the per-window override was already applied by
448+
// `select_theme`; record it on the owning workspace for persistence and
449+
// skip writing the global theme setting.
450+
if self.scope == ThemeScope::ThisWindow {
451+
ctx.emit(ThemeChooserEvent::WindowThemeOverride(Some(
452+
selected_kind.clone(),
453+
)));
454+
return;
455+
}
456+
403457
let theme_settings = ThemeSettings::handle(ctx);
404458

405459
let selected_themes = respect_system_theme(theme_settings.as_ref(ctx))
@@ -437,6 +491,35 @@ impl ThemeChooser {
437491
});
438492
}
439493
};
494+
495+
// Choosing a theme for all windows clears this window's per-window
496+
// override (if any) so it follows the global theme again.
497+
let window_id = self.window_id;
498+
AppearanceManager::handle(ctx).update(ctx, |appearance_manager, ctx| {
499+
appearance_manager.clear_window_theme(window_id, ctx);
500+
});
501+
ctx.emit(ThemeChooserEvent::WindowThemeOverride(None));
502+
}
503+
504+
/// Switches the selection scope (all windows vs. this window). Switching to
505+
/// "All windows" immediately drops this window's override so it rejoins the
506+
/// global theme. Switching to "This window" defers until the user picks a
507+
/// theme.
508+
fn set_scope(&mut self, scope: ThemeScope, ctx: &mut ViewContext<Self>) {
509+
if self.scope == scope {
510+
return;
511+
}
512+
self.scope = scope;
513+
514+
if scope == ThemeScope::AllWindows {
515+
let window_id = self.window_id;
516+
AppearanceManager::handle(ctx).update(ctx, |appearance_manager, ctx| {
517+
appearance_manager.clear_window_theme(window_id, ctx);
518+
});
519+
ctx.emit(ThemeChooserEvent::WindowThemeOverride(None));
520+
}
521+
522+
ctx.notify();
440523
}
441524

442525
fn theme_position(&self, kind: ThemeKind) -> Option<usize> {
@@ -463,8 +546,14 @@ impl ThemeChooser {
463546
ctx.notify();
464547
});
465548

466-
AppearanceManager::handle(ctx).update(ctx, |appearance_manager, ctx| {
467-
appearance_manager.set_transient_theme(kind, ctx);
549+
// Preview the selection. In "All windows" scope this is a global transient
550+
// preview; in "This window" scope it applies a per-window override so only
551+
// the current window changes.
552+
let scope = self.scope;
553+
let window_id = self.window_id;
554+
AppearanceManager::handle(ctx).update(ctx, |appearance_manager, ctx| match scope {
555+
ThemeScope::AllWindows => appearance_manager.set_transient_theme(kind, ctx),
556+
ThemeScope::ThisWindow => appearance_manager.set_window_theme(window_id, kind, ctx),
468557
});
469558
}
470559

@@ -660,6 +749,39 @@ impl ThemeChooser {
660749
.finish()
661750
}
662751

752+
fn render_scope_selector(&self, appearance: &Appearance) -> Box<dyn Element> {
753+
let tabs = vec![
754+
SettingsTab::new(
755+
ThemeScope::AllWindows.label(),
756+
self.button_mouse_states
757+
.scope_all_windows_mouse_state
758+
.clone(),
759+
),
760+
SettingsTab::new(
761+
ThemeScope::ThisWindow.label(),
762+
self.button_mouse_states
763+
.scope_this_window_mouse_state
764+
.clone(),
765+
),
766+
];
767+
768+
let selector = render_tab_selector(
769+
tabs,
770+
self.scope.label(),
771+
|label, ctx| {
772+
if let Some(scope) = ThemeScope::from_label(label) {
773+
ctx.dispatch_typed_action(ThemeChooserAction::SetScope(scope));
774+
}
775+
},
776+
appearance,
777+
);
778+
779+
Container::new(selector)
780+
.with_margin_left(TITLE_MARGIN)
781+
.with_margin_right(TITLE_MARGIN)
782+
.finish()
783+
}
784+
663785
fn render_search_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
664786
Container::new(
665787
Flex::row()
@@ -806,6 +928,7 @@ impl TypedActionView for ThemeChooser {
806928
Enter => self.enter(ctx),
807929
OpenThemeCreator => self.open_theme_creator_modal(ctx),
808930
OpenThemeDeletionModal(kind) => self.open_theme_deletion_modal(kind.clone(), ctx),
931+
SetScope(scope) => self.set_scope(*scope, ctx),
809932
}
810933
}
811934
}
@@ -831,6 +954,7 @@ impl View for ThemeChooser {
831954
Flex::column()
832955
.with_child(self.render_header(traffic_light_data.as_ref(), appearance, app))
833956
.with_child(self.render_title_row(appearance))
957+
.with_child(self.render_scope_selector(appearance))
834958
.with_child(self.mode.render_hint_text(appearance))
835959
.with_child(self.render_search_bar(appearance))
836960
.with_child(self.render_list(appearance))

0 commit comments

Comments
 (0)