Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ fn main() -> eframe::Result<()> {
if updated {
app.session.status = format!("Updated to PlotX {}.", env!("CARGO_PKG_VERSION"));
// Stamp the new version so the notice shows only once.
plotx_core::settings::update(|_| {});
app.settings.app_version = env!("CARGO_PKG_VERSION").to_owned();
app.persist_settings();
}
if let Some(notice) = crash_notice {
if updated {
Expand Down
93 changes: 60 additions & 33 deletions crates/app/src/scale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,19 @@ impl ScaleDriver {
.input(|i| i.viewport().native_pixels_per_point)
.unwrap_or(1.0);
let auto = auto_zoom(probe.ppi, native_ppp);
let mut scale = MonitorScale { auto, user: None };
let save_result = settings::try_update(|settings| {
let entry = settings
let scale = {
let entry = app
.settings
.appearance
.ui_scale
.monitors
.entry(probe.key.clone())
.or_insert(scale);
.or_insert(MonitorScale { auto, user: None });
// Refresh `auto` on every sight: same key means same hardware, but
// the derivation itself may have changed between app versions.
entry.auto = auto;
scale = *entry;
});
*entry
};

let first_sight = self.current.is_none();
self.current = Some(probe.token);
Expand All @@ -125,9 +125,7 @@ impl ScaleDriver {
user: scale.user,
ppi: probe.ppi,
});
if let Err(error) = save_result {
app.session.status = format!("Could not save display scale settings: {error}");
}
app.persist_settings();
let zoom = scale.effective();
if (ctx.zoom_factor() - zoom).abs() > f32::EPSILON {
ctx.set_zoom_factor(zoom);
Expand Down Expand Up @@ -179,43 +177,41 @@ pub(crate) fn reset_ui_zoom(app: &mut PlotxApp, ctx: &egui::Context) {
}

fn set_ui_zoom(app: &mut PlotxApp, ctx: &egui::Context, user: Option<f32>) {
set_ui_zoom_with(app, ctx, user, persist_ui_zoom);
set_ui_zoom_with(app, ctx, user, PlotxApp::persist_settings);
}

/// The flush is a parameter so a test can prove the zoom commands persist at
/// all, and that they do it *after* the optimistic status — `apply_ui_zoom`
/// alone would leave the shortcuts session-only with the suite still green.
fn set_ui_zoom_with(
app: &mut PlotxApp,
ctx: &egui::Context,
user: Option<f32>,
persist: impl FnOnce(&str, f32, Option<f32>) -> std::io::Result<()>,
persist: impl FnOnce(&mut PlotxApp) -> bool,
) {
apply_ui_zoom(app, ctx, user);
persist(app);
}

fn apply_ui_zoom(app: &mut PlotxApp, ctx: &egui::Context, user: Option<f32>) {
let Some(status) = app.session.monitor.as_ref() else {
return;
};
let key = status.key.clone();
let auto = status.auto;
let save_result = persist(&key, auto, user);
update_monitor_override(&mut app.settings, &key, auto, user);
let status = app.session.monitor.as_mut().expect("status checked above");
status.user = user;
let zoom = status.effective();
ctx.set_zoom_factor(zoom);
app.session.status = match (user, save_result) {
(Some(_), Ok(())) => format!("UI scale {:.0}% on this display.", zoom * 100.0),
(None, Ok(())) => {
app.session.status = match user {
Some(_) => format!("UI scale {:.0}% on this display.", zoom * 100.0),
None => {
format!("UI scale automatic ({:.0}%) on this display.", zoom * 100.0)
}
(_, Err(error)) => format!(
"UI scale changed to {:.0}% for this session, but could not save it: {error}",
zoom * 100.0
),
};
}

fn persist_ui_zoom(key: &str, auto: f32, user: Option<f32>) -> std::io::Result<()> {
settings::try_update(|settings| {
update_monitor_override(settings, key, auto, user);
})
}

fn update_monitor_override(
settings: &mut settings::Settings,
key: &str,
Expand Down Expand Up @@ -390,7 +386,26 @@ mod tests {
}

#[test]
fn failed_override_save_is_visible_and_keeps_session_zoom() {
fn override_updates_live_settings_and_session_zoom() {
let mut app = PlotxApp::new_with_settings(settings::Settings::default());
app.session.monitor = Some(MonitorScaleStatus {
key: "test-monitor".to_owned(),
auto: 1.0,
user: None,
ppi: None,
});
let ctx = egui::Context::default();
apply_ui_zoom(&mut app, &ctx, Some(1.5));

assert_eq!(app.session.monitor.as_ref().unwrap().user, Some(1.5));
assert_eq!(
app.settings.appearance.ui_scale.monitors["test-monitor"].user,
Some(1.5)
);
}

#[test]
fn a_zoom_command_flushes_the_override_and_keeps_a_failure_visible() {
let mut app = PlotxApp::new_with_settings(settings::Settings::default());
app.session.monitor = Some(MonitorScaleStatus {
key: "test-monitor".to_owned(),
Expand All @@ -399,16 +414,28 @@ mod tests {
ppi: None,
});
let ctx = egui::Context::default();
set_ui_zoom_with(&mut app, &ctx, Some(1.5), |_, _, _| {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"read-only settings",
))
let mut flushed = false;
set_ui_zoom_with(&mut app, &ctx, Some(1.5), |app| {
flushed = true;
// What the real flush does when the file cannot be written.
app.session.status =
"Couldn't save preferences — changes apply this session only (test)".to_owned();
false
});

assert!(flushed, "a zoom command must flush the override");
assert_eq!(
app.settings.appearance.ui_scale.monitors["test-monitor"].user,
Some(1.5)
);
// The zoom itself still applies, and the write failure survives the
// optimistic "UI scale 150%" line rather than replacing it.
assert_eq!(app.session.monitor.as_ref().unwrap().user, Some(1.5));
assert!(app.session.status.contains("could not save it"));
assert!(app.session.status.contains("read-only settings"));
assert!(
app.session.status.contains("Couldn't save preferences"),
"{}",
app.session.status
);
}

#[test]
Expand Down
4 changes: 2 additions & 2 deletions crates/app/src/ui/canvas/tiling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ pub(crate) fn update_tile_drop(
.clamp(0.0, 1.0),
],
});
app.session.status = if app.keep_empty_source_canvas {
app.session.status = if app.settings.general.keep_empty_source_canvas {
"Hold Alt to remove the empty source canvas.".into()
} else {
"Hold Alt to keep the empty source canvas.".into()
Expand Down Expand Up @@ -154,7 +154,7 @@ pub(crate) fn commit_tile_drop(
preview: TileDropPreview,
alt: bool,
) {
let remove_empty_source = app.keep_empty_source_canvas == alt;
let remove_empty_source = app.settings.general.keep_empty_source_canvas == alt;
let source_becomes_empty =
app.doc.canvases.get(drag.canvas).is_some_and(|canvas| {
canvas.objects.len() == 1 && canvas.object(drag.object).is_some()
Expand Down
53 changes: 16 additions & 37 deletions crates/app/src/ui/canvas_size.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use egui::{Response, RichText, Ui};
use egui_phosphor::regular as icon;
use plotx_core::actions::{Action, PageSizeState, PendingCanvasSizeEdit};
use plotx_core::settings::{self, CanvasSizeDefaults, CustomSizePreset};
use plotx_core::settings::{CanvasSizeDefaults, CustomSizePreset};
use plotx_core::state::{
CanvasSizeUnit, MM_TO_PT, PlotxApp, SizePreset, SizePresetGroup, content_bounds_pt,
content_overflows, matching_preset, size_display_name, size_presets, wider_preset_suggestion,
Expand Down Expand Up @@ -52,7 +52,7 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) {
});

let filter = search.trim().to_lowercase();
let defaults = canvas_size_defaults(ui.ctx());
let defaults = app.settings.canvas_size.clone();
egui::ScrollArea::vertical()
.max_height(170.0)
.show(ui, |ui| {
Expand Down Expand Up @@ -138,13 +138,13 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) {

match choice {
Some(SizeChoice::Preset(preset)) => apply_preset(app, ui.ctx(), ci, preset),
Some(SizeChoice::Custom(after)) => apply_size(app, ui.ctx(), ci, after, None),
Some(SizeChoice::Custom(after)) => apply_size(app, ci, after, None),
Some(SizeChoice::Swap) => {
let after = [size[1], size[0]];
apply_size(app, ui.ctx(), ci, after, matched.filter(|p| p.is_fixed()));
apply_size(app, ci, after, matched.filter(|p| p.is_fixed()));
}
Some(SizeChoice::DeleteCustom(index)) => {
update_canvas_size_defaults(ui.ctx(), |d| {
update_canvas_size_defaults(app, |d| {
if index < d.custom_presets.len() {
d.custom_presets.remove(index);
}
Expand Down Expand Up @@ -191,7 +191,7 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) {
ui.label(unit.label());
});

let defaults = canvas_size_defaults(ui.ctx());
let defaults = app.settings.canvas_size.clone();
let mut scale_content = defaults.scale_content;
if ui
.checkbox(&mut scale_content, "Scale content when applying sizes")
Expand All @@ -202,7 +202,7 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) {
)
.changed()
{
update_canvas_size_defaults(ui.ctx(), |d| d.scale_content = scale_content);
update_canvas_size_defaults(app, |d| d.scale_content = scale_content);
}

let mut auto = auto_height;
Expand Down Expand Up @@ -246,7 +246,7 @@ pub(crate) fn size_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) {
.on_hover_text("Keep the current page size in the preset list, across sessions.")
.clicked()
{
update_canvas_size_defaults(ui.ctx(), |d| {
update_canvas_size_defaults(app, |d| {
let exists = d.custom_presets.iter().any(|c| {
(c.width_mm - size[0]).abs() < 0.01 && (c.height_mm - size[1]).abs() < 0.01
});
Expand Down Expand Up @@ -285,7 +285,7 @@ fn preset_row(ui: &mut Ui, preset: &'static SizePreset, matched: Option<&SizePre
/// used only for an empty page).
pub(crate) fn apply_preset(
app: &mut PlotxApp,
ctx: &egui::Context,
_ctx: &egui::Context,
ci: usize,
preset: &'static SizePreset,
) {
Expand All @@ -295,20 +295,19 @@ pub(crate) fn apply_preset(
let current = canvas.size_mm;
let after = if preset.is_fixed() || canvas.objects.is_empty() {
preset.size_mm()
} else if canvas_size_defaults(ctx).scale_content {
} else if app.settings.canvas_size.scale_content {
[
preset.width_mm,
(current[1] * preset.width_mm / current[0]).clamp(10.0, 1000.0),
]
} else {
[preset.width_mm, current[1]]
};
apply_size(app, ctx, ci, after, Some(preset));
apply_size(app, ci, after, Some(preset));
}

fn apply_size(
app: &mut PlotxApp,
ctx: &egui::Context,
ci: usize,
after_size: [f32; 2],
preset: Option<&'static SizePreset>,
Expand All @@ -322,15 +321,15 @@ fn apply_size(
preset_id: preset.map(|p| p.id.to_owned()),
};
if before != after {
let action = if canvas_size_defaults(ctx).scale_content {
let action = if app.settings.canvas_size.scale_content {
Action::set_canvas_size_scaling_content(app, ci, after)
} else {
Action::set_canvas_size(ci, before, after)
};
app.execute_action(action);
}
if let Some(preset) = preset {
update_canvas_size_defaults(ctx, |d| d.note_recent(preset.id));
update_canvas_size_defaults(app, |d| d.note_recent(preset.id));
}
}

Expand Down Expand Up @@ -509,29 +508,9 @@ fn suggestion_dismissed(ctx: &egui::Context, resource_id: &str, preset: &SizePre
.unwrap_or(false)
}

/// The sticky canvas-size choices, cached in egui memory so the per-frame UI
/// never re-reads the settings file; writes go through
/// [`update_canvas_size_defaults`], which refreshes the cache and persists.
fn canvas_size_defaults(ctx: &egui::Context) -> CanvasSizeDefaults {
let id = egui::Id::new("canvas_size_defaults_cache");
if let Some(cached) = ctx.data(|d| d.get_temp::<CanvasSizeDefaults>(id)) {
return cached;
}
let loaded = settings::load().canvas_size;
ctx.data_mut(|d| d.insert_temp(id, loaded.clone()));
loaded
}

fn update_canvas_size_defaults(ctx: &egui::Context, f: impl FnOnce(&mut CanvasSizeDefaults)) {
let mut defaults = canvas_size_defaults(ctx);
f(&mut defaults);
ctx.data_mut(|d| {
d.insert_temp(
egui::Id::new("canvas_size_defaults_cache"),
defaults.clone(),
)
});
settings::update(|s| s.canvas_size = defaults);
fn update_canvas_size_defaults(app: &mut PlotxApp, f: impl FnOnce(&mut CanvasSizeDefaults)) {
f(&mut app.settings.canvas_size);
app.persist_settings();
}

fn handle_canvas_dimension_response(
Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/ui/clipboard_figure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ fn build_payload(
.canvases
.get(canvas_index)
.ok_or(ClipboardFigureError::NoTarget)?;
let dpi = plotx_core::settings::load().export.dpi;
let dpi = app.settings.export.dpi;
let raster =
rasterize_canvas(canvas, RasterOptions::new(dpi)).map_err(ClipboardFigureError::Raster)?;
#[cfg(windows)]
Expand Down
13 changes: 12 additions & 1 deletion crates/app/src/ui/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::properties::{self, PanelRoute};
use super::*;
use egui::{Align2, FontId, Key, TextEdit, vec2};
use plotx_core::properties::PropertyId;
use plotx_core::state::{ObjectId, PropertyFocus, ToolGroup};
use plotx_core::state::{ObjectId, PropertyFocus, SettingsCategory, ToolGroup};

const PANEL_WIDTH: f32 = 540.0;
const LIST_HEIGHT: f32 = 320.0;
Expand Down Expand Up @@ -323,6 +323,17 @@ pub(super) fn reveal_property(app: &mut PlotxApp, property: PropertyId, now: f64
app.session.secondary_sidebar_visible = true;
app.session.ui.requested_tool_group = Some(ToolGroup::Processing);
}
PanelRoute::Preferences => {
app.open_settings();
if let Some(dialog) = app.session.ui.settings_dialog.as_mut()
&& let Some(category) = SettingsCategory::ALL
.into_iter()
.find(|category| category.section_id() == route.section)
{
dialog.category = category;
}
return;
}
}
// A property owned by an owner-local component needs that component opened,
// or the row it names is not on screen to scroll to. The step is chosen here
Expand Down
20 changes: 19 additions & 1 deletion crates/app/src/ui/command_palette_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::*;
use plotx_core::properties::contour;
use plotx_core::properties::{contour, export_dpi};

fn indices(items: &[PaletteItem], query: &str) -> Vec<PaletteAction> {
filter(items, query)
Expand Down Expand Up @@ -84,6 +84,24 @@ fn activating_a_property_requests_its_home_route() {
assert!((focus.highlight_until - 10.8).abs() < 1e-9);
}

#[test]
fn activating_export_dpi_opens_the_export_preferences_category() {
let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default());
reveal_property(&mut app, export_dpi::DPI, 10.0);

let dialog = app
.session
.ui
.settings_dialog
.as_ref()
.expect("Preferences opens");
assert_eq!(dialog.category, plotx_core::state::SettingsCategory::Export);
assert!(
app.session.ui.property_focus.is_none(),
"the native Preferences row needs no sidebar focus request"
);
}

fn property_item(items: &[PaletteItem], property: PropertyId) -> &PaletteItem {
items
.iter()
Expand Down
Loading
Loading