summaryrefslogtreecommitdiff
path: root/rsvp
diff options
context:
space:
mode:
Diffstat (limited to 'rsvp')
-rw-r--r--rsvp/player.lua200
1 files changed, 185 insertions, 15 deletions
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