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
60 changes: 27 additions & 33 deletions crates/eye_declare/examples/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 4 additions & 5 deletions crates/eye_declare/examples/wrapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
_ => {}
}
Expand Down
32 changes: 30 additions & 2 deletions crates/eye_declare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,21 @@ 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<S> = Box<dyn FnOnce(&mut S) + Send>;
type TrackedStateUpdateFn<S> = Box<dyn FnOnce(&mut TrackedRef<'_, S>) + Send>;
type StateGetFn<S> = Box<dyn FnOnce(&S) + Send>;
type ViewFn<S> = Box<dyn Fn(&S) -> Elements>;
type CommitCallbackFn<S> = Box<dyn FnMut(&CommittedElement, &mut S)>;
type EventHandlerFn<'a, S> = Option<&'a mut dyn FnMut(&Event, &mut S) -> ControlFlow>;

enum AppMessage<S> {
UpdateState(StateUpdateFn<S>),
UpdateStateTracked(TrackedStateUpdateFn<S>),
GetState(StateGetFn<S>),
}

Expand Down Expand Up @@ -137,11 +139,20 @@ impl<S: Send + 'static> Handle<S> {
/// 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<T: Send + 'static>(
&self,
Expand Down Expand Up @@ -682,6 +693,9 @@ impl<S: Send + 'static> Application<S> {
update(&mut self.state);
self.dirty = true;
}
Some(AppMessage::UpdateStateTracked(update)) => {
self.apply_tracked_update(update);
}
None => {
// All Handles dropped
channel_open = false;
Expand Down Expand Up @@ -802,6 +816,9 @@ impl<S: Send + 'static> Application<S> {
update(&mut self.state);
self.dirty = true;
}
Some(AppMessage::UpdateStateTracked(update)) => {
self.apply_tracked_update(update);
}
Some(AppMessage::GetState(get)) => {
get(&self.state);
}
Expand Down Expand Up @@ -848,13 +865,24 @@ impl<S: Send + 'static> Application<S> {
self.dirty = false;
}

fn apply_tracked_update(&mut self, update: TrackedStateUpdateFn<S>) {
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 {
AppMessage::UpdateState(update) => {
update(&mut self.state);
self.dirty = true;
}
AppMessage::UpdateStateTracked(update) => {
self.apply_tracked_update(update);
}
AppMessage::GetState(get) => {
get(&self.state);
}
Expand Down
45 changes: 45 additions & 0 deletions crates/eye_declare/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,51 @@ impl<S> DerefMut for Tracked<S> {
}
}

/// A mutable reference to `S` with dirty tracking.
///
/// Like [`Tracked<S>`] 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<S> Deref for TrackedRef<'_, S> {
type Target = S;

fn deref(&self) -> &S {
self.inner
}
}

impl<S> 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)
Expand Down
2 changes: 1 addition & 1 deletion crates/eye_declare/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
6 changes: 1 addition & 5 deletions crates/eye_declare/src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1401,11 +1401,7 @@ fn allocate_widths(constraints: &[WidthConstraint], total: u16) -> Vec<u16> {
.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 {
Expand Down
18 changes: 18 additions & 0 deletions docs/content/guide/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading