marvi 0.6.0 → 0.8.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: 6f6a2c8745ddc2086bb12cb705f8564f1a23b770bb8b1026bdf89e2ccfea8bea
4
+ data.tar.gz: 879c6bbf77fac95dc588c8abce142144e2b27a5216d9831fd7c8d231956d248d
5
5
  SHA512:
6
- metadata.gz: 54848bb6e78b5833c96eb6aaea4de9869c5dcd18c381c9b05dc69e1d2d9528f93be9846068bf89acc56f569c54b37e96bbd7868c070802b0ae644f98fa8914ed
7
- data.tar.gz: d645f16e52d118bf57ef02dc5009cede7e7400377e7c6640030c78a36392ff37adab60a2bfbe8f88e749107bed3f57b153ab8f50754fc22ba822ff948adcbfcf
6
+ metadata.gz: d4edaa52fd6b82ad2443c821dab35e40ceb87cd28def6fa6401f413fb654f175d9fdf64b2ac070004c6dfdac99a1c757b0f4b6648388a4cbf952ab744d3815b2
7
+ data.tar.gz: b53ac675a9618f8e2be301758c3cb768315942665dc22dd81273bd35d8b490206d575b07689308c522f796f24987a6bd970a85c51cf9463dd8ee8d4e372077f8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-07-06
4
+
5
+ - Turn the `o` prompt into a file picker. It lists Markdown/text documents under the current directory and narrows the list as you type (all space-separated terms must match, case-insensitive); select with `C-n`/`C-p` or the arrow keys and open with `Enter`. Input starting with `/`, `~`, `./`, or `../` switches to explicit path entry with shell-style `Tab` completion, and selecting a directory descends into it.
6
+ - Open multiple files in tabs. Pass several files on the command line (`marvi a.md b.md`), and a tab bar with the file names appears at the top of the screen, wrapping to multiple rows when the terminal is narrow. Switch tabs with `Tab`/`Shift-Tab`, jump directly with `1`–`9`, or click a tab with the mouse. Press `o` to open another file in a new tab and `x` to close the current one. File-change polling covers every tab and marks updated ones with `●` in the bar.
7
+
8
+ ## [0.7.0] - 2026-06-15
9
+
10
+ - 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.
11
+
3
12
  ## [0.6.0] - 2026-06-11
4
13
 
5
14
  - 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.
data/README.md CHANGED
@@ -24,6 +24,24 @@ Render a Markdown file:
24
24
  marvi README.md
25
25
  ```
26
26
 
27
+ Open multiple files at once — each file gets its own tab in a bar at the top of the screen (the bar wraps to multiple rows when tabs don't fit the terminal width):
28
+
29
+ ```bash
30
+ marvi README.md CHANGELOG.md docs/guide.md
31
+ ```
32
+
33
+ Tab keys inside the pager:
34
+
35
+ | Key | Action |
36
+ | --- | --- |
37
+ | `Tab` / `Shift-Tab` | next / previous tab |
38
+ | `1`–`9` | jump to tab by number |
39
+ | mouse click on a tab | switch to that tab |
40
+ | `o` | open a file in a new tab via the file picker |
41
+ | `x` | close the current tab (closing the last one quits) |
42
+
43
+ The `o` picker lists Markdown/text documents under the current directory and narrows the list as you type (every space-separated term must match, case-insensitive). Move the selection with `C-n`/`C-p` (or the arrow keys) and press `Enter` to open. Start the input with `/`, `~`, `./`, or `../` to enter an explicit path instead — `Tab` then completes it shell-style, and selecting a directory descends into it.
44
+
27
45
  Read from stdin:
28
46
 
29
47
  ```bash
data/exe/marvi CHANGED
@@ -5,7 +5,7 @@ require "optparse"
5
5
  require_relative "../lib/marvi"
6
6
 
7
7
  OptionParser.new do |opts|
8
- opts.banner = "Usage: marvi [FILE]\n cat FILE | marvi"
8
+ opts.banner = "Usage: marvi [FILE...]\n cat FILE | marvi"
9
9
  opts.on("-h", "--help", "Show help") do
10
10
  puts opts
11
11
  exit
@@ -16,14 +16,27 @@ OptionParser.new do |opts|
16
16
  end
17
17
  end.parse!
18
18
 
19
- markdown = if ARGV.empty?
20
- $stdin.read
21
- else
22
- File.read(ARGV[0])
19
+ ARGV.each do |file|
20
+ abort "marvi: cannot open #{file}" unless File.file?(file) && File.readable?(file)
23
21
  end
24
22
 
25
23
  if $stdout.tty?
26
- Marvi::Renderer::Curses.new.render(markdown, file: ARGV[0])
24
+ if ARGV.empty?
25
+ Marvi::Renderer::Curses.new.render($stdin.read)
26
+ else
27
+ Marvi::Renderer::Curses.new.render_files(ARGV)
28
+ end
27
29
  else
28
- print Marvi::Renderer::ANSI.new.render(markdown)
30
+ renderer = Marvi::Renderer::ANSI.new
31
+ if ARGV.empty?
32
+ print renderer.render($stdin.read)
33
+ else
34
+ ARGV.each_with_index do |file, i|
35
+ if ARGV.size > 1
36
+ puts if i.positive?
37
+ puts "==> #{file} <=="
38
+ end
39
+ print renderer.render(File.read(file, encoding: Encoding::UTF_8))
40
+ end
41
+ end
29
42
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marvi
4
+ module Renderer
5
+ class Curses
6
+ # Candidate discovery, filtering, and tab-completion behind the `o`
7
+ # (open file) picker. Pure filesystem/string logic with no curses calls,
8
+ # so it can be tested headlessly.
9
+ #
10
+ # Two modes, decided per keystroke from the typed input:
11
+ # - list mode: filter documents scanned under the base directory; every
12
+ # whitespace-separated term must match somewhere in the path.
13
+ # - path mode (input starts with /, ~, ./ or ../): complete the typed
14
+ # path against the filesystem, shell-style.
15
+ class FilePicker
16
+ SCAN_GLOB = "**/*.{md,markdown,mdown,mkd,txt}"
17
+ MAX_FILES = 2000
18
+
19
+ def initialize(base_dir = Dir.pwd)
20
+ @base_dir = base_dir
21
+ end
22
+
23
+ # Documents under the base directory, capped to keep the picker snappy
24
+ # in huge trees. Dir.glob skips hidden directories by default.
25
+ def all_files
26
+ @all_files ||= Dir.glob(SCAN_GLOB, base: @base_dir)
27
+ .reject { |path| File.directory?(File.join(@base_dir, path)) }
28
+ .sort
29
+ .first(MAX_FILES)
30
+ end
31
+
32
+ def path_mode?(input)
33
+ input.start_with?("/", "~", "./", "../")
34
+ end
35
+
36
+ def candidates(input)
37
+ path_mode?(input) ? path_candidates(input) : filter(input)
38
+ end
39
+
40
+ def filter(input)
41
+ terms = input.downcase.split
42
+ return all_files if terms.empty?
43
+
44
+ all_files.select do |path|
45
+ haystack = path.downcase
46
+ terms.all? { |term| haystack.include?(term) }
47
+ end
48
+ end
49
+
50
+ # Longest unambiguous extension of the typed path. A lone directory
51
+ # match gains a trailing slash so the next Tab descends into it.
52
+ def complete(input)
53
+ cands = path_candidates(input)
54
+ return input if cands.empty?
55
+
56
+ prefix = common_prefix(cands)
57
+ (prefix.length > input.length) ? prefix : input
58
+ end
59
+
60
+ private
61
+
62
+ # Filesystem entries matching the typed fragment. Directories get a
63
+ # trailing slash; a leading ~ in the input is preserved in the results.
64
+ def path_candidates(input)
65
+ home = Dir.home
66
+ home_style = input.start_with?("~/") || input == "~"
67
+ raw = home_style ? input.sub(/\A~/, home) : input
68
+ Dir.glob("#{glob_escape(raw)}*").sort.map do |path|
69
+ path = "#{path}/" if File.directory?(path)
70
+ home_style ? path.sub(home, "~") : path
71
+ end
72
+ end
73
+
74
+ def glob_escape(path)
75
+ path.gsub(/[\[\]{}*?\\]/) { |ch| "\\#{ch}" }
76
+ end
77
+
78
+ def common_prefix(strings)
79
+ strings.reduce do |prefix, s|
80
+ i = 0
81
+ i += 1 while i < prefix.length && i < s.length && prefix[i] == s[i]
82
+ prefix[0, i]
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Marvi
4
+ module Renderer
5
+ class Curses
6
+ # Per-document state for one open file: source markdown, walked lines,
7
+ # scroll position, search state, and file-watch bookkeeping. All layout
8
+ # inputs (content width, page size) are passed in by the renderer so this
9
+ # class stays free of curses calls and can be tested headlessly.
10
+ class Tab
11
+ attr_reader :file, :lines, :markdown, :search_query, :matches, :current_match
12
+ attr_accessor :scroll
13
+
14
+ def initialize(file: nil, markdown: nil)
15
+ @file = file
16
+ @markdown = markdown || read_file
17
+ @scroll = 0
18
+ @lines = []
19
+ @walked_width = nil
20
+ clear_search
21
+ mark_reloaded
22
+ end
23
+
24
+ def label
25
+ @file ? File.basename(@file) : "(stdin)"
26
+ end
27
+
28
+ # Walk lazily: tabs that are not visible keep their lines from the last
29
+ # width they were displayed at and re-walk only when shown again.
30
+ def ensure_walked(width, page_size)
31
+ return if @walked_width == width
32
+
33
+ @walked_width = width
34
+ @lines = ASTWalker.new.walk(@markdown, max_width: width)
35
+ clamp_scroll(page_size)
36
+ refresh_search_after_rewalk(page_size)
37
+ end
38
+
39
+ def rewalk(width, page_size)
40
+ @walked_width = nil
41
+ ensure_walked(width, page_size)
42
+ end
43
+
44
+ def reload(width, page_size)
45
+ @markdown = read_file
46
+ rewalk(width, page_size)
47
+ mark_reloaded
48
+ end
49
+
50
+ def max_scroll(page_size)
51
+ [@lines.size - page_size, 0].max
52
+ end
53
+
54
+ def clamp_scroll(page_size)
55
+ @scroll = @scroll.clamp(0, max_scroll(page_size))
56
+ end
57
+
58
+ def scroll_by(delta, page_size)
59
+ @scroll = (@scroll + delta).clamp(0, max_scroll(page_size))
60
+ end
61
+
62
+ def visible_lines(page_size)
63
+ @lines[@scroll, page_size] || []
64
+ end
65
+
66
+ def current_source_line(page_size)
67
+ visible_lines(page_size).each { |line| return line.source_line if line.source_line }
68
+ # fall back to searching upward from scroll position
69
+ @scroll.downto(0) { |i| return @lines[i].source_line if @lines[i]&.source_line }
70
+ 1
71
+ end
72
+
73
+ # --- file watching ---
74
+
75
+ # Returns true when the file changed on disk since the last check and
76
+ # the updated flag was newly raised.
77
+ def check_file_updated
78
+ return false unless @file
79
+
80
+ mtime = current_mtime
81
+ return false if mtime.nil? || mtime == @last_mtime
82
+
83
+ @last_mtime = mtime
84
+ return false if @file_updated
85
+
86
+ @file_updated = true
87
+ true
88
+ end
89
+
90
+ def file_updated?
91
+ @file_updated
92
+ end
93
+
94
+ def mark_reloaded
95
+ @last_mtime = current_mtime
96
+ @file_updated = false
97
+ end
98
+
99
+ # --- search ---
100
+
101
+ def update_search(query, page_size)
102
+ @search_query = query
103
+ recompute_matches
104
+ focus_match_from(@scroll, page_size) unless @matches.empty?
105
+ end
106
+
107
+ def commit_search
108
+ clear_search if @search_query.to_s.empty? || @matches.empty?
109
+ end
110
+
111
+ def clear_search
112
+ @search_query = nil
113
+ @matches = []
114
+ @current_match = nil
115
+ end
116
+
117
+ def jump_match(direction, page_size)
118
+ return if @matches.empty?
119
+
120
+ if @current_match.nil?
121
+ focus_match_from(@scroll, page_size)
122
+ else
123
+ set_current_match(@current_match + direction, page_size)
124
+ end
125
+ end
126
+
127
+ def highlight_ranges_for(line_index)
128
+ return nil if @matches.empty?
129
+
130
+ ranges = []
131
+ @matches.each_with_index do |(li, start, finish), i|
132
+ ranges << [start, finish, i == @current_match] if li == line_index
133
+ end
134
+ ranges.empty? ? nil : ranges
135
+ end
136
+
137
+ def search_hint
138
+ return " n/N next/prev" if @search_query.nil? || @search_query.empty?
139
+ return " no matches: #{@search_query}" if @matches.empty?
140
+
141
+ position = @current_match ? @current_match + 1 : 0
142
+ " [#{position}/#{@matches.size}] n/N"
143
+ end
144
+
145
+ private
146
+
147
+ def read_file
148
+ # Force UTF-8 so markdown parses even under a C/POSIX locale where
149
+ # Encoding.default_external would be US-ASCII.
150
+ @file ? File.read(@file, encoding: Encoding::UTF_8) : ""
151
+ end
152
+
153
+ def current_mtime
154
+ return nil unless @file
155
+
156
+ File.mtime(@file)
157
+ rescue SystemCallError
158
+ nil
159
+ end
160
+
161
+ def refresh_search_after_rewalk(page_size)
162
+ return unless @search_query
163
+
164
+ recompute_matches
165
+ focus_match_from(@scroll, page_size) unless @matches.empty?
166
+ end
167
+
168
+ def recompute_matches
169
+ @matches = []
170
+ @current_match = nil
171
+ needle = @search_query.to_s.downcase
172
+ return if needle.empty?
173
+
174
+ @lines.each_with_index do |line, line_index|
175
+ haystack = line.plain_text.downcase
176
+ next if haystack.empty?
177
+
178
+ pos = 0
179
+ while (idx = haystack.index(needle, pos))
180
+ @matches << [line_index, idx, idx + needle.length]
181
+ pos = idx + needle.length
182
+ end
183
+ end
184
+ end
185
+
186
+ def focus_match_from(from_line, page_size)
187
+ return if @matches.empty?
188
+
189
+ index = @matches.index { |match| match[0] >= from_line } || 0
190
+ set_current_match(index, page_size)
191
+ end
192
+
193
+ def set_current_match(index, page_size)
194
+ return if @matches.empty?
195
+
196
+ @current_match = index % @matches.size
197
+ ensure_match_visible(@matches[@current_match][0], page_size)
198
+ end
199
+
200
+ def ensure_match_visible(line_index, page_size)
201
+ if line_index < @scroll || line_index >= @scroll + page_size
202
+ @scroll = [line_index, max_scroll(page_size)].min
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Marvi
6
+ module Renderer
7
+ class Curses
8
+ # Pure layout computation for the tab bar so it can be tested without a
9
+ # terminal. Tabs are packed greedily left to right and wrap to a new row
10
+ # when the screen width is exceeded, producing a multi-row bar.
11
+ module TabBar
12
+ Item = Struct.new(:index, :text, :row, :col, :width, keyword_init: true) do
13
+ def contains?(y, x)
14
+ y == row && x >= col && x < col + width
15
+ end
16
+ end
17
+
18
+ module_function
19
+
20
+ def layout(labels, screen_width)
21
+ screen_width = [screen_width, 1].max
22
+ row = 0
23
+ col = 0
24
+ labels.each_with_index.map do |label, index|
25
+ text = truncate_to_width(" #{label} ", screen_width)
26
+ width = Unicode::DisplayWidth.of(text)
27
+ if col.positive? && col + width > screen_width
28
+ row += 1
29
+ col = 0
30
+ end
31
+ item = Item.new(index: index, text: text, row: row, col: col, width: width)
32
+ col += width
33
+ item
34
+ end
35
+ end
36
+
37
+ def rows(items)
38
+ items.empty? ? 0 : items.last.row + 1
39
+ end
40
+
41
+ def item_at(items, y, x)
42
+ items.find { |item| item.contains?(y, x) }
43
+ end
44
+
45
+ def truncate_to_width(text, max_width)
46
+ return text if Unicode::DisplayWidth.of(text) <= max_width
47
+
48
+ result = +""
49
+ width = 0
50
+ text.each_char do |ch|
51
+ ch_width = Unicode::DisplayWidth.of(ch)
52
+ break if width + ch_width > max_width
53
+
54
+ result << ch
55
+ width += ch_width
56
+ end
57
+ result
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -24,26 +24,39 @@ module Marvi
24
24
  FILE_POLL_INTERVAL_MS = 500
25
25
 
26
26
  CTRL_D = 4
27
+ CTRL_N = 14
28
+ CTRL_P = 16
27
29
  CTRL_U = 21
30
+ TAB_KEY = 9
28
31
 
29
32
  MIN_HORIZONTAL_PADDING = 2
30
33
  HORIZONTAL_PADDING_DIVISOR = 12
31
34
 
32
35
  def render(markdown, file: nil)
33
- @file = file
34
- @markdown = markdown
35
- @scroll = 0
36
- mark_reloaded
36
+ run([Tab.new(file: file, markdown: markdown)])
37
+ end
38
+
39
+ def render_files(files)
40
+ run(files.map { |file| Tab.new(file: file) })
41
+ end
42
+
43
+ private
44
+
45
+ def run(tabs)
46
+ @tabs = tabs
47
+ @current = 0
48
+ @search_input = nil
49
+ @status_message = nil
37
50
 
38
51
  init_curses_state
39
- rewalk
52
+ current_tab.ensure_walked(content_width, page_size)
40
53
  draw
41
54
 
42
55
  catch(:quit) do
43
56
  loop do
44
57
  key = ::Curses.getch
45
58
  if key.nil? || key == -1
46
- check_file_updated
59
+ poll_files
47
60
  else
48
61
  handle_key(key)
49
62
  end
@@ -53,7 +66,9 @@ module Marvi
53
66
  ::Curses.close_screen
54
67
  end
55
68
 
56
- private
69
+ def current_tab
70
+ @tabs[@current]
71
+ end
57
72
 
58
73
  # ncurses uses the terminfo `rep` capability ("ESC[Nb") to compress runs of
59
74
  # identical glyphs, but ghostty mishandles it and drops the run from the
@@ -111,6 +126,7 @@ module Marvi
111
126
  end
112
127
 
113
128
  def handle_key(key)
129
+ @status_message = nil
114
130
  case key
115
131
  when "q", "Q", 27 then throw :quit
116
132
  when "j", ::Curses::Key::DOWN then scroll_by(1)
@@ -119,24 +135,119 @@ module Marvi
119
135
  when "u", CTRL_U then scroll_by(-page_size / 2)
120
136
  when "f", " ", ::Curses::Key::NPAGE then scroll_by(page_size)
121
137
  when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
122
- when "g" then @scroll = 0
138
+ when "g" then current_tab.scroll = 0
123
139
  draw
124
- when "G" then @scroll = max_scroll
140
+ when "G" then current_tab.scroll = current_tab.max_scroll(page_size)
125
141
  draw
126
- when "e" then launch_editor if @file
127
- when "r", "R" then reload_from_key if @file
142
+ when "/" then start_search
143
+ when "n" then jump_match(1)
144
+ when "N" then jump_match(-1)
145
+ when "e" then launch_editor if current_tab.file
146
+ when "r", "R" then reload_from_key if current_tab.file
147
+ when "\t", TAB_KEY then cycle_tab(1)
148
+ when ::Curses::Key::BTAB then cycle_tab(-1)
149
+ when "1".."9" then switch_to(key.to_i - 1)
150
+ when "o" then start_open
151
+ when "x" then close_current_tab
152
+ when ::Curses::Key::MOUSE then handle_mouse
128
153
  when ::Curses::Key::RESIZE then handle_resize
129
154
  end
130
155
  end
131
156
 
132
- def handle_resize
133
- rewalk
134
- @scroll = [@scroll, max_scroll].min
157
+ # --- tabs ---
158
+
159
+ def cycle_tab(delta)
160
+ return if @tabs.size <= 1
161
+
162
+ switch_to((@current + delta) % @tabs.size)
163
+ end
164
+
165
+ def switch_to(index)
166
+ return if index.negative? || index >= @tabs.size
167
+
168
+ @current = index
169
+ current_tab.ensure_walked(content_width, page_size)
135
170
  draw
136
171
  end
137
172
 
138
- def rewalk
139
- @lines = ASTWalker.new.walk(@markdown, max_width: content_width)
173
+ def close_current_tab
174
+ throw :quit if @tabs.size == 1
175
+
176
+ @tabs.delete_at(@current)
177
+ @current = [@current, @tabs.size - 1].min
178
+ update_mousemask
179
+ # The bar may have lost a row (or disappeared), changing the page size.
180
+ current_tab.ensure_walked(content_width, page_size)
181
+ draw
182
+ end
183
+
184
+ def open_tab(path)
185
+ path = File.expand_path(path)
186
+ existing = @tabs.index { |tab| tab.file == path }
187
+ return switch_to(existing) if existing
188
+
189
+ unless File.file?(path) && File.readable?(path)
190
+ @status_message = " cannot open: #{path} "
191
+ draw
192
+ return
193
+ end
194
+
195
+ @tabs << Tab.new(file: path)
196
+ update_mousemask
197
+ switch_to(@tabs.size - 1)
198
+ end
199
+
200
+ def update_mousemask
201
+ return unless ::Curses.respond_to?(:mousemask)
202
+
203
+ # Claim the mouse only when the tab bar is shown, so single-document
204
+ # sessions keep native terminal text selection.
205
+ mask = (@tabs.size > 1) ? (::Curses::BUTTON1_CLICKED | ::Curses::BUTTON1_PRESSED) : 0
206
+ ::Curses.mousemask(mask)
207
+ end
208
+
209
+ def handle_mouse
210
+ event = ::Curses.getmouse
211
+ return unless event
212
+ return if (event.bstate & (::Curses::BUTTON1_CLICKED | ::Curses::BUTTON1_PRESSED)).zero?
213
+
214
+ item = TabBar.item_at(tab_layout, event.y, event.x)
215
+ switch_to(item.index) if item
216
+ end
217
+
218
+ def tab_layout
219
+ return [] if @tabs.size <= 1
220
+
221
+ labels = @tabs.each_with_index.map { |tab, i| tab_label(tab, i) }
222
+ TabBar.layout(labels, ::Curses.cols)
223
+ end
224
+
225
+ def tab_label(tab, index)
226
+ updated_mark = tab.file_updated? ? " ●" : ""
227
+ "#{index + 1}:#{tab.label}#{updated_mark}"
228
+ end
229
+
230
+ def tab_bar_height
231
+ TabBar.rows(tab_layout)
232
+ end
233
+
234
+ def draw_tab_bar(layout)
235
+ layout.each do |item|
236
+ ::Curses.setpos(item.row, item.col)
237
+ attr = if item.index == @current
238
+ ::Curses::A_REVERSE | ::Curses::A_BOLD
239
+ else
240
+ ::Curses.color_pair(COLOR_PAIRS[:cyan])
241
+ end
242
+ ::Curses.attron(attr) { ::Curses.addstr(item.text) }
243
+ rescue ::Curses::Error
244
+ # ignore write errors at screen edge
245
+ end
246
+ end
247
+
248
+ def handle_resize
249
+ current_tab.rewalk(content_width, page_size)
250
+ draw
140
251
  end
141
252
 
142
253
  def horizontal_padding
@@ -150,55 +261,28 @@ module Marvi
150
261
  end
151
262
 
152
263
  def reload_from_key
153
- reload
154
- mark_reloaded
264
+ current_tab.reload(content_width, page_size)
155
265
  draw
156
266
  end
157
267
 
158
- def check_file_updated
159
- return unless @file
160
- mtime = current_mtime
161
- return if mtime.nil? || mtime == @last_mtime
162
-
163
- @last_mtime = mtime
164
- return if @file_updated
165
-
166
- @file_updated = true
167
- draw_status_bar
168
- ::Curses.refresh
169
- end
170
-
171
- def mark_reloaded
172
- @last_mtime = current_mtime
173
- @file_updated = false
174
- end
175
-
176
- def current_mtime
177
- return nil unless @file
178
- File.mtime(@file)
179
- rescue SystemCallError
180
- nil
268
+ def poll_files
269
+ updated = @tabs.select(&:check_file_updated)
270
+ draw unless updated.empty?
181
271
  end
182
272
 
183
273
  def launch_editor
274
+ tab = current_tab
184
275
  editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
185
- line = current_source_line
186
- cmd = build_editor_command(editor, @file, line)
276
+ line = tab.current_source_line(page_size)
277
+ cmd = build_editor_command(editor, tab.file, line)
187
278
 
188
279
  ::Curses.close_screen
189
280
  system(cmd)
190
281
  init_curses_state
191
- reload
192
- mark_reloaded
282
+ tab.reload(content_width, page_size)
193
283
  draw
194
284
  end
195
285
 
196
- def reload
197
- @markdown = File.read(@file)
198
- rewalk
199
- @scroll = [@scroll, max_scroll].min
200
- end
201
-
202
286
  def init_curses_state
203
287
  with_safe_term { ::Curses.init_screen }
204
288
  ::Curses.start_color
@@ -208,6 +292,7 @@ module Marvi
208
292
  ::Curses.stdscr.keypad(true)
209
293
  ::Curses.stdscr.timeout = FILE_POLL_INTERVAL_MS
210
294
  setup_colors
295
+ update_mousemask
211
296
  end
212
297
 
213
298
  def build_editor_command(editor, file, line)
@@ -224,58 +309,100 @@ module Marvi
224
309
  end
225
310
  end
226
311
 
227
- def current_source_line
228
- visible_lines.each { |line| return line.source_line if line.source_line }
229
- # fall back to searching upward from scroll position
230
- @scroll.downto(0) { |i| return @lines[i].source_line if @lines[i]&.source_line }
231
- 1
232
- end
233
-
234
312
  def draw
235
313
  ::Curses.clear
314
+ layout = tab_layout
315
+ draw_tab_bar(layout)
316
+ offset = TabBar.rows(layout)
317
+ tab = current_tab
318
+ tab.clamp_scroll(page_size)
236
319
  padding = horizontal_padding
237
- visible_lines.each_with_index do |line, row|
238
- ::Curses.setpos(row, padding)
239
- render_line(line)
320
+ tab.visible_lines(page_size).each_with_index do |line, row|
321
+ ::Curses.setpos(row + offset, padding)
322
+ render_line(line, tab.highlight_ranges_for(tab.scroll + row))
240
323
  end
241
324
  draw_status_bar
242
325
  ::Curses.refresh
243
326
  end
244
327
 
245
328
  def draw_status_bar
329
+ tab = current_tab
246
330
  ::Curses.setpos(::Curses.lines - 1, 0)
247
- top = @scroll + 1
248
- bottom = [@scroll + page_size, @lines.size].min
249
- edit_hint = @file ? " e edit" : ""
250
- status = " #{top}-#{bottom}/#{@lines.size} j/k scroll g/G top/bottom#{edit_hint} q quit"
251
- updated_hint = @file_updated ? " updated (r to reload) " : ""
252
- available = [::Curses.cols - updated_hint.length, 0].max
331
+ top = tab.scroll + 1
332
+ bottom = [tab.scroll + page_size, tab.lines.size].min
333
+ tab_hint = (@tabs.size > 1) ? " [#{@current + 1}/#{@tabs.size}] Tab next" : ""
334
+ edit_hint = tab.file ? " e edit" : ""
335
+ status = "#{tab_hint} #{top}-#{bottom}/#{tab.lines.size} j/k scroll g/G top/bottom / search#{tab.search_hint}#{edit_hint} o open q quit"
336
+ right_hint = @status_message || (tab.file_updated? ? " ● updated (r to reload) " : "")
337
+ available = [::Curses.cols - right_hint.length, 0].max
253
338
 
254
339
  ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
255
340
  ::Curses.addstr(status.ljust(available)[0, available])
256
341
  end
257
- unless updated_hint.empty?
342
+ unless right_hint.empty?
258
343
  ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:yellow]) | ::Curses::A_BOLD) do
259
- ::Curses.addstr(updated_hint)
344
+ ::Curses.addstr(right_hint)
260
345
  end
261
346
  end
262
347
  end
263
348
 
264
- def render_line(line)
349
+ def render_line(line, highlights = nil)
350
+ col = 0
265
351
  line.spans.each do |span|
266
- render_span(span)
352
+ render_span(span, col, highlights)
353
+ col += span.text.length
267
354
  rescue ::Curses::Error
268
355
  # ignore write errors at line edge
269
356
  end
270
357
  end
271
358
 
272
- def render_span(span)
359
+ def render_span(span, col_offset = 0, highlights = nil)
273
360
  attr = build_attr(span)
361
+ if highlights.nil? || highlights.empty?
362
+ write_text(span.text, attr)
363
+ return
364
+ end
365
+
366
+ highlight_segments(span.text, col_offset, highlights).each do |text, state|
367
+ seg_attr = attr
368
+ seg_attr |= ::Curses::A_REVERSE if state
369
+ seg_attr |= ::Curses::A_BOLD if state == :current
370
+ write_text(text, seg_attr)
371
+ end
372
+ end
373
+
374
+ def write_text(text, attr)
274
375
  if attr != 0
275
- ::Curses.attron(attr) { ::Curses.addstr(span.text) }
376
+ ::Curses.attron(attr) { ::Curses.addstr(text) }
276
377
  else
277
- ::Curses.addstr(span.text)
378
+ ::Curses.addstr(text)
379
+ end
380
+ end
381
+
382
+ # Split a span's text into runs of identical highlight state so each run can
383
+ # be drawn with the matching attributes. Highlight ranges are expressed in
384
+ # whole-line character offsets, hence col_offset locates this span on the line.
385
+ def highlight_segments(text, col_offset, highlights)
386
+ chars = text.chars
387
+ states = Array.new(chars.length)
388
+ highlights.each do |start, finish, current|
389
+ start.upto(finish - 1) do |c|
390
+ idx = c - col_offset
391
+ next if idx.negative? || idx >= chars.length
392
+ states[idx] = :current if current
393
+ states[idx] ||= true
394
+ end
395
+ end
396
+
397
+ segments = []
398
+ chars.each_with_index do |ch, i|
399
+ if segments.empty? || segments.last[1] != states[i]
400
+ segments << [ch.dup, states[i]]
401
+ else
402
+ segments.last[0] << ch
403
+ end
278
404
  end
405
+ segments
279
406
  end
280
407
 
281
408
  def build_attr(span)
@@ -293,22 +420,161 @@ module Marvi
293
420
  attr
294
421
  end
295
422
 
296
- def visible_lines
297
- @lines[@scroll, page_size] || []
423
+ def page_size
424
+ [::Curses.lines - 1 - tab_bar_height, 1].max
298
425
  end
299
426
 
300
- def page_size
301
- [::Curses.lines - 1, 1].max
427
+ def scroll_by(delta)
428
+ current_tab.scroll_by(delta, page_size)
429
+ draw
302
430
  end
303
431
 
304
- def max_scroll
305
- [@lines.size - page_size, 0].max
432
+ # --- line-editing prompts (search and open) ---
433
+
434
+ BACKSPACE_KEYS = [127, 8].freeze
435
+
436
+ def start_search
437
+ @search_input = ""
438
+ update_search(@search_input)
439
+ draw_prompt("/#{@search_input}")
440
+
441
+ loop do
442
+ key = ::Curses.getch
443
+ next if key.nil? || key == -1
444
+
445
+ case key
446
+ when 27 # ESC cancels and clears the search
447
+ current_tab.clear_search
448
+ @search_input = nil
449
+ draw
450
+ return
451
+ when "\n", "\r", 10, 13, ::Curses::Key::ENTER
452
+ current_tab.commit_search
453
+ @search_input = nil
454
+ draw
455
+ return
456
+ when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
457
+ @search_input = @search_input[0...-1] || ""
458
+ update_search(@search_input)
459
+ draw_prompt("/#{@search_input}")
460
+ else
461
+ next unless key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
462
+ @search_input += key
463
+ update_search(@search_input)
464
+ draw_prompt("/#{@search_input}")
465
+ end
466
+ end
306
467
  end
307
468
 
308
- def scroll_by(delta)
309
- @scroll = (@scroll + delta).clamp(0, max_scroll)
469
+ def update_search(query)
470
+ current_tab.update_search(query, page_size)
310
471
  draw
311
472
  end
473
+
474
+ def jump_match(direction)
475
+ current_tab.jump_match(direction, page_size)
476
+ draw
477
+ end
478
+
479
+ def start_open
480
+ picker = FilePicker.new
481
+ input = ""
482
+ selected = 0
483
+
484
+ loop do
485
+ candidates = picker.candidates(input)
486
+ selected = candidates.empty? ? 0 : selected % candidates.size
487
+ draw_picker(input, candidates, selected)
488
+
489
+ key = ::Curses.getch
490
+ next if key.nil? || key == -1
491
+
492
+ case key
493
+ when 27 # ESC cancels
494
+ draw
495
+ return
496
+ when "\n", "\r", 10, 13, ::Curses::Key::ENTER
497
+ choice = candidates[selected] || input.strip
498
+ if choice.empty?
499
+ draw
500
+ return
501
+ elsif choice.end_with?("/")
502
+ # descend into the selected directory and keep picking
503
+ input = choice
504
+ selected = 0
505
+ else
506
+ open_tab(choice)
507
+ return
508
+ end
509
+ when "\t", TAB_KEY
510
+ if picker.path_mode?(input)
511
+ input = picker.complete(input)
512
+ else
513
+ selected += 1
514
+ end
515
+ when ::Curses::Key::BTAB
516
+ selected -= 1
517
+ when CTRL_N, ::Curses::Key::DOWN
518
+ selected += 1
519
+ when CTRL_P, ::Curses::Key::UP
520
+ selected -= 1
521
+ when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
522
+ input = input[0...-1] || ""
523
+ selected = 0
524
+ else
525
+ if key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
526
+ input += key
527
+ selected = 0
528
+ end
529
+ end
530
+ end
531
+ end
532
+
533
+ def draw_picker(input, candidates, selected)
534
+ ::Curses.clear
535
+ width = ::Curses.cols
536
+ position = candidates.empty? ? 0 : selected + 1
537
+ header = " open file (#{position}/#{candidates.size}) C-n/C-p select Enter open ESC cancel"
538
+ ::Curses.setpos(0, 0)
539
+ ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan]) | ::Curses::A_BOLD) do
540
+ ::Curses.addstr(header.ljust(width)[0, width])
541
+ end
542
+
543
+ list_rows = [::Curses.lines - 2, 1].max
544
+ offset = (selected < list_rows) ? 0 : selected - list_rows + 1
545
+ (candidates[offset, list_rows] || []).each_with_index do |candidate, i|
546
+ ::Curses.setpos(1 + i, 0)
547
+ text = " #{candidate} ".ljust(width)[0, width]
548
+ if offset + i == selected
549
+ ::Curses.attron(::Curses::A_REVERSE) { ::Curses.addstr(text) }
550
+ else
551
+ ::Curses.addstr(text)
552
+ end
553
+ rescue ::Curses::Error
554
+ # ignore write errors at screen edge
555
+ end
556
+
557
+ prompt = "open: #{input}"
558
+ ::Curses.setpos(::Curses.lines - 1, 0)
559
+ ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
560
+ ::Curses.addstr(prompt.ljust(width)[0, width])
561
+ end
562
+ ::Curses.setpos(::Curses.lines - 1, [prompt.length, width - 1].min)
563
+ ::Curses.refresh
564
+ end
565
+
566
+ def draw_prompt(prompt)
567
+ ::Curses.setpos(::Curses.lines - 1, 0)
568
+ ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
569
+ ::Curses.addstr(prompt.ljust(::Curses.cols)[0, ::Curses.cols])
570
+ end
571
+ ::Curses.setpos(::Curses.lines - 1, [prompt.length, ::Curses.cols - 1].min)
572
+ ::Curses.refresh
573
+ end
312
574
  end
313
575
  end
314
576
  end
577
+
578
+ require_relative "curses/tab"
579
+ require_relative "curses/tab_bar"
580
+ require_relative "curses/file_picker"
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.8.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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mitsutaka Mimura
@@ -110,6 +110,9 @@ files:
110
110
  - lib/marvi/highlighter.rb
111
111
  - lib/marvi/renderer/ansi.rb
112
112
  - lib/marvi/renderer/curses.rb
113
+ - lib/marvi/renderer/curses/file_picker.rb
114
+ - lib/marvi/renderer/curses/tab.rb
115
+ - lib/marvi/renderer/curses/tab_bar.rb
113
116
  - lib/marvi/version.rb
114
117
  - marvi-0.1.0.gem
115
118
  - sig/marvi.rbs