summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-10-27 20:41:49 -0600
committert <t@tjp.lol>2026-10-27 21:11:55 -0600
commitff6d9a4c30ec4218c559960741745a4b190fb063 (patch)
tree40cafda55d349d5ab3020dc36f07626bb0c4f4be
parent009d5705a59bcf86f3579a78e25c8b3fcd4cd476 (diff)
Add idle RSVP focus view and persist reading speedHEADmain
-rw-r--r--README.md16
-rw-r--r--_selfcheck.lua226
-rw-r--r--init.lua13
-rw-r--r--rsvp/player.lua200
4 files changed, 421 insertions, 34 deletions
diff --git a/README.md b/README.md
index 42148f6..b9d15ee 100644
--- a/README.md
+++ b/README.md
@@ -21,18 +21,22 @@ RSVP is an optional **focus mode**, not a comprehension or speed promise.
"ctrl+s" = "/rsvp"
```
-3. Restart panto. Run `/rsvp` or Ctrl+S while idle to start the next unread paragraph.
+3. Restart panto. Run `/rsvp` or Ctrl+S while idle to open the focus view. **Playback does not start on activation**; press Ctrl+S again to start the next unread paragraph.
If `[extensions]` or `[keys]` already exists, add these entries to the existing tables rather than declaring them twice.
## Controls
-- Ctrl+S: play the next unread paragraph while waiting; ignored during playback.
+- Ctrl+S: enter the focus view when idle; once open, start the next unread paragraph. Ignored during playback.
- Space: pause or resume. Resume gives the current word a fresh interval.
-- `-` / `+`: adjust speed; changes apply to the next word interval.
+- `-` / `+`: adjust speed; changes apply to the next word interval and are saved to `ext.state` when changed.
- Esc: leave. An unfinished paragraph remains unread and restarts on the next activation.
-The current word stays centered above the input, with speed and paragraph progress. Text arriving while a paragraph is playing does not change that paragraph's word snapshot. Progress resets on session replacement and is not persisted.
+The view opens idle, with stationary `|` markers above and below the focus column; no playback timer runs until the second Ctrl+S. The focus column is `floor(viewport width / 2) + 1`, fixed for idle and playback. Word length excludes ASCII and common Unicode punctuation (which remains displayed and counts toward visual offset): lengths 1 target character 1; 2–5 target 2; 6–9 target 3; 10–13 target 4; 14+ target 5. Punctuation-only tokens fall back to their first visible character. ANSI styling is ignored for counting and retained for display; combining marks do not add a character, and host display widths account for Unicode glyph widths and clipping. Unicode punctuation recognition and complex grapheme clusters are approximate, and rendering remains bounded in narrow viewports.
+
+The word-length buckets follow the [Spritz-style rule documented by Stanford](https://nifty.stanford.edu/2015/posera-speed-reader/speed_reader.html); the [Spritz patent, Table I](https://patents.google.com/patent/US20140016867A1/en), excludes punctuation when counting word characters. This is an alignment heuristic, not a demonstrated comprehension benefit.
+
+The current word stays above the input, with speed and paragraph progress. Text arriving while a paragraph is playing does not change that paragraph's word snapshot. Progress resets on session replacement and is not persisted.
## Settings
@@ -47,11 +51,11 @@ min_wpm = 50
| Setting | Default | Meaning |
| --- | ---: | --- |
-| `wpm` | `250` | Initial words per minute; values below `min_wpm` start at the lower bound. |
+| `wpm` | `250` | Fallback words per minute; values below `min_wpm` start at the lower bound. A saved `rsvp.wpm` value overrides this fallback when it is finite and positive. |
| `wpm_step` | `25` | Change per `-` or `+`. |
| `min_wpm` | `50` | Lowest speed; `-` stops here. |
-All three values must be finite positive numbers. Theme changes re-render the active paragraph using the current Markdown display style.
+The extension restores and saves speed through `ext.state.get("rsvp.wpm")` / `ext.state.set("rsvp.wpm", speed)`; it does not access state files directly. Missing or invalid saved values use the configured `wpm`; restored speeds are clamped to `min_wpm`. Read/write failures are shown in the focus view without stopping playback or discarding an in-memory speed change. All three configuration values must be finite positive numbers. Theme changes re-render the active paragraph using the current Markdown display style.
## Development
diff --git a/_selfcheck.lua b/_selfcheck.lua
index bbcf380..ed92458 100644
--- a/_selfcheck.lua
+++ b/_selfcheck.lua
@@ -14,6 +14,47 @@ for _, bad in ipairs({ 0, -1, 0 / 0, math.huge, "fast" }) do
assert(settings == nil and err:find("rsvp.wpm"), "invalid WPM rejected")
end
+local function plain(text)
+ return (text or ""):gsub("\27%[[0-9;:]*m", ""):gsub("\27%]8;;.-\27\\", "")
+end
+
+local function char_width(char)
+ local cp = utf8.codepoint(char)
+ if (cp >= 0x0300 and cp <= 0x036f) or cp == 0x200d
+ or (cp >= 0xfe00 and cp <= 0xfe0f) then return 0 end
+ if cp >= 0x1100 and (cp <= 0x115f or cp == 0x2329 or cp == 0x232a
+ or (cp >= 0x2e80 and cp <= 0xa4cf) or (cp >= 0xac00 and cp <= 0xd7a3)
+ or (cp >= 0xf900 and cp <= 0xfaff) or (cp >= 0xfe10 and cp <= 0xfe6f)
+ or (cp >= 0xff00 and cp <= 0xff60) or (cp >= 0x1f300 and cp <= 0x1faff)) then return 2 end
+ return 1
+end
+
+local function display_width(text)
+ local stripped, width = plain(text), 0
+ for _, cp in utf8.codes(stripped) do width = width + char_width(utf8.char(cp)) end
+ return width
+end
+
+local function truncate_display(text, max_width)
+ local output, width, i = {}, 0, 1
+ while i <= #text do
+ if text:sub(i, i) == "\27" then
+ local sequence = text:match("^(\27%[[0-9;:]*m)", i) or text:match("^(\27%]8;;.-\27\\)", i)
+ if sequence then output[#output + 1] = sequence; i = i + #sequence
+ else output[#output + 1] = text:sub(i, i); i = i + 1 end
+ else
+ local cp = utf8.codepoint(text, i)
+ local char = utf8.char(cp)
+ local char_width = char_width(char)
+ if width + char_width > max_width then break end
+ output[#output + 1] = char
+ width, i = width + char_width, i + #char
+ end
+ end
+ output[#output + 1] = "\27]8;;\27\\\27[0m"
+ return table.concat(output)
+end
+
local function host(options)
options = options or {}
local h = { timers = {}, modes = {}, mounts = {}, highlighted = {}, styles = { welcome = "a", tool_header = "b" } }
@@ -58,16 +99,15 @@ local function host(options)
h.options = options
h.player = Player.new {
settings = options.settings or { wpm = 250, wpm_step = 25, min_wpm = 50 },
+ save_wpm = options.save_wpm or function() return true end,
+ persistence_error = options.persistence_error,
highlight = function(body)
h.highlighted[#h.highlighted + 1] = body
if options.highlight then return options.highlight(body) end
return body
end,
- width = function(text)
- text = text:gsub("\27%[[0-9;]*m", ""):gsub("\27%]8;;.-\27\\", "")
- return #text
- end,
- truncate = function(text, width) return text:sub(1, width) end,
+ width = display_width,
+ truncate = truncate_display,
style = function() return h.styles end,
new_timer = h.new_timer,
mount = h.mount,
@@ -76,10 +116,6 @@ local function host(options)
return h
end
-local function plain(text)
- return (text or ""):gsub("\27%[[0-9;:]*m", "")
-end
-
local function key(h, chord)
for _, binding in ipairs(h.modes[#h.modes].spec.keys) do
if binding.key == chord then binding.run(h.modes[#h.modes]); return end
@@ -105,6 +141,12 @@ do
p:set_source("older text\n", true, 4)
local first_generation = p.generation
assert(p:activate())
+ local idle = p:render(20)
+ assert(not p.playback and p.timer == nil and idle[1]:sub(11, 11) == "|" and idle[3] == idle[1],
+ "activation stays idle with stationary centered focus markers")
+ assert(plain(p:render(80)[4]):find("Ready · Ctrl+S to start", 1, true)
+ and h.modes[1].spec.keys[1].desc == "start/next", "idle prompt and control description explain playback")
+ key(h, "ctrl+s")
local stale = p.timer
assert(plain(p.current_word) == "older")
@@ -139,11 +181,15 @@ do
local h = host()
local p = h.player
p:set_source("\n\nrestart me\n", true, 2)
- assert(p:activate() and plain(p.current_word) == "restart")
+ assert(p:activate())
+ key(h, "ctrl+s")
+ assert(plain(p.current_word) == "restart")
assert(p.cursor == 3, "cursor retains the original source segment index")
p:cancel("closed")
assert(p.cursor == 3, "cancel does not mark the active paragraph read")
- assert(p:activate() and plain(p.current_word) == "restart", "next activation restarts unfinished paragraph")
+ assert(p:activate())
+ key(h, "ctrl+s")
+ assert(plain(p.current_word) == "restart", "next activation restarts unfinished paragraph")
p:cancel("closed")
end
@@ -161,7 +207,9 @@ do
local h, p = host(), nil
p = h.player
p:set_source(source, true, 1)
- assert(p:activate() and plain(p.current_word) == "read")
+ assert(p:activate())
+ key(h, "ctrl+s")
+ assert(plain(p.current_word) == "read")
assert(h.highlighted[1] == filtered, "highlight receives the complete filtered body")
finish_paragraph(p)
assert(p:next() and plain(p.current_word) == "after")
@@ -185,7 +233,9 @@ do
}
local p = h.player
p:set_source("one two\nthree four", true, 1)
- assert(p:activate() and p.current_word:find(esc .. "%[1m"))
+ assert(p:activate())
+ key(h, "ctrl+s")
+ assert(p.current_word:find(esc .. "%[1m"))
local first_timer = p.timer
fire(first_timer)
assert(p.current_word:find("two") and p.current_word:find(esc .. "%[1m"), "style replays at the next word")
@@ -206,6 +256,8 @@ do
local p = h.player
p:set_source("one two", true, 1)
assert(p:activate())
+ assert(p.timer == nil and p.current_word == nil, "activation does not schedule playback")
+ key(h, "ctrl+s")
local first_timer = p.timer
assert(first_timer.timeout == 600)
key(h, "ctrl+s")
@@ -237,6 +289,9 @@ do
local p = h.player
p:set_source("**not raw playback**", true, 1)
assert(p:activate())
+ key(h, "ctrl+s")
+ assert(p.playback == nil and p.current_word == nil)
+ key(h, "ctrl+s")
assert(p.playback == nil and p.current_word == nil)
assert(p.status:find("Markdown rendering failed", 1, true))
p:cancel("closed")
@@ -248,6 +303,7 @@ do
local p = h.player
p:set_source("stay visible\n", true, 1)
assert(p:activate())
+ key(h, "ctrl+s")
local timer, word = p.timer, p.current_word
p:suspend()
assert(p.paused and timer.closing and p.current_word == word)
@@ -262,6 +318,7 @@ for _, exit in ipairs({ "closed", "turn", "session", "error", "teardown", "sessi
local p = h.player
p:set_source("unfinished paragraph\n", true, 1)
assert(p:activate())
+ key(h, "ctrl+s")
local stale, mode, mount = p.timer, h.modes[1], h.mounts[1]
local paints = mount.invalidations
if exit == "closed" then
@@ -277,4 +334,147 @@ for _, exit in ipairs({ "closed", "turn", "session", "error", "teardown", "sessi
assert(mount.unmounts == 1, exit .. " unmounts exactly once")
end
+-- Focus buckets count visible non-punctuation characters, not ANSI bytes, and
+-- alignment uses display columns including leading punctuation and Unicode width.
+do
+ local h = host()
+ local p = h.player
+ p:activate()
+ local cases = {
+ { "x", 1 }, { "ab", 2 }, { "abcde", 2 }, { "abcdef", 3 },
+ { "abcdefghi", 3 }, { "abcdefghij", 4 }, { "abcdefghijklm", 4 },
+ { "abcdefghijklmn", 5 }, { "well-known,", 3 },
+ { "'books,'", 3 }, { "“books,”", 3 }, { "!!!", 1 },
+ { "\27[1mabcdef\27[0m", 3 }, { "e\204\129abcd", 3 },
+ { "界界界abc", 3 }, { "「界界界。」", 3 },
+ }
+ for _, case in ipairs(cases) do
+ p.current_word = case[1]
+ local rows = p:render(40)
+ assert(rows[1]:sub(21, 21) == "|" and rows[3] == rows[1], "focus markers stay centered and stationary")
+ local visible = plain(rows[2])
+ local lead = #(visible:match("^( *)") or "")
+ local word = visible:sub(lead + 1)
+ local chars = {}
+ for _, cp in utf8.codes(word) do chars[#chars + 1] = utf8.char(cp) end
+ local before = 0
+ for i = 1, case[2] - 1 do before = before + char_width(chars[i]) end
+ assert(lead + before + 1 == 21, "focus column correct for " .. case[1])
+ if case[1]:find("\27", 1, true) then assert(rows[2]:find("\27[1m", 1, true), "ANSI styling survives alignment") end
+ end
+
+ p.current_word = "\27[1mabcdefghijklmnopqrstuv\27[0m"
+ local narrow = p:render(8)
+ assert(narrow[1] == " |" and narrow[3] == narrow[1], "narrow view bounds centered marker")
+ for _, row in ipairs(narrow) do assert(display_width(row) <= 8, "rendered row stays within viewport") end
+ assert(narrow[2]:find("\27[1m", 1, true), "narrow clipping preserves ANSI styling")
+
+ p.current_word = "界界界界界界"
+ narrow = p:render(3)
+ assert(narrow[1] == " |" and narrow[3] == narrow[1], "marker clamps to the viewport center")
+ for _, row in ipairs(narrow) do assert(display_width(row) <= 3, "wide glyph clipping stays bounded") end
+ for _, width in ipairs({ 0, 1, 2, 3, 8, 40 }) do
+ for _, row in ipairs(p:render(width)) do
+ assert(display_width(row) <= width, "all rows fit even a zero-width viewport")
+ end
+ end
+ p:cancel("closed")
+end
+
+-- Speed changes persist only when the value really changes; failures keep the
+-- in-memory speed and remain visible without interrupting RSVP.
+do
+ local writes = {}
+ local h = host { settings = { wpm = 100, wpm_step = 25, min_wpm = 50 },
+ save_wpm = function(value) writes[#writes + 1] = value; return nil, "disk unavailable" end }
+ local p = h.player
+ assert(p:adjust(-1) == 75 and writes[1] == 75, "changed speed is persisted")
+ assert(p.persistence_error:find("disk unavailable", 1, true), "save failure is retained")
+ assert(plain(p:render(80)[5]):find("WPM save failed", 1, true), "save failure is surfaced in the view")
+ p:adjust(1)
+ assert(p.wpm == 100 and p.persistence_error, "failed save does not roll back speed")
+ local count = #writes
+ for _ = 1, 10 do p:adjust(-1) end
+ assert(p.wpm == 50 and #writes == count + 2, "clamped no-op adjustments are not persisted")
+ p.save_wpm = function() return true end
+ p:adjust(1)
+ assert(p.persistence_error == nil, "successful save clears the previous warning")
+end
+
+-- Init reads/writes only through ext.state and opens in idle mode. Invalid saved
+-- values use configured WPM; storage errors stay visible but do not block use.
+do
+ local function launch(saved, read_error, write_error)
+ local h, events, persisted = host(), {}, saved
+ local command
+ local ext = {
+ config = { rsvp = { wpm = 100, wpm_step = 25, min_wpm = 50 } },
+ state = {
+ get = function(key)
+ assert(key == "rsvp.wpm")
+ return persisted, read_error
+ end,
+ set = function(key, value)
+ assert(key == "rsvp.wpm")
+ if write_error then return nil, write_error end
+ persisted = value
+ return true
+ end,
+ },
+ on = function(name, callback) events[name] = callback end,
+ highlight = function(body) return body end,
+ style = function() return h.styles end,
+ mount = h.mount,
+ input_mode = h.input_mode,
+ register_command = function(spec) command = spec end,
+ }
+ package.loaded.panto = { ext = ext, text = { width = display_width, truncate = truncate_display } }
+ package.loaded.luv = { new_timer = function() return h.new_timer() end }
+ local extension = dofile(here .. "/init.lua")
+ extension.activate()
+ assert(command and command.name == "rsvp")
+ command.handler("")
+ return h, events, function() return persisted end
+ end
+
+ local h, events, persisted = launch(30)
+ local idle = h.mounts[1].component:render(40)
+ assert(h.timers[1] == nil and idle[1]:sub(21, 21) == "|" and idle[3] == idle[1],
+ "extension activation is visible but schedules no playback")
+ assert(plain(idle[4]):find("50 WPM", 1, true), "saved speed clamps to configured minimum")
+ events.render_assistant_text({ text = "first paragraph\nsecond", index = 1 })
+ key(h, "ctrl+s")
+ assert(h.timers[1] and plain(h.mounts[1].component:render(40)[2]):find("first", 1, true),
+ "first Ctrl+S starts the first paragraph")
+ key(h, "+")
+ assert(persisted() == 75, "extension saves WPM through ext.state.set")
+ key(h, "escape")
+
+ h = launch(175)
+ assert(plain(h.mounts[1].component:render(40)[4]):find("175 WPM", 1, true),
+ "finite positive saved WPM overrides configured fallback")
+ key(h, "escape")
+
+ for _, invalid in ipairs({ 0, -1, 0 / 0, math.huge, "fast" }) do
+ h = launch(invalid)
+ local view = h.mounts[1].component:render(40)
+ assert(plain(view[4]):find("100 WPM", 1, true), "invalid saved WPM uses configured fallback")
+ key(h, "escape")
+ end
+
+ h = launch(nil, "read denied")
+ local warning = h.mounts[1].component:render(60)
+ assert(plain(warning[4]):find("100 WPM", 1, true) and plain(warning[5]):find("read denied", 1, true),
+ "read failure falls back and is displayed")
+ key(h, "+")
+ assert(#h.mounts[1].component:render(60) == 4, "successful save clears the resolved restore warning")
+ key(h, "escape")
+
+ h = launch(nil, nil, "write denied")
+ key(h, "+")
+ assert(plain(h.mounts[1].component:render(60)[5]):find("write denied", 1, true),
+ "extension displays ext.state.set failures")
+ key(h, "escape")
+end
+
print("panto-rsvp self-check passed")
diff --git a/init.lua b/init.lua
index 532152a..a9cf18f 100644
--- a/init.lua
+++ b/init.lua
@@ -6,10 +6,23 @@ local function activate()
local ext = panto.ext
local settings, err = config.read(ext.config and ext.config.rsvp)
if not settings then error("panto-rsvp: " .. err) end
+ local restored, state_err
+ local state_ok, saved_wpm, read_err = pcall(function() return ext.state.get("rsvp.wpm") end)
+ if not state_ok then
+ state_err = "WPM restore failed: " .. tostring(saved_wpm)
+ elseif read_err ~= nil then
+ state_err = "WPM restore failed: " .. tostring(read_err)
+ elseif type(saved_wpm) == "number" and saved_wpm == saved_wpm and saved_wpm > 0
+ and saved_wpm < math.huge then
+ restored = math.max(settings.min_wpm, saved_wpm)
+ end
+ if restored then settings.wpm = restored end
local uv = require("luv")
local player = Player.new {
settings = settings,
+ persistence_error = state_err,
+ save_wpm = function(speed) return ext.state.set("rsvp.wpm", speed) end,
highlight = function(body) return ext.highlight(body, "markdown") end,
width = panto.text.width,
truncate = panto.text.truncate,
diff --git a/rsvp/player.lua b/rsvp/player.lua
index b98d00e..795b10c 100644
--- a/rsvp/player.lua
+++ b/rsvp/player.lua
@@ -137,8 +137,117 @@ local function visible_line_count(lines, complete)
return n
end
-local function finite_width(value)
- return type(value) == "number" and value == value and value > 0 and value < math.huge
+local function viewport_width(value)
+ if type(value) ~= "number" or value ~= value or value <= 0 or value >= math.huge then return 0 end
+ return math.floor(value)
+end
+
+local function char_at(text, at)
+ local first = text:byte(at)
+ local length = first < 0x80 and 1 or first < 0xe0 and 2 or first < 0xf0 and 3 or 4
+ local after = math.min(#text + 1, at + length)
+ return text:sub(at, after - 1), after
+end
+
+local function codepoint(char)
+ local ok, value = pcall(utf8.codepoint, char)
+ return ok and value or char:byte()
+end
+
+local function combining(cp)
+ return (cp >= 0x0300 and cp <= 0x036f)
+ or (cp >= 0x0483 and cp <= 0x0489)
+ or (cp >= 0x0591 and cp <= 0x05bd)
+ or cp == 0x05bf or (cp >= 0x05c1 and cp <= 0x05c2)
+ or (cp >= 0x0610 and cp <= 0x061a)
+ or (cp >= 0x064b and cp <= 0x065f) or cp == 0x0670
+ or (cp >= 0x06d6 and cp <= 0x06ed)
+ or (cp >= 0x0900 and cp <= 0x0903)
+ or (cp >= 0x093a and cp <= 0x094f)
+ or (cp >= 0x1ab0 and cp <= 0x1aff)
+ or (cp >= 0x1dc0 and cp <= 0x1dff)
+ or (cp >= 0x20d0 and cp <= 0x20ff)
+ or cp == 0x200d
+ or (cp >= 0xfe00 and cp <= 0xfe0f)
+ or (cp >= 0xfe20 and cp <= 0xfe2f)
+ or (cp >= 0x1f3fb and cp <= 0x1f3ff)
+ or (cp >= 0xe0100 and cp <= 0xe01ef)
+end
+
+local function punctuation(cp, char)
+ if cp < 0x80 then return char:match("^%p$") ~= nil end
+ return cp == 0x00a1 or cp == 0x00a7 or cp == 0x00ab or cp == 0x00b6
+ or cp == 0x00b7 or cp == 0x00bb or cp == 0x00bf
+ or cp == 0x037e or cp == 0x0387
+ or (cp >= 0x055a and cp <= 0x055f) or cp == 0x0589 or cp == 0x058a
+ or cp == 0x05be or cp == 0x05c0 or cp == 0x05c3 or cp == 0x05c6
+ or (cp >= 0x0609 and cp <= 0x060a) or cp == 0x060c or cp == 0x061b
+ or (cp >= 0x061d and cp <= 0x061f) or (cp >= 0x066a and cp <= 0x066d)
+ or cp == 0x06d4 or cp == 0x0964 or cp == 0x0965 or cp == 0x0e4f
+ or (cp >= 0x0f0d and cp <= 0x0f12) or (cp >= 0x17d4 and cp <= 0x17da)
+ or (cp >= 0x1800 and cp <= 0x180a)
+ or (cp >= 0x2000 and cp <= 0x206f) or (cp >= 0x2e00 and cp <= 0x2e7f)
+ or (cp >= 0x3001 and cp <= 0x303f) or (cp >= 0xa4fe and cp <= 0xa4ff)
+ or (cp >= 0xfe10 and cp <= 0xfe1f) or (cp >= 0xfe30 and cp <= 0xfe4f)
+ or (cp >= 0xff01 and cp <= 0xff0f) or (cp >= 0xff1a and cp <= 0xff20)
+ or (cp >= 0xff3b and cp <= 0xff40) or (cp >= 0xff5b and cp <= 0xff65)
+end
+
+local function visible_chars(text)
+ local chars, i = {}, 1
+ while i <= #text do
+ local sequence, after = control_at(text, i)
+ if sequence then
+ i = after
+ else
+ local char
+ char, i = char_at(text, i)
+ local cp = codepoint(char)
+ chars[#chars + 1] = {
+ text = char,
+ visible = not combining(cp),
+ word_char = not combining(cp) and not punctuation(cp, char),
+ }
+ end
+ end
+ return chars
+end
+
+local function focus_index(length)
+ if length <= 1 then return 1 end
+ if length <= 5 then return 2 end
+ if length <= 9 then return 3 end
+ if length <= 13 then return 4 end
+ return 5
+end
+
+local function styled_clip(text, skip, limit, measure)
+ local output, skipped, used, i = {}, 0, 0, 1
+ local clipping = skip > 0
+ while i <= #text do
+ local sequence, after = control_at(text, i)
+ if sequence then
+ output[#output + 1] = sequence
+ i = after
+ else
+ local char
+ char, i = char_at(text, i)
+ local char_width = measure(char)
+ if clipping and skipped + char_width <= skip then
+ skipped = skipped + char_width
+ else
+ clipping = false
+ if char_width == 0 or used + char_width <= limit then
+ output[#output + 1] = char
+ used = used + char_width
+ else
+ break
+ end
+ end
+ end
+ end
+ output[#output + 1] = "\27]8;;\27\\\27[0m"
+ return table.concat(output)
end
local Player = {}
@@ -154,6 +263,8 @@ function Player.new(options)
mount_component = assert(options.mount),
acquire_mode = assert(options.input_mode),
settings = assert(options.settings),
+ save_wpm = assert(options.save_wpm),
+ persistence_error = options.persistence_error,
wpm = options.settings.wpm,
source = "",
source_complete = false,
@@ -231,7 +342,7 @@ function Player:_mode_spec()
local self_ref = self
return {
keys = {
- { key = "ctrl+s", desc = "next", run = function() self_ref:next() end },
+ { key = "ctrl+s", desc = "start/next", run = function() self_ref:next() end },
{ key = "space", desc = "pause/resume", run = function() self_ref:toggle_pause() end },
{ key = "-", desc = "slower", run = function() self_ref:adjust(-1) end },
{ key = "+", desc = "faster", run = function() self_ref:adjust(1) end },
@@ -249,14 +360,13 @@ function Player:activate()
self.released = false
self.active = true
self.mode = mode
- self.status = "Ready"
+ self.status = "Ready · Ctrl+S to start"
local ok, mounted = pcall(self.mount_component, self.component, "above_input")
if not ok then
self:cancel("error")
return nil, tostring(mounted)
end
self.mount = mounted
- self:next()
self:_invalidate()
return true
end
@@ -457,8 +567,18 @@ function Player:suspend()
end
function Player:adjust(direction)
- local candidate = self.wpm + self.settings.wpm_step * direction
- self.wpm = math.max(self.settings.min_wpm, candidate)
+ local candidate = math.max(self.settings.min_wpm, self.wpm + self.settings.wpm_step * direction)
+ if candidate ~= self.wpm then
+ self.wpm = candidate
+ local ok, result, err = pcall(self.save_wpm, self.wpm)
+ if not ok then
+ self.persistence_error = "WPM save failed: " .. tostring(result)
+ elseif result ~= true then
+ self.persistence_error = "WPM save failed: " .. tostring(err or "state API rejected the value")
+ else
+ self.persistence_error = nil
+ end
+ end
self:_invalidate()
return self.wpm
end
@@ -487,20 +607,66 @@ function Player:_refresh_active_theme()
end
end
+function Player:_measure(text)
+ local ok, value = pcall(self.width, text)
+ if ok and type(value) == "number" and value == value and value >= 0 and value < math.huge then
+ return value
+ end
+ return 0
+end
+
+function Player:_focus_offset(text)
+ local chars = visible_chars(text)
+ local length = 0
+ for _, char in ipairs(chars) do
+ if char.word_char then length = length + 1 end
+ end
+ local target = focus_index(length)
+ local before, seen = {}, 0
+ for _, char in ipairs(chars) do
+ if length == 0 then
+ if char.visible then break end
+ elseif char.word_char then
+ seen = seen + 1
+ if seen == target then break end
+ end
+ before[#before + 1] = char.text
+ end
+ return self:_measure(table.concat(before))
+end
+
+function Player:_fit(text, width)
+ if width <= 0 then return "" end
+ if self:_measure(text) <= width then return text end
+ local ok, result = pcall(self.truncate, text, width)
+ if ok and type(result) == "string" and self:_measure(result) <= width then return result end
+ return styled_clip(text, 0, width, function(char) return self:_measure(char) end)
+end
+
function Player:_center(text, width)
+ width = viewport_width(width)
+ if width == 0 then return "" end
+ text = self:_fit(text, width)
+ local text_width = math.min(width, self:_measure(text))
+ return string.rep(" ", math.floor(math.max(0, width - text_width) / 2)) .. text
+end
+
+function Player:_at_focus(text, width, column)
if width <= 0 then return "" end
- local text_width = self.width(text)
- if not finite_width(text_width) then text_width = 0 end
- if text_width > width then
- text = self.truncate(text, width)
- text_width = math.min(width, math.max(0, self.width(text)))
+ local offset = self:_focus_offset(text)
+ local left = column - 1 - offset
+ if left >= 0 then
+ return self:_fit(string.rep(" ", left) .. text, width)
end
- return string.rep(" ", math.floor(math.max(0, width - text_width) / 2)) .. text
+ return styled_clip(text, -left, width, function(char) return self:_measure(char) end)
end
function Player:render(width)
self:_refresh_active_theme()
- local word = self.current_word or (self.status:find("failed", 1, true) and "Markdown rendering failed" or "Rapid serial presentation")
+ width = viewport_width(width)
+ local column = width > 0 and (math.floor(width / 2) + 1) or 0
+ local marker = column > 0 and (string.rep(" ", column - 1) .. "|") or ""
+ local word = self.current_word or "RSVP"
local detail
if self.playback then
detail = string.format("%g WPM · paragraph %d/%d · %s", self.wpm, self.playback.rank, self.playback.total,
@@ -510,7 +676,11 @@ function Player:render(width)
else
detail = string.format("%g WPM · paragraph %d · %s", self.wpm, self.read_count + 1, self.status)
end
- return { self:_center(word, width), self:_center(detail, width) }
+ local rows = { marker, self:_at_focus(word, width, column), marker, self:_center(detail, width) }
+ if self.persistence_error then
+ rows[#rows + 1] = self:_center(self.persistence_error, width)
+ end
+ return rows
end
M.Player = Player