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
38 changes: 32 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
use ratatui::DefaultTerminal;

use mossaic::app::{App, Graphics, Options, Source};
use mossaic::app::{App, Graphics, Load, Options, Source};
use mossaic::cli::Args;
use mossaic::primer::{Appearance, Palette, Season};
use mossaic::{github, graphics, png, term, ui};
Expand Down Expand Up @@ -148,6 +148,13 @@ fn run(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
let mut mouse = false;
app.request();

// Whether anything has changed since the last frame went out. The first
// one always does; after that the loop draws only when there is something
// new to show (#102). Without this it drew on every `TICK`, so an idle
// chart put a synchronized-update bracket on the wire twelve times a
// second for a picture that was not moving.
let mut dirty = true;

while !app.quit {
if app.mouse != mouse {
mouse = app.mouse;
Expand All @@ -157,7 +164,13 @@ fn run(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
execute!(out, DisableMouseCapture)?;
}
}
// A fetch landing is the one model change no event announces, so it
// is detected rather than reported: `request` sets `Loading`, and
// `drain` leaves that state when a calendar or a failure arrives.
let was_loading = matches!(app.load, Load::Loading);
app.drain();
dirty |= was_loading != matches!(app.load, Load::Loading);

if std::mem::take(&mut app.redraw) {
// Pixels the text layer never wrote are pixels it cannot erase, so
// anything that moves or removes the image clears the screen first.
Expand All @@ -172,14 +185,23 @@ fn run(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
if let Some(painter) = &mut app.gfx {
painter.invalidate();
}
dirty = true;
}
// One frame, bracketed: the text goes out through ratatui and the images
// straight after it, and a terminal that understands DEC 2026 shows the
// two together instead of a chart that arrives without its cells.
execute!(out, BeginSynchronizedUpdate)?;
terminal.draw(|frame| ui::draw(frame, app))?;
app.paint(&mut out)?;
execute!(out, EndSynchronizedUpdate)?;
//
// Drawn only when it would say something different. The spinner is the
// single thing that moves without an event, so a fetch in flight keeps
// the loop painting; everything else here is driven by a key, a mouse
// report, a resize, or that fetch landing.
if dirty || matches!(app.load, Load::Loading) {
execute!(out, BeginSynchronizedUpdate)?;
terminal.draw(|frame| ui::draw(frame, app))?;
app.paint(&mut out)?;
execute!(out, EndSynchronizedUpdate)?;
dirty = false;
}

if event::poll(TICK)? {
// Drain the queue rather than taking one event per frame: motion
Expand All @@ -189,8 +211,12 @@ fn run(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
app.on_key(key.code, key.modifiers);
dirty = true;
}
Event::Mouse(event) => {
app.on_mouse(event);
dirty = true;
}
Event::Mouse(event) => app.on_mouse(event),
Event::Resize(..) => {
// A resize is also the moment a font size may have
// changed, and the cell was measured once at startup.
Expand Down
61 changes: 34 additions & 27 deletions tests/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,34 +622,36 @@ fn chrono_year(screen: &Screen) -> i32 {

#[test]
fn an_idle_chart_stops_writing() -> termlens::Result<()> {
// The chart repaints on a timer — 80 ms, so the loading spinner can turn —
// which means a frame goes out whether or not anything changed. What must
// *not* happen is that each of those frames rewrites the screen: ratatui
// diffs its buffer and the painter diffs the images, so a settled chart
// should cost a pair of synchronized-update brackets and nothing else.
// **The claim got stronger in #102.** The chart used to repaint on an
// 80 ms timer whether or not anything had changed, and this asserted the
// next best thing: that each of those frames rewrote *nothing*, because
// ratatui diffs its buffer and the painter diffs the images. Now there is
// no frame at all — so rather than counting printable characters in idle
// frames, this counts the idle frames, and there are none.
//
// Invisible to every content predicate, because each of those frames shows
// exactly the right content. `printable_chars` is what sees it.
// Invisible to every content predicate either way, because a settled
// chart shows exactly the right thing whichever it does.
let mut t = chart(&PREVIEW)?;
t.wait_frame(loaded)?;
let settled = t.screen().repaints();
// Let several more frames go by with no input at all.
t.wait_frame(|s| s.repaints() >= settled + 4)?;

let idle: Vec<u32> = t
.frame_timings()
.iter()
.filter(|frame| frame.index() > settled)
.map(|frame| frame.printable_chars())
.collect();

// A wait that has to expire: nothing is coming, because nothing changed.
let err = t
.wait_frame_for(|s| s.repaints() > settled, Duration::from_secs(2))
.expect_err("an idle chart must not repaint");
assert!(
idle.len() >= 3,
"expected several idle frames to inspect, got {idle:?}"
matches!(err, termlens::Error::Timeout { .. }),
"expected a timeout, got: {err}"
);
assert!(
idle.iter().all(|written| *written == 0),
"an idle chart rewrote the screen: printable characters per frame {idle:?}"
assert_eq!(
t.screen().repaints(),
settled,
"an idle chart put frames on the wire for a picture that was not moving"
);

// And the loop is idle, not wedged: a keystroke still draws.
t.send(Key::Right)?;
t.wait_frame(|s| s.repaints() > settled)?;
Ok(())
}

Expand Down Expand Up @@ -678,9 +680,12 @@ fn nothing_rings_the_bell() -> termlens::Result<()> {
] {
t.send(key)?;
}
// A frame after the last of them, so every key has been through the loop.
let settled = t.screen().repaints();
let screen = t.wait_frame(|s| s.repaints() > settled + 1)?;
// Let the batch settle, so every key has been through the loop. Waiting
// for *more frames* was the idiom while the chart repainted on a timer;
// since #102 an idle chart produces none, and "the picture stopped
// changing" is both what this actually means and what survives the app
// getting quieter still.
let screen = t.wait_stable(Duration::from_millis(300))?;
assert_eq!(
screen.bells(),
0,
Expand Down Expand Up @@ -800,9 +805,11 @@ fn the_wheel_does_nothing_behind_the_help_overlay() -> termlens::Result<()> {
let help = t.wait_frame(|s| s.contains("This terminal"))?;
t.scroll(GRID_X, row, Scroll::Up)?;

// Give it frames to have acted in, then check nothing did.
let settled = help.repaints();
let after = t.wait_frame(|s| s.repaints() > settled + 2)?;
// Give it time to have acted in, then check nothing did. Not "wait for
// two more frames": since #102 a scroll the overlay swallows produces no
// frame at all, which is the point being asserted.
let _ = help;
let after = t.wait_stable(Duration::from_millis(300))?;
assert!(
after.contains("This terminal"),
"the overlay should still be up:\n{after}"
Expand Down