src/boost-bars.lua
1-- boost-bars.lua
2--
3-- The nested frame for "boost" (reshared) posts, shared by every render path so
4-- the three copies can't drift (they had, in several different ways). A boost
5-- frame is ASYMMETRIC like a golden poem: the LEFT edge is permanently double
6-- (it anchors the frame), the RIGHT edge is a FILL FRONTIER -- single (┐│┴)
7-- until the progress bar's ═ reaches the far-right column, then double (╗║╩),
8-- which only happens for the chronologically-last poems. Arrows ride the
9-- corners: ◀═ into the top-left, ─▶ out of the bottom-right.
10--
11-- Geometry (columns, 0-indexed; the whole line is 82 wide before the ─▶):
12-- col 0-1 : ◀═ arrow (top) / two spaces (other lines)
13-- col 2 : outer-left wall (╦ top / ║ body / ╠ nav-sep / ╚ bottom) -- DOUBLE
14-- col 3-80: 78-wide interior (progress bar, or " inner-box ")
15-- col 81 : outer-right wall (┐/│/┤/┴ -> ╗/║/┤?/╩ once filled) -- FRONTIER
16-- then : ─▶ arrow on the bottom line only
17--
18-- Colors are injected (the project's boost palette) so this module is palette-
19-- agnostic, mirroring poem-bars.
20
21local M = {}
22
23local BAR_WIDTH = 78 -- progress bar interior, columns 3..80
24local LABEL = "[BOOST]"
25local LABEL_LEN = 7
26
27-- Visible content width inside the green box. It is 72 (not the old 74) because
28-- the body lines are indented 2 columns so their ║ wall sits at col 2, aligned
29-- under the top border's ╦ (which the ◀═ arrow pushes off col 0). Callers that
30-- pre-wrap long content/URLs must wrap to THIS width.
31M.CONTENT_WIDTH = 72
32
33local palette = {
34 arrow = "#dc3c3c", outer_frame = "#74C0FC", inner_box = "#38D9A9",
35 content_text = "#c8b428", -- the boosted text itself (yellow)
36}
37
38-- {{{ function M.configure()
39function M.configure(p)
40 palette = p or palette
41end
42-- }}}
43
44-- {{{ local color()
45local function color(ch, hex, bold)
46 if bold then
47 return string.format('<font color="%s"><b>%s</b></font>', hex, ch)
48 end
49 return string.format('<font color="%s">%s</font>', hex, ch)
50end
51-- }}}
52
53-- right_filled(progress_chars): is the far-right column (the last bar cell)
54-- filled? That decides whether the right frame edge is double or single.
55local function right_filled(progress_chars)
56 return progress_chars >= BAR_WIDTH
57end
58
59-- {{{ function M.top_border()
60-- ◀═╦ ═══[BOOST]═══ ─── ┐ (┐ becomes ╗ when the bar is full)
61function M.top_border(progress_pct)
62 local progress_chars = math.floor(progress_pct * BAR_WIDTH)
63 if progress_chars < LABEL_LEN + 2 then progress_chars = LABEL_LEN + 2 end
64 local label_start = math.floor(progress_chars / 2) - math.floor(LABEL_LEN / 2)
65 if label_start < 1 then label_start = 1 end
66
67 -- Arrow ◀═ feeds into the ╦ tee (double, anchors the left frame edge).
68 local out = { color("◀═", palette.arrow, true), color("╦", palette.outer_frame, true) }
69 -- Single pass: never re-slice a multibyte string (that produced ▢ before).
70 for i = 1, BAR_WIDTH do
71 if i >= label_start and i < label_start + LABEL_LEN then
72 local ch = LABEL:sub(i - label_start + 1, i - label_start + 1) -- ASCII, safe
73 out[#out + 1] = color(ch, palette.arrow, true)
74 elseif i <= progress_chars then
75 out[#out + 1] = color("═", palette.outer_frame, true)
76 else
77 out[#out + 1] = color("─", palette.outer_frame, false)
78 end
79 end
80 -- Right corner: fill frontier.
81 out[#out + 1] = right_filled(progress_chars)
82 and color("╗", palette.outer_frame, true) or "┐"
83 return table.concat(out)
84end
85-- }}}
86
87-- outer_right(progress_chars, single_char, double_char): the right frame wall,
88-- single until the bar fills the far-right column, then double.
89local function outer_right(progress_chars, single_char, double_char)
90 if right_filled(progress_chars) then
91 return color(double_char, palette.outer_frame, true)
92 end
93 return single_char
94end
95
96-- {{{ function M.inner_top() -> ║ ┌──────────┐ │
97function M.inner_top(progress_chars)
98 return " " .. color("║", palette.outer_frame, true) .. " "
99 .. color("┌" .. string.rep("─", 74) .. "┐", palette.inner_box, false) .. " "
100 .. outer_right(progress_chars, "│", "║")
101end
102-- }}}
103
104-- {{{ function M.inner_bottom() -> ║ └──────────┘ │
105function M.inner_bottom(progress_chars)
106 return " " .. color("║", palette.outer_frame, true) .. " "
107 .. color("└" .. string.rep("─", 74) .. "┘", palette.inner_box, false) .. " "
108 .. outer_right(progress_chars, "│", "║")
109end
110-- }}}
111
112-- {{{ function M.content_line() -> ║ │ <content padded to 72> │ │
113function M.content_line(content, progress_chars)
114 content = content or ""
115 -- Count UTF-8 codepoints of the tag-stripped text for an honest column width.
116 local visible = content:gsub("<[^>]+>", "")
117 local _, vlen = visible:gsub("[^\128-\191]", "")
118 -- Color the text yellow, THEN pad with plain spaces (padding stays uncolored).
119 local colored = string.format('<font color="%s">%s</font>', palette.content_text, content)
120 local padded = colored .. string.rep(" ", math.max(0, M.CONTENT_WIDTH - vlen))
121 local inner = color("│", palette.inner_box, false)
122 return " " .. color("║", palette.outer_frame, true) .. " "
123 .. inner .. " " .. padded .. " " .. inner .. " "
124 .. outer_right(progress_chars, "│", "║")
125end
126-- }}}
127
128-- Nav-box geometry (within the 78-wide interior, cols 3..80):
129-- similar box : frame-wall(col2) + " similar " (9, cols 3-11) + box-wall(col12)
130-- gap : 56 spaces (cols 13-68)
131-- different box: box-wall(col69) + " different " (11, cols 70-80) + frame-wall(col81)
132-- The nav boxes are GREEN (inner_box) like the content box; only the FRAME's
133-- right edge (col 81) is the fill frontier. The similar/different box walls
134-- stay single green -- the user asked for the far-right frontier only on boosts
135-- (the per-column "reflection" frontier is a golden-poem feature, in poem-bars).
136local NAV_GAP = 56
137
138-- {{{ function M.nav_separator() -> ╠─────────┐ ... ┌───────────┤
139function M.nav_separator(progress_chars)
140 local frame_wall = color("╠", palette.outer_frame, true) -- frame left, always double
141 local g_dash = color("─", palette.inner_box, false)
142 local g_corner_l = color("┐", palette.inner_box, false) -- similar box right corner
143 local g_corner_r = color("┌", palette.inner_box, false) -- different box left corner
144 local similar_box = frame_wall .. string.rep(g_dash, 9) .. g_corner_l
145 local different_box = g_corner_r .. string.rep(g_dash, 11)
146 .. outer_right(progress_chars, "┤", "╣") -- frame right = frontier
147 return " " .. similar_box .. string.rep(" ", NAV_GAP) .. different_box
148end
149-- }}}
150
151-- {{{ function M.nav_line() -> ║ similar │ ... chronological ... │ different │
152function M.nav_line(similar_link, different_link, chronological_link, progress_chars)
153 local frame_wall = color("║", palette.outer_frame, true) -- frame left, always double
154 local g_wall = color("│", palette.inner_box, false) -- green box wall
155 local sim_html = similar_link or "similar"
156 local dif_html = different_link or "different"
157 local sim_vis = #(sim_html:gsub("<[^>]+>", ""))
158 local dif_vis = #(dif_html:gsub("<[^>]+>", ""))
159 -- " <link> " padded so the visible cell is exactly 9 / 11 wide.
160 local similar_cell = " " .. sim_html .. string.rep(" ", math.max(0, 9 - 2 - sim_vis)) .. " "
161 local different_cell = " " .. dif_html .. string.rep(" ", math.max(0, 11 - 2 - dif_vis)) .. " "
162 -- Center the optional chronological link inside the 56-wide gap.
163 local center = chronological_link or ""
164 local center_vis = #(center:gsub("<[^>]+>", ""))
165 local rem = NAV_GAP - center_vis
166 local left_gap = string.rep(" ", math.floor(rem / 2))
167 local right_gap = string.rep(" ", math.ceil(rem / 2))
168 return " " .. frame_wall .. similar_cell .. g_wall
169 .. left_gap .. center .. right_gap
170 .. g_wall .. different_cell .. outer_right(progress_chars, "│", "║")
171end
172-- }}}
173
174-- {{{ function M.bottom_border() -> ╚═══╧═══...═══┴═══╩─▶
175-- Junctions sit UNDER the nav-box walls: similar box wall at col 12 (bar-index
176-- 10), different box wall at col 69 (bar-index 67). The bottom-right corner is
177-- the fill frontier (┴ single -> ╩ double) and the ─▶ arrow exits to the right.
178function M.bottom_border(progress_pct)
179 local progress_chars = math.floor(progress_pct * BAR_WIDTH)
180 local LEFT_JUNCTION = 10
181 local RIGHT_JUNCTION = 67
182 local out = { " ", color("╚", palette.outer_frame, true) }
183 for i = 1, BAR_WIDTH do
184 local in_progress = i <= progress_chars
185 -- Junction: the green nav wall above lands here. Double-down ╧ once the
186 -- bar has filled past it, single ┴ until then.
187 if i == LEFT_JUNCTION or i == RIGHT_JUNCTION then
188 out[#out + 1] = in_progress
189 and color("╧", palette.outer_frame, true) or color("┴", palette.outer_frame, false)
190 elseif in_progress then
191 out[#out + 1] = color("═", palette.outer_frame, true)
192 else
193 out[#out + 1] = color("─", palette.outer_frame, false)
194 end
195 end
196 -- Bottom-right corner connects up(wall) + left(bar) + right(arrow): ┴/╩.
197 out[#out + 1] = right_filled(progress_chars)
198 and color("╩", palette.outer_frame, true) or color("┴", palette.outer_frame, false)
199 out[#out + 1] = color("─▶", palette.arrow, true)
200 return table.concat(out)
201end
202-- }}}
203
204-- {{{ function M.format_boost()
205-- Assemble a whole boost frame. content_lines is an array of pre-wrapped,
206-- already-HTML-colored content strings (the caller handles URL wrapping etc.).
207-- include_nav adds the similar/different/chronological row.
208function M.format_boost(content_lines, progress_pct, similar_link, different_link, chronological_link, include_nav)
209 local progress_chars = math.floor(progress_pct * BAR_WIDTH)
210 local out = { M.top_border(progress_pct), M.inner_top(progress_chars) }
211 for _, line in ipairs(content_lines) do
212 out[#out + 1] = M.content_line(line, progress_chars)
213 end
214 out[#out + 1] = M.inner_bottom(progress_chars)
215 if include_nav then
216 out[#out + 1] = M.nav_separator(progress_chars)
217 out[#out + 1] = M.nav_line(similar_link, different_link, chronological_link, progress_chars)
218 end
219 out[#out + 1] = M.bottom_border(progress_pct)
220 return table.concat(out, "\n")
221end
222-- }}}
223
224return M
225