marvi 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/lib/marvi/renderer/curses.rb +193 -7
- data/lib/marvi/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3372fe5ca768321491d123b2591149b77594c9ab0b2bbc4008e16f6b542bcba
|
|
4
|
+
data.tar.gz: 68e2bad815cf3b20c461a8285df8944d568640288e8bfc3aef40117d5095fccb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7ae44f8bdbc6ab8326b986b5663cbf81a79ef6682871af01157c7d390e75f77de907cae36e4a761d4f46e82aefbd42d070c991d98a0fb498300c6eff856ff1bf
|
|
7
|
+
data.tar.gz: a2554b23a557bdec28fcca02afa213edf865f5354ce7673faa16db15d494781a9d9eb7e9d1bcb482b0752806ee943bf486b318c36bab73d7e573b19890a2c54a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.7.0] - 2026-06-15
|
|
4
|
+
|
|
5
|
+
- Add incremental search to the curses pager. Press `/` to search as you type, with all matches highlighted (current match in bold) and the view jumping to the nearest match. Navigate matches with `n`/`N` (vi/less style); search is case-insensitive.
|
|
6
|
+
|
|
3
7
|
## [0.6.0] - 2026-06-11
|
|
4
8
|
|
|
5
9
|
- Render Mermaid fenced code blocks as Unicode box-drawing art. Supports `flowchart`/`graph` (TD/TB/LR/RL), `sequenceDiagram`, `classDiagram`, and `stateDiagram`/`stateDiagram-v2`. Unsupported diagram types, malformed syntax, and over-width output fall back to the highlighted code block.
|
|
@@ -33,6 +33,10 @@ module Marvi
|
|
|
33
33
|
@file = file
|
|
34
34
|
@markdown = markdown
|
|
35
35
|
@scroll = 0
|
|
36
|
+
@search_query = nil
|
|
37
|
+
@search_input = nil
|
|
38
|
+
@matches = []
|
|
39
|
+
@current_match = nil
|
|
36
40
|
mark_reloaded
|
|
37
41
|
|
|
38
42
|
init_curses_state
|
|
@@ -123,6 +127,9 @@ module Marvi
|
|
|
123
127
|
draw
|
|
124
128
|
when "G" then @scroll = max_scroll
|
|
125
129
|
draw
|
|
130
|
+
when "/" then start_search
|
|
131
|
+
when "n" then jump_match(1)
|
|
132
|
+
when "N" then jump_match(-1)
|
|
126
133
|
when "e" then launch_editor if @file
|
|
127
134
|
when "r", "R" then reload_from_key if @file
|
|
128
135
|
when ::Curses::Key::RESIZE then handle_resize
|
|
@@ -132,6 +139,7 @@ module Marvi
|
|
|
132
139
|
def handle_resize
|
|
133
140
|
rewalk
|
|
134
141
|
@scroll = [@scroll, max_scroll].min
|
|
142
|
+
refresh_search_after_rewalk
|
|
135
143
|
draw
|
|
136
144
|
end
|
|
137
145
|
|
|
@@ -197,6 +205,7 @@ module Marvi
|
|
|
197
205
|
@markdown = File.read(@file)
|
|
198
206
|
rewalk
|
|
199
207
|
@scroll = [@scroll, max_scroll].min
|
|
208
|
+
refresh_search_after_rewalk
|
|
200
209
|
end
|
|
201
210
|
|
|
202
211
|
def init_curses_state
|
|
@@ -236,7 +245,7 @@ module Marvi
|
|
|
236
245
|
padding = horizontal_padding
|
|
237
246
|
visible_lines.each_with_index do |line, row|
|
|
238
247
|
::Curses.setpos(row, padding)
|
|
239
|
-
render_line(line)
|
|
248
|
+
render_line(line, highlight_ranges_for(@scroll + row))
|
|
240
249
|
end
|
|
241
250
|
draw_status_bar
|
|
242
251
|
::Curses.refresh
|
|
@@ -247,7 +256,7 @@ module Marvi
|
|
|
247
256
|
top = @scroll + 1
|
|
248
257
|
bottom = [@scroll + page_size, @lines.size].min
|
|
249
258
|
edit_hint = @file ? " e edit" : ""
|
|
250
|
-
status = " #{top}-#{bottom}/#{@lines.size} j/k scroll g/G top/bottom#{edit_hint} q quit"
|
|
259
|
+
status = " #{top}-#{bottom}/#{@lines.size} j/k scroll g/G top/bottom / search#{search_hint}#{edit_hint} q quit"
|
|
251
260
|
updated_hint = @file_updated ? " ● updated (r to reload) " : ""
|
|
252
261
|
available = [::Curses.cols - updated_hint.length, 0].max
|
|
253
262
|
|
|
@@ -261,23 +270,65 @@ module Marvi
|
|
|
261
270
|
end
|
|
262
271
|
end
|
|
263
272
|
|
|
264
|
-
def render_line(line)
|
|
273
|
+
def render_line(line, highlights = nil)
|
|
274
|
+
col = 0
|
|
265
275
|
line.spans.each do |span|
|
|
266
|
-
render_span(span)
|
|
276
|
+
render_span(span, col, highlights)
|
|
277
|
+
col += span.text.length
|
|
267
278
|
rescue ::Curses::Error
|
|
268
279
|
# ignore write errors at line edge
|
|
269
280
|
end
|
|
270
281
|
end
|
|
271
282
|
|
|
272
|
-
def render_span(span)
|
|
283
|
+
def render_span(span, col_offset = 0, highlights = nil)
|
|
273
284
|
attr = build_attr(span)
|
|
285
|
+
if highlights.nil? || highlights.empty?
|
|
286
|
+
write_text(span.text, attr)
|
|
287
|
+
return
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
highlight_segments(span.text, col_offset, highlights).each do |text, state|
|
|
291
|
+
seg_attr = attr
|
|
292
|
+
seg_attr |= ::Curses::A_REVERSE if state
|
|
293
|
+
seg_attr |= ::Curses::A_BOLD if state == :current
|
|
294
|
+
write_text(text, seg_attr)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def write_text(text, attr)
|
|
274
299
|
if attr != 0
|
|
275
|
-
::Curses.attron(attr) { ::Curses.addstr(
|
|
300
|
+
::Curses.attron(attr) { ::Curses.addstr(text) }
|
|
276
301
|
else
|
|
277
|
-
::Curses.addstr(
|
|
302
|
+
::Curses.addstr(text)
|
|
278
303
|
end
|
|
279
304
|
end
|
|
280
305
|
|
|
306
|
+
# Split a span's text into runs of identical highlight state so each run can
|
|
307
|
+
# be drawn with the matching attributes. Highlight ranges are expressed in
|
|
308
|
+
# whole-line character offsets, hence col_offset locates this span on the line.
|
|
309
|
+
def highlight_segments(text, col_offset, highlights)
|
|
310
|
+
chars = text.chars
|
|
311
|
+
states = Array.new(chars.length)
|
|
312
|
+
highlights.each do |start, finish, current|
|
|
313
|
+
start.upto(finish - 1) do |c|
|
|
314
|
+
idx = c - col_offset
|
|
315
|
+
next if idx.negative? || idx >= chars.length
|
|
316
|
+
states[idx] = :current if current
|
|
317
|
+
states[idx] ||= true
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
segments = []
|
|
322
|
+
chars.each_with_index do |ch, i|
|
|
323
|
+
if segments.empty? || segments.last[1] != states[i]
|
|
324
|
+
segments << [ch.dup, states[i]]
|
|
325
|
+
else
|
|
326
|
+
segments.last[0] << ch
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
segments
|
|
330
|
+
end
|
|
331
|
+
|
|
281
332
|
def build_attr(span)
|
|
282
333
|
attr = 0
|
|
283
334
|
attr |= ::Curses::A_BOLD if span.bold
|
|
@@ -309,6 +360,141 @@ module Marvi
|
|
|
309
360
|
@scroll = (@scroll + delta).clamp(0, max_scroll)
|
|
310
361
|
draw
|
|
311
362
|
end
|
|
363
|
+
|
|
364
|
+
# --- Incremental search ---
|
|
365
|
+
|
|
366
|
+
BACKSPACE_KEYS = [127, 8].freeze
|
|
367
|
+
|
|
368
|
+
def start_search
|
|
369
|
+
@search_input = ""
|
|
370
|
+
update_search(@search_input)
|
|
371
|
+
draw_search_prompt
|
|
372
|
+
|
|
373
|
+
loop do
|
|
374
|
+
key = ::Curses.getch
|
|
375
|
+
next if key.nil? || key == -1
|
|
376
|
+
|
|
377
|
+
case key
|
|
378
|
+
when 27 # ESC cancels and clears the search
|
|
379
|
+
clear_search
|
|
380
|
+
draw
|
|
381
|
+
return
|
|
382
|
+
when "\n", "\r", 10, 13, ::Curses::Key::ENTER
|
|
383
|
+
commit_search
|
|
384
|
+
return
|
|
385
|
+
when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
|
|
386
|
+
@search_input = @search_input[0...-1] || ""
|
|
387
|
+
update_search(@search_input)
|
|
388
|
+
draw_search_prompt
|
|
389
|
+
else
|
|
390
|
+
next unless key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
|
|
391
|
+
@search_input += key
|
|
392
|
+
update_search(@search_input)
|
|
393
|
+
draw_search_prompt
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def update_search(query)
|
|
399
|
+
@search_query = query
|
|
400
|
+
recompute_matches
|
|
401
|
+
focus_match_from(@scroll) unless @matches.empty?
|
|
402
|
+
draw
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def commit_search
|
|
406
|
+
if @search_query.to_s.empty? || @matches.empty?
|
|
407
|
+
clear_search
|
|
408
|
+
end
|
|
409
|
+
@search_input = nil
|
|
410
|
+
draw
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def clear_search
|
|
414
|
+
@search_query = nil
|
|
415
|
+
@search_input = nil
|
|
416
|
+
@matches = []
|
|
417
|
+
@current_match = nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def recompute_matches
|
|
421
|
+
@matches = []
|
|
422
|
+
@current_match = nil
|
|
423
|
+
needle = @search_query.to_s.downcase
|
|
424
|
+
return if needle.empty?
|
|
425
|
+
|
|
426
|
+
@lines.each_with_index do |line, line_index|
|
|
427
|
+
haystack = line.plain_text.downcase
|
|
428
|
+
next if haystack.empty?
|
|
429
|
+
|
|
430
|
+
pos = 0
|
|
431
|
+
while (idx = haystack.index(needle, pos))
|
|
432
|
+
@matches << [line_index, idx, idx + needle.length]
|
|
433
|
+
pos = idx + needle.length
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def focus_match_from(from_line)
|
|
439
|
+
return if @matches.empty?
|
|
440
|
+
index = @matches.index { |match| match[0] >= from_line } || 0
|
|
441
|
+
set_current_match(index)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def set_current_match(index)
|
|
445
|
+
return if @matches.empty?
|
|
446
|
+
@current_match = index % @matches.size
|
|
447
|
+
ensure_match_visible(@matches[@current_match][0])
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def ensure_match_visible(line_index)
|
|
451
|
+
if line_index < @scroll || line_index >= @scroll + page_size
|
|
452
|
+
@scroll = [line_index, max_scroll].min
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def jump_match(direction)
|
|
457
|
+
return if @matches.empty?
|
|
458
|
+
if @current_match.nil?
|
|
459
|
+
focus_match_from(@scroll)
|
|
460
|
+
else
|
|
461
|
+
set_current_match(@current_match + direction)
|
|
462
|
+
end
|
|
463
|
+
draw
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def refresh_search_after_rewalk
|
|
467
|
+
return unless @search_query
|
|
468
|
+
recompute_matches
|
|
469
|
+
focus_match_from(@scroll) unless @matches.empty?
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def highlight_ranges_for(line_index)
|
|
473
|
+
return nil if @matches.empty?
|
|
474
|
+
ranges = []
|
|
475
|
+
@matches.each_with_index do |(li, start, finish), i|
|
|
476
|
+
ranges << [start, finish, i == @current_match] if li == line_index
|
|
477
|
+
end
|
|
478
|
+
ranges.empty? ? nil : ranges
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def search_hint
|
|
482
|
+
return " n/N next/prev" if @search_query.nil? || @search_query.empty?
|
|
483
|
+
return " no matches: #{@search_query}" if @matches.empty?
|
|
484
|
+
|
|
485
|
+
position = @current_match ? @current_match + 1 : 0
|
|
486
|
+
" [#{position}/#{@matches.size}] n/N"
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def draw_search_prompt
|
|
490
|
+
prompt = "/#{@search_input}"
|
|
491
|
+
::Curses.setpos(::Curses.lines - 1, 0)
|
|
492
|
+
::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
|
|
493
|
+
::Curses.addstr(prompt.ljust(::Curses.cols)[0, ::Curses.cols])
|
|
494
|
+
end
|
|
495
|
+
::Curses.setpos(::Curses.lines - 1, [prompt.length, ::Curses.cols - 1].min)
|
|
496
|
+
::Curses.refresh
|
|
497
|
+
end
|
|
312
498
|
end
|
|
313
499
|
end
|
|
314
500
|
end
|
data/lib/marvi/version.rb
CHANGED