Browse / github.com/storytold/designcraft / patch
Canvas: the text caret blinks only while its window has focus
Canvas: the text caret blinks only while its window has focus **Problem.** Both canvas caret paths (story frames, and table cells and footnotes) called `request_repaint_after(330 ms)` with no focus check. A DesignCraft window left in the background with a text caret therefore redrew the whole UI three times a second, for as long as another app was in front. egui's own `TextEdit` stops blinking when its viewport loses focus. **Change.** - **The caret.** Both paths now go through `draw_caret`. While the window has focus, the caret blinks and asks for the frame that blinks it, as before. In the background it stays drawn, steady, and asks for no frames. egu
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ZJHeepfixer (imported from pull request #1814, open)
- Base
7ca39c6c10f123d0cd0176eda1511e59667c89d0- Size
- +107 −18 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
ec18005263e8de2f71203e2e
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/ui-egui/src/canvas.rs crates/ui-egui/src/lib.rs
@@ -378,6 +378,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · The text caret blinks only while its window has focus. In a window in the background it stays drawn and asks for no frames: it redrew the whole window three times a second while another app was in front (egui's own text fields already stop blinking there). The crash-recovery timer, which ran on those frames, now asks for its own frame when an edit is due to be written, so edits made just before switching apps are still saved for recovery. No readiness percentage change. - **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change. - **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change.@@ -1652,10 +1652,20 @@ fn draw_text_selection( let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));- let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;- if blink {- painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));- }+ draw_caret(painter, p0, p1);+ }+}++/// The text caret from `p0` to `p1`. It blinks while its window has focus, asking for the frames+/// that blink it. In a window in the background it stays drawn and asks for none: it no longer+/// redraws the whole window three times a second while another app is in front (egui's own text+/// fields stop blinking there too).+fn draw_caret(painter: &egui::Painter, p0: Pos2, p1: Pos2) {+ let (focused, time) = painter.ctx().input(|i| (i.focused, i.time));+ if !focused || (time * 1.6) as i64 % 2 == 0 {+ painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));+ }+ if focused { painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); } }@@ -1734,11 +1744,7 @@ fn draw_cell_selection( { let m = a * doc.text_xf(it); let (p0, p1) = (xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc)));- let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;- if blink {- painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));- }- painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));+ draw_caret(painter, p0, p1); } } @@ -3680,6 +3686,30 @@ mod tests { use super::*; + /// A text caret redrew the whole window three times a second while another app was in front.+ #[test]+ fn the_caret_blinks_only_while_its_window_has_focus() {+ let ctx = egui::Context::default();+ let frame = |focused: bool| {+ let raw = egui::RawInput { focused, ..Default::default() };+ let mut out = ctx.run_ui(raw, |ui| draw_caret(ui.painter(), pos2(10.0, 10.0), pos2(10.0, 30.0)));+ out.textures_delta.clear();+ let drawn = out.shapes.iter().any(|c| matches!(c.shape, egui::Shape::LineSegment { .. }));+ (drawn, out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay))+ };+ // Focused: it asks for the frame that blinks it.+ let (_, delay) = frame(true);+ assert!(delay.is_some_and(|d| d <= std::time::Duration::from_millis(330)), "{delay:?}");+ // In the background (after the frame egui runs for the change of focus itself): drawn,+ // steady, and no frame asked for.+ assert!(frame(false).0);+ for _ in 0..3 {+ assert_eq!(frame(false), (true, Some(std::time::Duration::MAX)));+ }+ // Focus back: blinking again.+ assert!(frame(true).1.is_some_and(|d| d <= std::time::Duration::from_millis(330)));+ }+ #[test] fn a_press_after_a_drag_is_not_a_double_click() { let (delay, dist) = (0.3, 6.0);@@ -634,6 +634,9 @@ pub struct DesignApp { last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64,+ /// The documents' (uid, revision) when the recovery timer last went off: an edit made since+ /// asks for the frame that writes it.+ recovery_revisions: Vec<(u64, u64)>, /// The egui context (set by the first frame): copied text goes to the system clipboard through it. egui_ctx: Option<egui::Context>, /// A control-channel request is being handled: commands it runs never read or write the@@ -720,6 +723,7 @@ impl DesignApp { native_shortcuts: Default::default(), last_time: 0.0, last_recovery: 0.0,+ recovery_revisions: Vec::new(), egui_ctx: None, in_control: false, object_clip_text: None,@@ -783,6 +787,34 @@ impl DesignApp { self.ui.status = s.into(); } + /// Crash recovery: unsaved documents are written to the recovery folder every 30 s. An edit+ /// made since the timer last went off asks for the frame that writes it when it is due, so it+ /// is written even while nothing else draws: the timer used to wait for some other frame (the+ /// caret's blink, a pointer move), and the caret no longer blinks in a window in the background.+ #[cfg(not(target_arch = "wasm32"))]+ fn recovery_timer(&mut self, ctx: &egui::Context, now: f64) {+ if self.session.recovery_dir.is_none() {+ return;+ }+ let interval = (self.session.prefs.recovery_minutes * 60.0).max(5.0);+ let since = now - self.last_recovery;+ if since <= interval {+ if self.session.documents().iter().any(|d| d.is_dirty() && !self.recovery_revisions.contains(&(d.uid, d.revision))) {+ ctx.request_repaint_after_secs((interval - since) as f32 + 0.1);+ }+ return;+ }+ self.last_recovery = now;+ self.recovery_revisions = self.session.documents().iter().map(|d| (d.uid, d.revision)).collect();+ if self.session.documents().iter().any(|d| d.is_dirty())+ && let Err(e) = self.session.execute("file.recovery.save", &json!({}))+ {+ // Not written: the next tick tries again.+ self.recovery_revisions.clear();+ self.status(crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e.to_string()));+ }+ }+ /// The host's save dialog for `name`, started beside the active document once it has been /// saved, else in the folder of the last file saved or exported (#1653). The answer's folder /// is remembered for the next one.@@ -1210,16 +1242,8 @@ impl DesignApp { self.restyle = false; } let now = ctx.input(|i| i.time);- // Crash recovery: unsaved documents are written to the recovery folder every 30 s. #[cfg(not(target_arch = "wasm32"))]- if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(5.0) {- self.last_recovery = now;- if self.session.documents().iter().any(|d| d.is_dirty())- && let Err(e) = self.session.execute("file.recovery.save", &json!({}))- {- self.status(crate::i18n::tr(&self.ui.language, "Couldn't write recovery data: {error}").replace("{error}", &e.to_string()));- }- }+ self.recovery_timer(ctx, now); let dt = now - self.last_time; if dt > 0.0 { self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;@@ -1838,6 +1862,40 @@ pub fn drop_key_name_text(raw: &mut egui::RawInput, text_input_focused: bool) { mod tests { use super::*; + /// The recovery timer ran only when some other frame did: with the caret no longer blinking in+ /// a window in the background, an edit made there would wait for the next pointer move. An edit+ /// since the timer last went off asks for the frame that writes it; once written, none is asked.+ #[cfg(not(target_arch = "wasm32"))]+ #[test]+ fn an_edit_asks_for_the_recovery_frame_and_nothing_else_does() {+ let dir = tempfile::tempdir().unwrap();+ let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());+ app.run("file.new", json!({})).unwrap();+ app.run("frame.create", json!({"rect": [20, 20, 120, 120], "content": "text", "text": "edited"})).unwrap();+ app.session.recovery_dir = Some(dir.path().to_path_buf());+ let entry = dir.path().join(format!("{}.designcraft", app.session.active().unwrap().uid));+ let ctx = egui::Context::default();+ let tick = |app: &mut DesignApp, now: f64| {+ let raw = egui::RawInput { time: Some(now), ..Default::default() };+ let mut out = ctx.run_ui(raw, |ui| app.recovery_timer(ui.ctx(), now));+ out.textures_delta.clear();+ out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay)+ };+ let secs = |d: Option<std::time::Duration>| d.map(|d| d.as_secs_f64());+ // A new context asks for frames of its own at first.+ tick(&mut app, 9.0);+ tick(&mut app, 9.5);+ // 10 s after the last tick, 30 s apart: the frame is due in 20 s.+ assert!(secs(tick(&mut app, 10.0)).is_some_and(|s| (20.0..21.0).contains(&s)));+ // Due: written. Nothing changed since: no frame is asked for.+ tick(&mut app, 30.5);+ assert!(entry.exists());+ assert_eq!(tick(&mut app, 31.0), Some(std::time::Duration::MAX));+ // Edited again: a frame at the next tick, 30 s after the last.+ app.run("frame.create", json!({"rect": [20, 140, 120, 240]})).unwrap();+ assert!(secs(tick(&mut app, 40.0)).is_some_and(|s| (20.5..21.0).contains(&s)));+ }+ /// The Control panel's two-row groups were centred as if they were one row tall, so the second /// row hung below the panel and was cut off. #[test]