Repository navigation
fix: single event loop, leaner dependencies, unclipped results graph - #29
Merged
Merged
Conversation
Thirteen rendering tests against ratatui's TestBackend, recorded from the code as it is before any cleanup: the running screen (fresh, with correct and incorrect input, wrapped, narrow, too small), the pace caret, the countdown, and the results screen with its chart. Each snapshot holds the characters, one style letter per cell and the cursor position, so a change in colour or cursor placement fails as loudly as a change in text. The results snapshots show the (t)weet legend, which today depends on a browser opener being on PATH of the machine running the tests.
…tion Dropped: - serde, serde_json, include_dir: the word lists were JSON embedded as a folder and parsed at every start. They are now one word per line, embedded with include_str!, so nothing is parsed or copied. - strum_macros: only turned the language enum into a file name. The enum that clap parses now names its list directly. - itertools: one sorted_by, replaced by slice::sort_by. - unicode-width: never imported. Trimmed: - ratatui 0.30.2 without its default features. layout-cache stays on (without it every frame runs the constraint solver); the unused widgets, macros and underline colour go. - chrono with only the clock feature: all it does is stamp the log. Updated: clap 4.6, directories 6.0 (same config folder on every platform), and the lock file, which still pinned releases from 2022. webbrowser is updated in a later commit because that changes what is shown on machines without a browser. Kept as they are: rand 0.8 and cgisf_lib 0.1.3. cgisf_lib needs rand 0.8, so a newer rand here would compile rand twice. Edition 2024 and rust-version 1.88: ratatui 0.30 already needs Rust 1.88, so the edition costs nobody a toolchain, and cargo now says so up front.
Event loop (main.rs) - The reader thread, the tick thread and the channel are gone. One loop waits for a key or for the next tick with event::poll, so a failed read is an io::Error that reaches main instead of a panic in a thread that left the program running with the terminal restored. - Keys are handled by one match; restart, new test, typing and tweeting are methods on App. A test is finished and saved in one place. - A guard restores the terminal on every return path, and the restore shows the cursor itself. The panic hook stays, so the message is printed after the alternate screen is left. - main returns io::Result; Box<dyn Error> had one error type left in it. Test state (thok.rs) - Instant instead of SystemTime: a wall-clock step can no longer panic at the end of a test or bend its numbers. - cursor_pos is gone: it was always input.len(). Typing is push, backspace is pop. - calc_results maps each correct key press to its second, sorts and counts runs. Same buckets, same numbers, no HashMap keyed by text. - raw_coords (never read) and the helpers only tests called are removed. History log (history.rs, new) - The row format, the append and the path lookup are separate functions. - A row that cannot be written is no longer dropped in silence: the first failure is printed on stderr after the terminal is restored. Rendering (ui.rs, layout.rs) - ui::draw replaces the Widget impl plus a second pass for the cursor: the prompt is wrapped once per frame instead of three times, and the two screens are two functions. - Styles are constants; layout arithmetic is integer arithmetic. Bugs fixed - A timed test ran up to 0.1 s short: the first tick now comes 100 ms after the first key instead of at a random point before that. - A timed test could run one tick long (-s 5, 10, 15 and 120 among others): the countdown is derived from the tick count instead of subtracting 0.1 again and again. Existing tests: the 23 keep their assertions. SystemTime became Instant in their setup, cursor_pos assertions read input.len(), and char_cell takes the wrapped lines the tests had already computed. The 13 snapshots are untouched and pass.
webbrowser before 1.2.2 has an open advisory: a BROWSER template with %s lets a crafted URL add browser arguments. thokr only ever opens its own fixed https link, so it was not exposed, but the fix is one update away and 0.7.1 is four years old. One visible effect. 0.7.1 called a browser available as soon as xdg-open was on PATH. 1.2 looks for an actual default browser. On a machine that has xdg-open but no browser configured (a server, a container) the results legend no longer offers (t)weet, a key that could not open anything there. With a browser, or with BROWSER set, nothing changes. A snapshot of the legend without the tweet key is added.
Measured on x86-64 Linux, rustc 1.98, median of four rounds, against v0.5.0. The whole branch, not this commit alone: launch to first frame 1.55 ms -> 1.32 ms same, -l english10k 2.53 ms -> 1.69 ms key press + frame, 100x30 33.9 us -> 26.1 us results frame, 100x30 57.6 us -> 39.8 us binary 3,018,328 -> 1,684,552 bytes clean release build 17.1 s -> 28.2 s In this commit: - [profile.release] lto, codegen-units = 1, strip. Against the default profile on the same code: key press + frame 27.9 -> 26.0 us, results frame 45.1 -> 38.5 us, binary 2.55 -> 1.68 MB. The price is the build: 19.2 s -> 28.8 s, paid once per cargo install. - Neighbouring prompt chars of one style share a span instead of each owning a String: rendering the prompt 6.9 -> 5.0 us. - Whether a browser can be opened is looked up once, when the first results show, instead of on every results frame. - src/bench.rs: the ignored test behind the frame numbers, cargo test --release -- --ignored --nocapture bench Most of a frame is ratatui comparing every cell with the last frame (about 21 us at 100x30); thokr's own share is now about 5 us.
History log - A row has exactly the format of the rows v0.5.0 wrote (the fixture is a log produced by the v0.5.0 binary). - Old rows and a new row read back column by column the same way. - Appending to an existing log adds a row and leaves the rest alone; a first run creates the folder and writes the header once. - A log that cannot be written is an error, not silence. Word lists - Each list holds the expected number of words, one per line. - A prompt is made of distinct words of the list, stops at the size of the list, and sentence mode counts its words.
…ntested, cargo missing
The t key on the results screen, the (t)weet legend entry, the share link, the browser lookup with its thread, and the webbrowser dependency are gone. The results legend is (r)etry / (n)ew / (esc)ape on every machine, the form machines without a browser already had. Nothing else on the results screen changes. Four results snapshots lose the legend entry. The snapshot of the legend without a browser and the test of the lookup are removed with what they tested. With webbrowser go url and the ICU crates it brought in.
The top of the wpm axis was the highest wpm rounded to the nearest whole number, so a peak with a fraction below one half (98.4 under a top of 98) lay outside the chart. The chart library of 0.4.1 drew nothing for a line with an end outside the chart, which removed the two segments touching the peak: the two second gap in the report. Today's ratatui cuts such a line at the edge instead, so the same peak is drawn shaved. The top is now rounded up. The seconds axis had the same defect for a test over in under a second, whose only point lies left of second 1 on an axis that then ran from 1 to 1 and drew nothing: it starts at 0 there. Both labels stay whole numbers. A snapshot of a 98.4 peak, a test of the sub-second case, and a check over 500 generated results that every point lies inside both axes; the last two fail without the fix.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A critique of the Rust in thokr, and the change that carries it out: clearer types and ownership, real error handling, fewer dependencies, a faster and smaller binary. Flags, screens, results numbers and the history log are the same as v0.5.0, with three listed exceptions (see "What changes for a user").
Fixes #22
The critique in brief
Correctness and error handling
let _ =.Structure and ownership
cursor_poswas a second copy ofinput.len()that had to be kept in step.Idiom
calc_resultscloned the input twice and counted seconds in aHashMapkeyed by numbers formatted as text.Options were checked withis_some()and then unwrapped;bools were matched withtrueandfalsearms.saturating_subsays the same thing.Performance
Stringper prompt character and wrapped the prompt three times.$PATHfor a browser on every frame.Dependencies
unicode-widthwas never imported).Tooling
rust-version, although ratatui 0.30 already requires Rust 1.88.pedanticgroup gave 45 warnings; about half pointed at the findings above, the rest were cast lints not worth acting on.What changes for a user
tkey on the results screen, the(t)weetentry in the legend and the share link. The legend reads(r)etry / (n)ew / (esc)apeon every machine. Nothing else on the results screen changes.Everything else a person sees or types is unchanged.
Changes by theme
Tests first (
tests: snapshot every screen)TestBackend, recorded frommainbefore anything else changed: characters, style of every cell, cursor position. They pass untouched through every commit of the cleanup; five of them later move the cursor by one cell with the cursor fix below, and nothing else in them changes.Dependencies (
deps: ..., two commits)include_str!instead of JSON parsed at every start.rust-version = "1.88".webbrowserwas first updated from 0.7.1 to 1.2 and then removed together with the tweet capability (remove the tweet capability).Cleanup (
cleanup: ...)event::poll) instead of two threads and a channel. Errors reachmainasio::Error.Instantinstead ofSystemTime;cursor_pos(always equal toinput.len()) removed;calc_resultsrewritten without the text-keyedHashMap; the two screens are two functions; the history log is its own module.Speed (
speed: ...)[profile.release]:lto,codegen-units = 1,strip.src/bench.rs: the ignored test behind the frame numbers.Tweet capability removed (
remove the tweet capability)webbrowserdependency. With it gourland the ICU crates: 119 to 91 crates, about 4 s off the clean build, 303 KB off the binary.Issue 22 (
fix: the results graph cut off a peak above its top label)Tests (
tests: the history log format and the word lists)Measurements
Release builds of
mainand of the pushed head, rustc 1.98.1, x86-64 Linux, both binaries measured in alternation, medians of three or four rounds.main, v0.5.0)thokr-l english1k-l english10k-f 2-s 30cargo install(no lock file)Cargo.lock, all platformsCargo.lockOne of these is not a gain: the clean build is about 7 s slower. That is the price of
lto+codegen-units = 1, which on the same code buy 7% on a key press, 15% on a results frame and a third of the binary. It is paid once percargo install.Most of a frame (about 21 of 28 us at 100x30) is ratatui comparing every cell with the previous frame. thokr's own rendering went from about 10 us to about 5 us.
Dependencies
serde1Deserializefor the word listsserde,serde_core,serde_derive,serde_json,include_dir,include_dir_macrosleave the build, and nothing is parsed at start (-l english10k: 2.53 ms to 1.69 ms)serde_json1.0.79include_str!serdeinclude_dir0.7.2include_str!is in the standard libraryserde; the binary no longer carries 164 KB of JSON and a copy ofmod.rsstrum_macros0.24strum_macrosleaves the build (ratatui brings its own)itertools0.10.3sorted_by;slice::sort_bydoes ititertoolsleaves the build (ratatui brings its own)unicode-width0.1.9unicode-widthleaves the buildratatui0.30.1crosstermandlayout-cacheonlayout-cachemust stay: without it a key press + frame costs 74 us instead of 26timeand its helpers,ratatui-macros); without underline colour ratatui's per-frame cell comparison drops from about 26 us to about 21 usclap4--help,-Vand the error texts are part of what people see, and were checked byte for bytechrono0.4clockon%c). The alternatives are larger or cannot read the local time zone safelydirectories4.0config_diris the same expression on Linux, macOS and Windows in both versions (sources compared), so the log stays where it isrand0.8.5cgisf_libneeds 0.8, so a newerrandhere would compile twocgisf_lib0.1.3-fprompts would changewebbrowser0.7.1urland the ICU cratesMinimum Rust is 1.88 because ratatui 0.30 requires it, which is what
mainneeds today; the 2024 edition (1.85) fits under that. Checked with real toolchains: 1.88.0 builds and passes the tests, 1.87.0 is refused by cargo with a clear message.Bugs fixed
-sand--pacemodes). It now exits with the error.0.1subtracted repeatedly does not land on zero.maintoday a peak shaved flat at the top row. It can look "lower than the max value" because the point that is missing is the maximum itself: what remains either side of the gap are its lower neighbours.(width - len) / 2while ratatui centres the text withwidth / 2 - len / 2. The two differ by one when the width is even and the length is odd: in an 80-column terminal with a 43-character prompt the text starts at column 19 and the cursor was put at column 18, one cell left of the next character for the whole test. The offset is now worked out once and used for both. Five snapshots change by that one cell; their text and styles do not.Found by the review step of the validation pipeline, not by me:
Suggestions not taken
They change behaviour on purpose or reach outside this change.
panic = "abort": 6% smaller binary, no speed change. Not done: a panic would end in SIGABRT and the terminal restore would rest on the hook alone.opt-level = "s"/"z": 13% / 15% smaller, frames 8-20% / 2.4 times slower.cgisf_lib0.2: changes what-fgenerates.-wlarger than the word list gives the whole list and logs the requested number.cargo clippy --all-targets, and a job on the minimum Rust version. The workflow is unchanged here.thokrgives 50 words; the default is 15. The roadmap items for performance and tests could also be ticked. Left as they are: the first was wrong before this change, the second is the author's own status.Proof that nothing else changed
--help,-h,-V, three argument errors and the not-a-terminal error: byte-identical output and exit codes.mainhold: text and styles untouched, the cursor one cell to the right in five of them (bug 7). Four results snapshots lose the(t)weetlegend entry and nothing else. New: a snapshot of a 98.4 wpm peak, a test of a sub-second result, and a check over 500 generated results that every point lies inside both axes (the last two fail without the fix).icanon echoon, the cursor shown and the normal screen active, for both binaries.-l english10k -w 30and-f 2: same shape; the words are random.SystemTimetoInstant,cursor_postoinput.len(),char_cell(&chars, width, i)tochar_cell(&lines, i),has_started()tostarted_at.is_some().check,fmt,lintandtestare green.How this was validated
The change went through the validation pipeline twice: once at the head that carried out the critique, and once at the final head,
26c2e35, after the tweet capability was removed and #22 was fixed.Second run, at the final head
Review: no functional defect found. Two notes, neither fixed here, both listed under follow-ups below.
Test: clean pass. 13 of 14 scenarios were driven against the release binary in a real terminal (tmux), most of them side by side with v0.5.0, and 67 checks passed with none failing:
The panic path was not driven in this run because the shipped binary could not be made to panic through its own surface (an empty prompt, a 1x1 terminal, resize storms and a blocked history log were tried). It was driven in the first run and in the comparison above, with a test build in which a key forces a panic: the terminal came back usable and the message readable.
Its timing against v0.5.0, medians on one machine through one pty: binary 2.95 MB to 1.35 MB, launch to first frame 5.03 ms to 2.75 ms (3.58 ms to 2.10 ms with
-l english10k). A waiting test is one thread with no wake-ups in 2 s; v0.5.0 has two threads. With the wall clock stepped forward one hour in the middle of a test, this branch reports 237 wpm over 2.18 s and v0.5.0 reports 1 wpm over 3602.17 s.Screens captured by that run (terminal captures rendered to images):
Lint: did not run in the pipeline. Its agent had no Rust toolchain on PATH.
cargo fmt --all -- --checkandcargo clippy --all-features -- -D warningsare thefmtandlintCI jobs on this PR, which are green at26c2e35.Document: nothing in the repository was stale. One thing it cannot fix from here: the demo gif the README links to lives in a separate assets repository and ends on a results screen whose legend still shows
(t)weet. It needs re-recording there.CI:
check,fmt,lintandtestare green at26c2e35.First run, before the tweet removal and the #22 fix
Running screens, results screens, the terminal after exit, timed tests and the pace caret, the browser lookup, resizing and the word lists: matching the base build cell for cell, apart from the two listed changes.
A history log written by the base build, then this head, then the base build again: one file, same header, same columns throughout.
Its timing, measured its own way (rustc 1.99, one byte into a pty until the frame comes back):
thokr-l english10k -w 50README.mdis identical tomain.Follow-ups from the second review, not in this PR
results_chart_of_a_test_shorter_than_a_seconddepends on the real clock with a 500 ms margin: on a runner that stalls for half a second between two lines it would fail. It should build its case from fixed coordinates, aspeak_with_a_fraction_below_one_halfdoes. The generated-results test already covers sub-second points without the clock.Input.charinsrc/thok.rsis written and never read. It is dead state of the kind this change removes elsewhere and can go, with its four initialisers.Open PRs
This will conflict with #18, #26, #27 and #28 (#27 is superseded by the fix for #22 here): each edits
src/main.rs,src/thok.rsorsrc/ui.rs, which are rewritten here, and #26 also editssrc/lang/mod.rs, which is nowsrc/lang.rs. #25 only addsdocs/usage-guide.mdand does not conflict. None of them is folded in.Notes
thatvegandevinCargo.toml(repository, homepage, author email),README.md, and the Docker image name in.github/workflows/deploy.yml.