diff --git a/crates/eye_declare/examples/chat.rs b/crates/eye_declare/examples/chat.rs index caa69cf..0e39951 100644 --- a/crates/eye_declare/examples/chat.rs +++ b/crates/eye_declare/examples/chat.rs @@ -308,44 +308,38 @@ async fn main() -> io::Result<()> { state.input.insert(state.cursor, *c); state.cursor += c.len_utf8(); } - KeyCode::Backspace => { - if state.cursor > 0 { - state.cursor -= 1; - state.input.remove(state.cursor); - } + KeyCode::Backspace if state.cursor > 0 => { + state.cursor -= 1; + state.input.remove(state.cursor); } KeyCode::Left => { state.cursor = state.cursor.saturating_sub(1); } - KeyCode::Right => { - if state.cursor < state.input.len() { - state.cursor += 1; - } + KeyCode::Right if state.cursor < state.input.len() => { + state.cursor += 1; } - KeyCode::Enter => { - if !state.input.is_empty() { - let text = std::mem::take(&mut state.input); - state.cursor = 0; - let user_id = state.next_id(); - state.messages.push(ChatMessage { - id: user_id, - kind: MessageKind::User(text), - }); - - let assistant_id = state.next_id(); - state.messages.push(ChatMessage { - id: assistant_id, - kind: MessageKind::Assistant { - content: String::new(), - done: false, - }, - }); - - let h2 = h.clone(); - tokio::spawn(async move { - stream_response(h2, assistant_id).await; - }); - } + KeyCode::Enter if !state.input.is_empty() => { + let text = std::mem::take(&mut state.input); + state.cursor = 0; + let user_id = state.next_id(); + state.messages.push(ChatMessage { + id: user_id, + kind: MessageKind::User(text), + }); + + let assistant_id = state.next_id(); + state.messages.push(ChatMessage { + id: assistant_id, + kind: MessageKind::Assistant { + content: String::new(), + done: false, + }, + }); + + let h2 = h.clone(); + tokio::spawn(async move { + stream_response(h2, assistant_id).await; + }); } KeyCode::Esc => { return ControlFlow::Exit; diff --git a/crates/eye_declare/examples/wrapping.rs b/crates/eye_declare/examples/wrapping.rs index e80ad29..0fb5e2a 100644 --- a/crates/eye_declare/examples/wrapping.rs +++ b/crates/eye_declare/examples/wrapping.rs @@ -90,13 +90,12 @@ fn main() -> io::Result<()> { stdout.write_all(&status_output)?; stdout.flush()?; } - Event::Key(key) => { + Event::Key(key) if key.code == KeyCode::Char('q') || (key.code == KeyCode::Char('c') - && key.modifiers.contains(KeyModifiers::CONTROL)) - { - break; - } + && key.modifiers.contains(KeyModifiers::CONTROL)) => + { + break; } _ => {} } diff --git a/crates/eye_declare/src/app.rs b/crates/eye_declare/src/app.rs index 3d59d94..ca8f068 100644 --- a/crates/eye_declare/src/app.rs +++ b/crates/eye_declare/src/app.rs @@ -30,12 +30,13 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use futures::StreamExt; use tokio::sync::{mpsc, oneshot}; -use crate::component::{EventResult, VStack}; +use crate::component::{EventResult, TrackedRef, VStack}; use crate::element::Elements; use crate::inline::InlineRenderer; use crate::node::NodeId; type StateUpdateFn = Box; +type TrackedStateUpdateFn = Box) + Send>; type StateGetFn = Box; type ViewFn = Box Elements>; type CommitCallbackFn = Box; @@ -43,6 +44,7 @@ type EventHandlerFn<'a, S> = Option<&'a mut dyn FnMut(&Event, &mut S) -> Control enum AppMessage { UpdateState(StateUpdateFn), + UpdateStateTracked(TrackedStateUpdateFn), GetState(StateGetFn), } @@ -137,11 +139,20 @@ impl Handle { /// Queue a state mutation. Applied on the next frame. /// /// This is non-blocking and can be called from both sync and - /// async contexts. + /// async contexts. Always triggers a rebuild. pub fn update(&self, f: impl FnOnce(&mut S) + Send + 'static) { let _ = self.tx.send(AppMessage::UpdateState(Box::new(f))); } + /// Queue a state mutation with dirty tracking. Only triggers a + /// rebuild if the callback actually writes through `DerefMut`. + /// + /// Use `state.read()` for reads that shouldn't trigger a rebuild, + /// and direct field access (which goes through `DerefMut`) for writes. + pub fn update_tracked(&self, f: impl FnOnce(&mut TrackedRef<'_, S>) + Send + 'static) { + let _ = self.tx.send(AppMessage::UpdateStateTracked(Box::new(f))); + } + /// Get the current state. pub fn fetch( &self, @@ -682,6 +693,9 @@ impl Application { update(&mut self.state); self.dirty = true; } + Some(AppMessage::UpdateStateTracked(update)) => { + self.apply_tracked_update(update); + } None => { // All Handles dropped channel_open = false; @@ -802,6 +816,9 @@ impl Application { update(&mut self.state); self.dirty = true; } + Some(AppMessage::UpdateStateTracked(update)) => { + self.apply_tracked_update(update); + } Some(AppMessage::GetState(get)) => { get(&self.state); } @@ -848,6 +865,14 @@ impl Application { self.dirty = false; } + fn apply_tracked_update(&mut self, update: TrackedStateUpdateFn) { + let mut tracked = TrackedRef::new(&mut self.state); + update(&mut tracked); + if tracked.is_dirty() { + self.dirty = true; + } + } + fn drain_updates(&mut self) { while let Ok(update) = self.rx.try_recv() { match update { @@ -855,6 +880,9 @@ impl Application { update(&mut self.state); self.dirty = true; } + AppMessage::UpdateStateTracked(update) => { + self.apply_tracked_update(update); + } AppMessage::GetState(get) => { get(&self.state); } diff --git a/crates/eye_declare/src/component.rs b/crates/eye_declare/src/component.rs index 589dc6f..f8f0381 100644 --- a/crates/eye_declare/src/component.rs +++ b/crates/eye_declare/src/component.rs @@ -173,6 +173,51 @@ impl DerefMut for Tracked { } } +/// A mutable reference to `S` with dirty tracking. +/// +/// Like [`Tracked`] but borrows instead of owning. Reading through +/// [`read()`](TrackedRef::read) or [`Deref`] does not set the dirty flag; +/// writing through [`DerefMut`] does. +pub struct TrackedRef<'a, S> { + inner: &'a mut S, + dirty: bool, +} + +impl<'a, S> TrackedRef<'a, S> { + /// Wrap a mutable reference, starting clean. + pub fn new(inner: &'a mut S) -> Self { + Self { + inner, + dirty: false, + } + } + + /// Whether any mutation has occurred through `DerefMut`. + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Get a shared reference without marking dirty. + pub fn read(&self) -> &S { + self.inner + } +} + +impl Deref for TrackedRef<'_, S> { + type Target = S; + + fn deref(&self) -> &S { + self.inner + } +} + +impl DerefMut for TrackedRef<'_, S> { + fn deref_mut(&mut self) -> &mut S { + self.dirty = true; + self.inner + } +} + /// A component that can render itself into a terminal region. /// /// Most users should define components with the [`#[component]`](macro@crate::component) diff --git a/crates/eye_declare/src/lib.rs b/crates/eye_declare/src/lib.rs index 6c5642d..28bd295 100644 --- a/crates/eye_declare/src/lib.rs +++ b/crates/eye_declare/src/lib.rs @@ -187,7 +187,7 @@ pub use cells::Cells; pub use children::{ AddTo, ChildCollector, ComponentWithSlot, DataChildren, DataHandle, SpliceInto, }; -pub use component::{Column, Component, EventResult, HStack, Tracked, VStack}; +pub use component::{Column, Component, EventResult, HStack, Tracked, TrackedRef, VStack}; pub use components::canvas::Canvas; pub use components::markdown::{Markdown, MarkdownState}; pub use components::spinner::{Spinner, SpinnerState}; diff --git a/crates/eye_declare/src/renderer.rs b/crates/eye_declare/src/renderer.rs index 740d782..4d04ee7 100644 --- a/crates/eye_declare/src/renderer.rs +++ b/crates/eye_declare/src/renderer.rs @@ -1401,11 +1401,7 @@ fn allocate_widths(constraints: &[WidthConstraint], total: u16) -> Vec { .count() as u16; let remaining = total.saturating_sub(fixed_sum); - let per_fill = if fill_count > 0 { - remaining / fill_count - } else { - 0 - }; + let per_fill = remaining.checked_div(fill_count).unwrap_or(0); let mut remainder = if fill_count > 0 { remaining % fill_count } else { diff --git a/docs/content/guide/application.md b/docs/content/guide/application.md index ce5eeb4..2a1760d 100644 --- a/docs/content/guide/application.md +++ b/docs/content/guide/application.md @@ -67,6 +67,24 @@ handle.update(|s| s.messages.push(msg3)); // → one rebuild with all three messages ``` +### Conditional updates + +`update()` always triggers a rebuild, even if the callback doesn't actually change anything. For high-frequency events like keystroke handlers, use `update_tracked()` instead — it only triggers a rebuild if the callback actually writes through `DerefMut`: + +```rust +handle.update_tracked(|state| { + let new_blank = text.is_empty(); + // read() checks the value without marking dirty + if state.read().is_input_blank != new_blank { + // Field access through DerefMut — marks dirty, triggers rebuild + state.is_input_blank = new_blank; + } + // If nothing was written, no rebuild happens +}); +``` + +The callback receives `&mut TrackedRef<'_, S>`, which works like `&mut S` but tracks whether any mutation occurred. Use `.read()` for comparisons that shouldn't trigger a rebuild. Direct field access goes through `DerefMut` and sets the dirty flag. + ### Exiting Call `handle.exit()` to stop the event loop, or simply drop the handle. When using `run()`, the app exits when the handle is dropped *and* all component effects (intervals, etc.) have stopped.