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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e3091d68f7f5ccd8b131401fd1dce1b010e7d8108125430de3c47bd68b403d4
4
- data.tar.gz: f52d7849ff7982cb1b988b0e5e5e1c9a496661c56e689bd008fe34347b84dd7f
3
+ metadata.gz: a3372fe5ca768321491d123b2591149b77594c9ab0b2bbc4008e16f6b542bcba
4
+ data.tar.gz: 68e2bad815cf3b20c461a8285df8944d568640288e8bfc3aef40117d5095fccb
5
5
  SHA512:
6
- metadata.gz: 54848bb6e78b5833c96eb6aaea4de9869c5dcd18c381c9b05dc69e1d2d9528f93be9846068bf89acc56f569c54b37e96bbd7868c070802b0ae644f98fa8914ed
7
- data.tar.gz: d645f16e52d118bf57ef02dc5009cede7e7400377e7c6640030c78a36392ff37adab60a2bfbe8f88e749107bed3f57b153ab8f50754fc22ba822ff948adcbfcf
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(span.text) }
300
+ ::Curses.attron(attr) { ::Curses.addstr(text) }
276
301
  else
277
- ::Curses.addstr(span.text)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Marvi
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: marvi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mitsutaka Mimura