fix(editor): fit blank frame on startup

This commit is contained in:
Fini 2026-05-30 05:16:04 +08:00
parent 502f301a70
commit 37c421697a
9 changed files with 99 additions and 15 deletions

View file

@ -45,6 +45,17 @@ impl Viewport {
/// the content centre to the canvas centre. No-op for an empty
/// content rect or a degenerate canvas.
pub fn fit_to(&mut self, content: Rect, canvas_w: f32, canvas_h: f32, padding: f32) {
self.fit_to_with_max_zoom(content, canvas_w, canvas_h, padding, Self::MAX_ZOOM);
}
pub fn fit_to_with_max_zoom(
&mut self,
content: Rect,
canvas_w: f32,
canvas_h: f32,
padding: f32,
max_zoom: f32,
) {
if content.size.x <= 0.0 || content.size.y <= 0.0 || canvas_w <= 0.0 || canvas_h <= 0.0 {
return;
}
@ -52,7 +63,10 @@ impl Viewport {
let avail_h = (canvas_h - 2.0 * padding).max(1.0);
let zoom = (avail_w / content.size.x)
.min(avail_h / content.size.y)
.clamp(Self::MIN_ZOOM, Self::MAX_ZOOM);
.clamp(
Self::MIN_ZOOM,
max_zoom.clamp(Self::MIN_ZOOM, Self::MAX_ZOOM),
);
let cc_x = content.origin.x + content.size.x / 2.0;
let cc_y = content.origin.y + content.size.y / 2.0;
self.zoom = zoom;
@ -118,6 +132,21 @@ mod tests {
assert!((v.pan_y + 325.0 * v.zoom - 200.0).abs() < 1e-2);
}
#[test]
fn fit_to_with_max_zoom_caps_content_fit() {
let mut v = Viewport::IDENTITY;
let content = Rect {
origin: Point2D::new(100.0, 50.0),
size: Point2D::new(200.0, 100.0),
};
v.fit_to_with_max_zoom(content, 1000.0, 800.0, 100.0, 2.0);
assert_eq!(v.zoom, 2.0);
assert!((v.pan_x - 100.0).abs() < 1e-2, "pan_x {}", v.pan_x);
assert!((v.pan_y - 200.0).abs() < 1e-2, "pan_y {}", v.pan_y);
}
#[test]
fn fit_to_ignores_empty_content() {
let mut v = Viewport::IDENTITY;

View file

@ -159,8 +159,12 @@ struct DesktopApp {
impl DesktopApp {
fn new(initial_file: Option<PathBuf>) -> Self {
let mut host = WidgetHostNative::new();
let fit_blank_frame = initial_file.is_none();
// Best-effort prefs restore onto the host's `EditorState`.
settings_io::load(host.editor_state_mut());
if fit_blank_frame {
host.fit_content_to_viewport(INITIAL_VIEWPORT_W, INITIAL_VIEWPORT_H);
}
host.mark_editor_state_dirty();
// Baseline for the unsaved-changes prompt — the fresh,
// empty document is by definition "saved" (nothing to lose).

View file

@ -34,3 +34,13 @@ fn cursor_redraw_still_paints_when_layer_hover_changes() {
assert!(app.prepare_redraw());
}
#[test]
fn fresh_app_fits_blank_frame_like_ts_canvas_init() {
let app = DesktopApp::new(None);
let v = app.host.editor_state().viewport;
assert!((v.zoom - 0.66).abs() < 1e-3, "zoom {}", v.zoom);
assert!((v.pan_x - 64.0).abs() < 1e-2, "pan_x {}", v.pan_x);
assert!((v.pan_y - 166.0).abs() < 1e-2, "pan_y {}", v.pan_y);
}

View file

@ -426,7 +426,7 @@ pub fn run_action(
match action {
FileAction::New => {
// Reset the document to a fresh untitled state.
*host.editor_state_mut() = EditorState::new();
*host.editor_state_mut() = EditorState::starter();
host.mark_editor_state_dirty();
*current_path = None;
refresh_title(current_path, window);
@ -702,6 +702,45 @@ mod tests {
);
}
#[test]
fn new_file_action_resets_to_starter_frame() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().doc.children.clear();
let mut current_path = Some(PathBuf::from("/tmp/old.op"));
let outcome = run_action(
op_editor_core::editor_ui_state::FileAction::New,
&mut host,
&mut current_path,
None,
);
assert_eq!(outcome, ActionOutcome::Saved);
assert!(current_path.is_none());
assert_eq!(host.editor_state().doc.children.len(), 1);
assert_eq!(
host.editor_state().selection.anchor,
op_editor_core::NodeId::new("n10")
);
let frame = match &host.editor_state().doc.children[0] {
jian_ops_schema::node::PenNode::Frame(frame) => frame,
other => panic!(
"new file should create the blank starter frame, got {:?}",
other
),
};
assert_eq!(frame.base.x, Some(0.0));
assert_eq!(frame.base.y, Some(0.0));
assert!(matches!(
frame.container.width,
Some(jian_ops_schema::sizing::SizingBehavior::Number(1200.0))
));
assert!(matches!(
frame.container.height,
Some(jian_ops_schema::sizing::SizingBehavior::Number(800.0))
));
}
#[test]
fn legacy_doc_payload_is_detected() {
// Fix 4: a pre-canonical private `DocPayload` JSON (integer

View file

@ -64,6 +64,7 @@ mod settings_dispatch;
mod shape_picker_press;
mod shortcuts;
mod toolbar_hover;
mod viewport_fit;
pub use frame_backend::NativeFrameBackend;

View file

@ -486,7 +486,7 @@ impl WidgetHostNative {
let (_l, _t, canvas_w, canvas_h) = self.canvas_region(viewport_w, viewport_h);
self.editor_state
.viewport
.fit_to(content, canvas_w, canvas_h, 48.0);
.fit_to_with_max_zoom(content, canvas_w, canvas_h, 64.0, 1.0);
self.mark_dirty();
}

View file

@ -196,17 +196,9 @@ fn status_bar_search_click_frames_content_in_viewport() {
assert!(consumed, "search-icon click must be consumed");
let v = host.editor_state().viewport;
assert!(
(v.zoom - 0.2).abs() > 1e-3,
"zoom should change to frame the content, got {}",
v.zoom
);
assert!(
v.pan_x > -5000.0 && v.pan_y > -5000.0,
"pan should re-anchor toward the content, got ({}, {})",
v.pan_x,
v.pan_y
);
assert!((v.zoom - 1.0).abs() < 1e-3, "zoom {}", v.zoom);
assert!((v.pan_x - 230.0).abs() < 1e-2, "pan_x {}", v.pan_x);
assert!((v.pan_y - 180.0).abs() < 1e-2, "pan_y {}", v.pan_y);
}
#[test]

View file

@ -0,0 +1,9 @@
//! Public viewport-fit adapter for desktop runner startup.
use super::WidgetHostNative;
impl WidgetHostNative {
pub fn fit_content_to_viewport(&mut self, viewport_w: f32, viewport_h: f32) {
self.zoom_to_fit(viewport_w, viewport_h);
}
}

View file

@ -57,7 +57,7 @@ impl WidgetHost {
let (_l, _t, canvas_w, canvas_h) = self.canvas_region(viewport_w, viewport_h);
self.editor_state
.viewport
.fit_to(content, canvas_w, canvas_h, 48.0);
.fit_to_with_max_zoom(content, canvas_w, canvas_h, 64.0, 1.0);
self.mark_dirty();
}
}