From ff6d9a4c30ec4218c559960741745a4b190fb063 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 27 Oct 2026 20:41:49 -0600 Subject: Add idle RSVP focus view and persist reading speed --- rsvp/player.lua | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 185 insertions(+), 15 deletions(-) (limited to 'rsvp/player.lua') 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 -- cgit v1.3