Merge pull request #705 from tylerjenningsw/enhance/custom-ui-scale

Settings: allow custom UI scale percentages (#682)
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:02:46 -04:00
commit d7fe6d2e56
7 files changed
+202 -74

No files matched your search

+1 -4
View File
@@ -34,9 +34,6 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("Português (Brasil)", "pt-br"),
];
/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent).
pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")];
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
@@ -1170,7 +1167,7 @@ pub fn pages() -> Vec<Page> {
s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), true),
Section("Labels and Colors"),
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
+3 -5
View File
@@ -88,7 +88,7 @@ fn prefs_set_validates_keys_types_and_ranges() {
}
/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200;
/// the dialog's choices set it as a number.
/// the dialog types or drags any whole percentage in that range (#682).
#[test]
fn ui_scale_is_a_percentage_from_75_to_200() {
let mut p = Prefs::default();
@@ -101,10 +101,8 @@ fn ui_scale_is_a_percentage_from_75_to_200() {
p.set("appearance.uiScale", json!(10)).unwrap();
assert_eq!(p.appearance.ui_scale, 75);
let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap();
let listed = page.items.iter().any(|i| {
matches!(i, crate::prefs::Item::Setting { key: "appearance.uiScale", kind: crate::prefs::Kind::Choice(c), live: true, .. } if c.contains(&("200%", "200")))
});
assert!(listed, "Settings ▸ Appearance offers it");
let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(75, 200, "%"), live: true };
assert!(page.items.contains(&field), "Settings ▸ Appearance offers it");
// Older settings files without it get the default.
assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);
}
+18 -3
View File
@@ -151,9 +151,23 @@ pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value)
}
}
/// The room a modal dialog may take: the window less a margin.
fn modal_space(ctx: &egui::Context) -> egui::Vec2 {
(ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0))
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
let available = (ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0));
let size = size.min(available);
let max_height = (modal_space(ctx).y - 84.0).max(40.0);
modal_fit(ctx, title, size, t, |ui, _| {
egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body);
});
}
/// [`modal`] without its scroll area, for a dialog that keeps its buttons in view and scrolls
/// only its content: `body` gets the height left under the title, with `size` kept within the
/// window.
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
let size = size.min(modal_space(ctx));
egui::Modal::new(egui::Id::new(("modal", title)))
.backdrop_color(Color32::from_black_alpha(120))
.frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18)))
@@ -163,7 +177,8 @@ pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Toke
// The id stays keyed on the untranslated source, so switching language cannot move it.
ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active));
ui.add_space(10.0);
egui::ScrollArea::both().max_height((available.y - 84.0).max(40.0)).auto_shrink([false, true]).show(ui, body);
let title_h = ui.cursor().top() - ui.min_rect().top();
body(ui, (size.y - 36.0 - title_h).max(40.0));
});
}
+41 -31
View File
@@ -10,7 +10,7 @@
use crate::i18n::{tr, tr_args};
use effectcraft_engine::prefs::{APPEARANCE_MODES, DARK_THEMES, Item, Kind, LIGHT_THEMES, Page, pages};
use effectcraft_engine::segment::Task;
use egui::{Color32, RichText, vec2};
use egui::{Color32, Rect, RichText, vec2};
use serde_json::{Value, json};
use crate::theme::{ThemeKind, Tokens};
@@ -118,7 +118,10 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
let r = match kind {
Kind::Int(lo, hi, unit) => {
let mut x = v.as_i64().unwrap_or(0);
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25)));
// A typed number applies once entered: typing 85 into UI Scale
// doesn't resize the interface to 75 % at its first digit.
let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false);
let r = ui.add(crate::widgets::drag_value(drag));
if !unit.is_empty() {
ui.label(RichText::new(unit).color(t.text_dim));
}
@@ -152,7 +155,6 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
r
}
Kind::Choice(opts) => {
// (A number with choices, UI Scale, shows as its text.)
let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string);
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
egui::ComboBox::from_id_salt(("settings", key))
@@ -602,10 +604,40 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let cur = serde_json::to_value(&app.session.prefs).unwrap_or_default();
let mut acts: Vec<Act> = vec![];
let (mut ok, mut cancel) = (false, false);
super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| {
let body_h = (ctx.content_rect().height() - 170.0).clamp(120.0, 470.0);
ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.set_height(body_h);
super::dialogs::modal_fit(ctx, "Settings", vec2(820.0, 600.0), t, |ui, height| {
// The buttons go first, from the bottom up, and the pages get the height they leave: at
// a high UI Scale the pages scroll and the buttons stay in view.
let area = ui.allocate_space(vec2(ui.available_width(), height)).1;
let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::bottom_up(egui::Align::Min)));
foot.horizontal(|ui| {
let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous")));
app.auto.add("settings.previous", r.rect, "Previous");
if r.clicked() {
page = all[idx - 1].id.to_string();
}
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next")));
app.auto.add("settings.next", r.rect, "Next");
if r.clicked() {
page = all[idx + 1].id.to_string();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
app.auto.add("settings.ok", r.rect, "OK");
ok |= r.clicked();
let r = ui.button(tr("Cancel"));
app.auto.add("settings.cancel", r.rect, "Cancel");
cancel |= r.clicked();
});
});
foot.separator();
foot.add_space(8.0);
let body_h = (foot.min_rect().top() - area.top()).max(0.0);
// (A child, clipped to its share, so a wide page can't widen the dialog under the buttons.)
let pages = Rect::from_min_size(area.min, vec2(area.width(), body_h));
let mut body = ui.new_child(egui::UiBuilder::new().max_rect(pages).layout(egui::Layout::left_to_right(egui::Align::Min)));
body.set_clip_rect(pages.intersect(body.clip_rect()));
{
let ui = &mut body;
// Page list (left, like After Effects).
ui.vertical(|ui| {
ui.set_width(180.0);
@@ -640,33 +672,11 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let p = &all[idx];
ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| {
egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| {
page_ui(app, ui, p, &cur, t, &mut acts);
});
});
});
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous")));
app.auto.add("settings.previous", r.rect, "Previous");
if r.clicked() {
page = all[idx - 1].id.to_string();
}
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next")));
app.auto.add("settings.next", r.rect, "Next");
if r.clicked() {
page = all[idx + 1].id.to_string();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
app.auto.add("settings.ok", r.rect, "OK");
ok |= r.clicked();
let r = ui.button(tr("Cancel"));
app.auto.add("settings.cancel", r.rect, "Cancel");
cancel |= r.clicked();
});
});
}
});
app.dialog_state.settings_page = page;
for a in acts {
+129 -26
View File
@@ -2,7 +2,9 @@
//! registers its automation ids; the Footage panel's buttons edit into the comp; the Progress
//! panel lists and cancels a job; Lumetri Scopes switch scope through their dropdown.
use effectcraft_engine::Session;
use std::sync::Arc;
use effectcraft_engine::{Session, config::MemoryConfig};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Event, Pos2, pos2};
@@ -237,46 +239,147 @@ fn leaving_the_learn_workspace_closes_its_tutorials() {
}
}
/// #284: Settings ▸ Appearance ▸ UI Scale sizes the whole interface as soon as it is chosen,
/// Cancel puts it back, OK keeps it; Ctrl+0 (Window ▸ Project) doesn't reset it.
fn open_settings(h: &mut Harness<'_, EffectcraftApp>) {
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "appearance"})).unwrap();
h.run_steps(3);
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: egui::Key, modifiers: egui::Modifiers) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
h.step();
}
}
/// The UI Scale setting and the zoom it gives.
fn scale(h: &Harness<'_, EffectcraftApp>) -> (u32, f32) {
(h.state().session.prefs.appearance.ui_scale, h.ctx.zoom_factor())
}
/// Types `text` into Settings' UI Scale a key at a time: nothing applies (and the field keeps
/// the keyboard) until Enter.
fn type_scale(h: &mut Harness<'_, EffectcraftApp>, text: &str) {
let before = scale(h);
click(h, "settings.appearance.uiScale");
for c in text.chars() {
h.input_mut().events.push(Event::Text(c.to_string()));
h.run_steps(2);
assert_eq!(scale(h), before, "{text}: not while typing");
assert!(h.ctx.memory(|m| m.focused().is_some()), "{text}: the field keeps the keyboard");
}
key(h, egui::Key::Enter, egui::Modifiers::NONE);
// (Settings re-centres a frame after the zoom changes.)
h.run_steps(4);
}
/// #284, #682: Settings ▸ Appearance ▸ UI Scale takes any typed percentage from 75 to 200 and
/// sizes the whole interface once it is entered, Cancel puts it back, OK keeps it and later
/// sessions start with it; Ctrl+0 (Window ▸ Project) doesn't reset it.
#[test]
fn ui_scale_setting_sizes_the_interface() {
let mut h = harness();
let ctx = h.ctx.clone();
let choose = |h: &mut Harness<'_, EffectcraftApp>, label: &str| {
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "appearance"})).unwrap();
h.run_steps(3);
click(h, "settings.appearance.uiScale");
let option = h.query_by_label(label).unwrap_or_else(|| panic!("the {label} choice")).rect().center();
click_at(h, option);
h.run_steps(2);
};
assert_eq!(ctx.zoom_factor(), 1.0);
choose(&mut h, "150%");
assert_eq!(h.state().session.prefs.appearance.ui_scale, 150);
assert_eq!(ctx.zoom_factor(), 1.5, "live");
open_settings(&mut h);
type_scale(&mut h, "85");
assert_eq!(scale(&h), (85, 0.85), "live");
click(&mut h, "settings.cancel");
h.run_steps(2);
assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (100, 1.0), "Cancel put it back");
choose(&mut h, "125%");
assert_eq!(scale(&h), (100, 1.0), "Cancel put it back");
open_settings(&mut h);
type_scale(&mut h, "300");
assert_eq!(scale(&h), (200, 2.0), "at most 200");
click(&mut h, "settings.cancel");
h.run_steps(2);
assert_eq!(scale(&h), (100, 1.0), "Cancel put it back from 200 %");
open_settings(&mut h);
type_scale(&mut h, "50");
assert_eq!(scale(&h), (75, 0.75), "at least 75");
type_scale(&mut h, "90");
click(&mut h, "settings.ok");
h.run_steps(2);
assert_eq!(h.state().dialog, None);
assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (125, 1.25), "OK kept it");
assert_eq!(scale(&h), (90, 0.9), "OK kept it");
// Ctrl+0 opens the Project panel; egui's own Ctrl+0 zoom reset stays out of it.
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key: egui::Key::Num0, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.step();
}
key(&mut h, egui::Key::Num0, egui::Modifiers::COMMAND);
h.run_steps(2);
assert_eq!(ctx.zoom_factor(), 1.25);
assert_eq!(ctx.zoom_factor(), 0.9);
// A saved scale applies from the first frame.
let mut s = Session::default();
s.prefs.set("appearance.uiScale", json!(175)).unwrap();
let s = Session { prefs: effectcraft_engine::prefs::Prefs::from_json(&h.state().session.prefs.to_json()), ..Default::default() };
let mut h2 = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h2.run_steps(3);
assert_eq!(h2.ctx.zoom_factor(), 1.75);
assert_eq!(h2.ctx.zoom_factor(), 0.9);
}
/// Settings' Previous / Next / Cancel / OK stay whole and in view at 200 % in a 1600×1000
/// window (they scrolled out of the dialog with its pages): the page scrolls under them, Cancel
/// restores the settings and OK saves them.
#[test]
fn settings_buttons_stay_in_view_at_200_percent() {
let store = Arc::new(MemoryConfig::default());
let saved = || {
let mut s = Session { config: Some(store.clone()), ..Default::default() };
s.prefs.disk.disk_cache_enabled = false;
s.load_settings();
s.prefs.appearance.ui_scale
};
let mut s = Session { config: Some(store.clone()), ..Default::default() };
s.prefs.disk.disk_cache_enabled = false;
s.prefs.set("appearance.uiScale", json!(200)).unwrap();
s.save_prefs();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
assert_eq!(scale(&h), (200, 2.0));
let rect = |h: &Harness<'_, EffectcraftApp>, id: &str| {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
};
// Whole: inside the window, and painted with none of them clipped away.
let buttons = |h: &Harness<'_, EffectcraftApp>| {
["previous", "next", "cancel", "ok"].map(|b| {
let r = rect(h, &format!("settings.{b}"));
let clip = h
.output()
.shapes
.iter()
.find(|c| matches!(&c.shape, egui::Shape::Rect(s) if (s.rect.center() - r.center()).length() < 0.5))
.map(|c| c.clip_rect);
assert!(h.ctx.content_rect().contains_rect(r) && clip.is_some_and(|c| c.contains_rect(r)), "{b} is cut off: {r:?} in {clip:?}");
r
})
};
let scroll_page = |h: &mut Harness<'_, EffectcraftApp>| {
let at = rect(h, "settings.appearance.appearanceMode").center();
h.input_mut().events.push(Event::PointerMoved(at));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -1000.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
});
h.run_steps(5);
};
open_settings(&mut h);
let footer = buttons(&h);
let field = rect(&h, "settings.appearance.uiScale");
scroll_page(&mut h);
assert_eq!(buttons(&h), footer, "the buttons don't scroll with the page");
let scrolled = rect(&h, "settings.appearance.uiScale");
assert!(scrolled.top() < field.top() && scrolled.bottom() < footer[2].top(), "the page scrolled to UI Scale: {field:?} -> {scrolled:?}");
for (button, kept) in [("settings.cancel", 200), ("settings.ok", 150)] {
if h.state().dialog.is_none() {
open_settings(&mut h);
scroll_page(&mut h);
}
type_scale(&mut h, "150");
assert_eq!(scale(&h), (150, 1.5));
buttons(&h);
click(&mut h, button);
h.run_steps(2);
assert_eq!(h.state().dialog, None, "{button} closed Settings");
assert_eq!((scale(&h), saved()), ((kept, kept as f32 / 100.0), kept), "{button}");
}
}
/// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with
+7 -3
View File
@@ -47,11 +47,15 @@ fn check_info_transaction(via_escape: bool) {
let edited_brightness = h.state().session.prefs.appearance.brightness;
assert!((edited_brightness - original_brightness).abs() > 0.01, "real brightness slider changed");
assert_eq!(saved(&store).prefs.appearance.brightness, original_brightness, "unconfirmed edit is not saved");
// Use the upstream percent-valued Scale choice; keyboard zoom is deliberately disabled.
// Type a UI Scale; keyboard zoom is deliberately disabled.
let at = rect(&h, "settings.appearance.uiScale").center();
click(&mut h, at);
let at = h.get_by_label("125%").rect().center();
click(&mut h, at);
h.event(Event::Text("125".into()));
for pressed in [true, false] {
h.event(Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
assert_eq!(h.state().session.prefs.appearance.ui_scale, 125);
assert_eq!(saved(&store).prefs.appearance.ui_scale, 100, "unconfirmed scale edit is not saved");
// Let UI Scale settle the centered dialog before reading its next hit target.
+3 -2
View File
@@ -59,8 +59,9 @@ Commands (CLI, MCP, control channel):
- `appearance.lightTheme`: Light Theme (`light`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme.
- `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use.
- `appearance.brightness`: Brightness
- `appearance.uiScale`: UI Scale, the size of the whole interface (75–200 %, on top of the
display's own scaling), applied as soon as it is chosen
- `appearance.uiScale`: UI Scale, the size of the whole interface (any whole percentage from
75 to 200, on top of the display's own scaling), dragged or typed; it applies as soon as it is
dragged or entered
- `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths
- `grids.gridColor`: Color
- `grids.gridSpacing`: Gridline Every