diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3f685ce..aa78ab8 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -55,6 +55,31 @@ pub fn specs() -> Vec { ok() }) }), + cmd!( + "story.replaceRange", + "Edit Story", + [], + None, + "{story, start, end, text} — replace a byte range keeping surrounding formatting", + has_doc, + |s, p| { + let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?); + let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; + let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a); + let text = str_param(p, "text").unwrap_or("").to_string(); + s.edit(|d, sel| { + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len()))); + st.replace(a..b, &text); + let len = st.len(); + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { + t.anchor = t.anchor.min(len); + t.focus = t.focus.min(len); + } + Ok(json!({"length": len})) + }) + } + ), cmd!(query "story.get", "Get Story", [], None, "{story? | frame?} → text, frames, paragraphs, overset", has_doc, |s, p| { let st = s.doc()?; let sid = story_of(s, p).ok_or_else(|| bad("story.get", "no story"))?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bec685b..f98bf94 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod dock; pub mod icons; pub mod menus; pub mod panels; +pub mod story_editor; pub mod theme; pub mod toolbar; pub mod widgets; @@ -148,6 +149,8 @@ pub struct DesignApp { pub canvas_rect: Option, pub perf: Perf, pub synthetic: Vec, + /// Story open in the Story Editor. + pub story_editor: Option, control_rx: Option>, pending_shots: Vec<(u64, Option, Sender, f64)>, queued_shots: Vec<(u64, f64, u32)>, @@ -172,6 +175,7 @@ impl DesignApp { canvas_rect: None, perf: Perf::default(), synthetic: vec![], + story_editor: None, control_rx: None, pending_shots: vec![], queued_shots: vec![], @@ -332,6 +336,7 @@ impl DesignApp { canvas::show(self, ui); }); dock::flyout(self, &ctx); + story_editor::show(self, &ctx); dialogs::show(self, &ctx); menus::palette(self, &ctx); self.perf.frame_ms = now_ms() - t0; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 985357a..e48b769 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -14,6 +14,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.palette", "Command Palette…", Some("Cmd+K"), "{}"), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), + ("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), ("view.fitPage", "Fit Page in Window", Some("Cmd+0"), "{}"), @@ -77,6 +78,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:edit.selectAll", "cmd:edit.deselectAll", "-", + "ui:app.findChange", + "cmd:find.next", + "ui:app.storyEditor", + "-", "ui:app.palette", ], ), @@ -198,6 +203,19 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option export_png(app, p), + "app.storyEditor" => { + let sid = p.get("story").and_then(Value::as_u64).map(designcraft_doc::StoryId).or_else(|| { + let st = app.session.active()?; + st.selection.text.map(|t| t.story).or_else(|| st.selection.items.iter().find_map(|i| st.doc.item(*i)?.text_frame().map(|t| t.story))) + }); + match sid { + Some(s) => { + app.story_editor = if app.story_editor == Some(s) { None } else { Some(s) }; + Ok(Value::Null) + } + None => Err("select a text frame or place the cursor in text".into()), + } + } "app.findChange" => { app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({}))); Ok(Value::Null) diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs new file mode 100644 index 0000000..9c05638 --- /dev/null +++ b/crates/ui-egui/src/story_editor.rs @@ -0,0 +1,84 @@ +//! Story Editor (Edit → Edit in Story Editor, ⌘Y): the story as plain text with each paragraph's +//! style in the left column and the overset point marked. Edits are applied as minimal range +//! replacements, so formatting around them is kept. + +use egui::vec2; +use serde_json::json; + +use crate::DesignApp; +use crate::theme::Tokens; + +pub fn show(app: &mut DesignApp, ctx: &egui::Context) { + let Some(sid) = app.story_editor else { return }; + let Some(st) = app.session.active() else { + app.story_editor = None; + return; + }; + let Some(story) = st.doc.story(sid).cloned() else { + app.story_editor = None; + return; + }; + let t = Tokens::get(ctx); + let cs = app.session.cache.get(&st.doc, sid, None); + let overset = cs.overset_at; + let mut open = true; + let mut edit: Option<(usize, usize, String)> = None; + egui::Window::new(format!("Story Editor — {} words", story.text.split_whitespace().count())) + .id(egui::Id::new("story_editor")) + .open(&mut open) + .default_size(vec2(560.0, 520.0)) + .show(ctx, |ui| { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_top(|ui| { + // Style column. + ui.vertical(|ui| { + ui.set_width(130.0); + for (i, r) in story.para_ranges().iter().enumerate() { + let lines = (story.text[r.clone()].len() / 60).max(0) + 1; + let style = &story.paras[i].style; + ui.label(egui::RichText::new(style).size(11.0).color(t.text_dim)); + for _ in 1..lines { + ui.label(egui::RichText::new(" ").size(11.0)); + } + } + }); + ui.separator(); + let mut text = story.text.clone(); + let r = ui.add( + egui::TextEdit::multiline(&mut text) + .font(egui::FontId::proportional(14.0)) + .desired_width(f32::INFINITY) + .desired_rows(20) + .frame(egui::Frame::NONE), + ); + if r.changed() { + // Minimal diff: common prefix and suffix. + let old = &story.text; + let pre = old.bytes().zip(text.bytes()).take_while(|(a, b)| a == b).count(); + let pre = (0..=pre).rev().find(|&i| old.is_char_boundary(i) && text.is_char_boundary(i)).unwrap_or(0); + let max_suf = old.len().min(text.len()) - pre; + let suf = old.bytes().rev().zip(text.bytes().rev()).take(max_suf).take_while(|(a, b)| a == b).count(); + let suf = + (0..=suf).rev().find(|&k| old.is_char_boundary(old.len() - k) && text.is_char_boundary(text.len() - k)).unwrap_or(0); + edit = Some((pre, old.len() - suf, text[pre..text.len() - suf].to_string())); + } + }); + if let Some(o) = overset { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("OVERSET").color(egui::Color32::from_rgb(230, 40, 40)).strong()); + ui.label( + egui::RichText::new(format!("{} characters don't fit", story.text[o.min(story.text.len())..].chars().count())) + .color(t.text_dim), + ); + }); + } + }); + }); + if let Some((a, b, text)) = edit { + let _ = app.run("story.replaceRange", json!({"story": sid.0, "start": a, "end": b, "text": text})); + } + if !open { + app.story_editor = None; + } +}