marvi 0.7.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: a3372fe5ca768321491d123b2591149b77594c9ab0b2bbc4008e16f6b542bcba
4
- data.tar.gz: 68e2bad815cf3b20c461a8285df8944d568640288e8bfc3aef40117d5095fccb
3
+ metadata.gz: 6f6a2c8745ddc2086bb12cb705f8564f1a23b770bb8b1026bdf89e2ccfea8bea
4
+ data.tar.gz: 879c6bbf77fac95dc588c8abce142144e2b27a5216d9831fd7c8d231956d248d
5
5
  SHA512:
6
- metadata.gz: 7ae44f8bdbc6ab8326b986b5663cbf81a79ef6682871af01157c7d390e75f77de907cae36e4a761d4f46e82aefbd42d070c991d98a0fb498300c6eff856ff1bf
7
- data.tar.gz: a2554b23a557bdec28fcca02afa213edf865f5354ce7673faa16db15d494781a9d9eb7e9d1bcb482b0752806ee943bf486b318c36bab73d7e573b19890a2c54a
6
+ metadata.gz: d4edaa52fd6b82ad2443c821dab35e40ceb87cd28def6fa6401f413fb654f175d9fdf64b2ac070004c6dfdac99a1c757b0f4b6648388a4cbf952ab744d3815b2
7
+ data.tar.gz: b53ac675a9618f8e2be301758c3cb768315942665dc22dd81273bd35d8b490206d575b07689308c522f796f24987a6bd970a85c51cf9463dd8ee8d4e372077f8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## [0.7.0] - 2026-06-15
4
9
 
5
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.
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,30 +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
- @search_query = nil
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
37
48
  @search_input = nil
38
- @matches = []
39
- @current_match = nil
40
- mark_reloaded
49
+ @status_message = nil
41
50
 
42
51
  init_curses_state
43
- rewalk
52
+ current_tab.ensure_walked(content_width, page_size)
44
53
  draw
45
54
 
46
55
  catch(:quit) do
47
56
  loop do
48
57
  key = ::Curses.getch
49
58
  if key.nil? || key == -1
50
- check_file_updated
59
+ poll_files
51
60
  else
52
61
  handle_key(key)
53
62
  end
@@ -57,7 +66,9 @@ module Marvi
57
66
  ::Curses.close_screen
58
67
  end
59
68
 
60
- private
69
+ def current_tab
70
+ @tabs[@current]
71
+ end
61
72
 
62
73
  # ncurses uses the terminfo `rep` capability ("ESC[Nb") to compress runs of
63
74
  # identical glyphs, but ghostty mishandles it and drops the run from the
@@ -115,6 +126,7 @@ module Marvi
115
126
  end
116
127
 
117
128
  def handle_key(key)
129
+ @status_message = nil
118
130
  case key
119
131
  when "q", "Q", 27 then throw :quit
120
132
  when "j", ::Curses::Key::DOWN then scroll_by(1)
@@ -123,28 +135,119 @@ module Marvi
123
135
  when "u", CTRL_U then scroll_by(-page_size / 2)
124
136
  when "f", " ", ::Curses::Key::NPAGE then scroll_by(page_size)
125
137
  when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
126
- when "g" then @scroll = 0
138
+ when "g" then current_tab.scroll = 0
127
139
  draw
128
- when "G" then @scroll = max_scroll
140
+ when "G" then current_tab.scroll = current_tab.max_scroll(page_size)
129
141
  draw
130
142
  when "/" then start_search
131
143
  when "n" then jump_match(1)
132
144
  when "N" then jump_match(-1)
133
- when "e" then launch_editor if @file
134
- when "r", "R" then reload_from_key if @file
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
135
153
  when ::Curses::Key::RESIZE then handle_resize
136
154
  end
137
155
  end
138
156
 
139
- def handle_resize
140
- rewalk
141
- @scroll = [@scroll, max_scroll].min
142
- refresh_search_after_rewalk
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)
143
170
  draw
144
171
  end
145
172
 
146
- def rewalk
147
- @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
148
251
  end
149
252
 
150
253
  def horizontal_padding
@@ -158,56 +261,28 @@ module Marvi
158
261
  end
159
262
 
160
263
  def reload_from_key
161
- reload
162
- mark_reloaded
264
+ current_tab.reload(content_width, page_size)
163
265
  draw
164
266
  end
165
267
 
166
- def check_file_updated
167
- return unless @file
168
- mtime = current_mtime
169
- return if mtime.nil? || mtime == @last_mtime
170
-
171
- @last_mtime = mtime
172
- return if @file_updated
173
-
174
- @file_updated = true
175
- draw_status_bar
176
- ::Curses.refresh
177
- end
178
-
179
- def mark_reloaded
180
- @last_mtime = current_mtime
181
- @file_updated = false
182
- end
183
-
184
- def current_mtime
185
- return nil unless @file
186
- File.mtime(@file)
187
- rescue SystemCallError
188
- nil
268
+ def poll_files
269
+ updated = @tabs.select(&:check_file_updated)
270
+ draw unless updated.empty?
189
271
  end
190
272
 
191
273
  def launch_editor
274
+ tab = current_tab
192
275
  editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
193
- line = current_source_line
194
- cmd = build_editor_command(editor, @file, line)
276
+ line = tab.current_source_line(page_size)
277
+ cmd = build_editor_command(editor, tab.file, line)
195
278
 
196
279
  ::Curses.close_screen
197
280
  system(cmd)
198
281
  init_curses_state
199
- reload
200
- mark_reloaded
282
+ tab.reload(content_width, page_size)
201
283
  draw
202
284
  end
203
285
 
204
- def reload
205
- @markdown = File.read(@file)
206
- rewalk
207
- @scroll = [@scroll, max_scroll].min
208
- refresh_search_after_rewalk
209
- end
210
-
211
286
  def init_curses_state
212
287
  with_safe_term { ::Curses.init_screen }
213
288
  ::Curses.start_color
@@ -217,6 +292,7 @@ module Marvi
217
292
  ::Curses.stdscr.keypad(true)
218
293
  ::Curses.stdscr.timeout = FILE_POLL_INTERVAL_MS
219
294
  setup_colors
295
+ update_mousemask
220
296
  end
221
297
 
222
298
  def build_editor_command(editor, file, line)
@@ -233,39 +309,39 @@ module Marvi
233
309
  end
234
310
  end
235
311
 
236
- def current_source_line
237
- visible_lines.each { |line| return line.source_line if line.source_line }
238
- # fall back to searching upward from scroll position
239
- @scroll.downto(0) { |i| return @lines[i].source_line if @lines[i]&.source_line }
240
- 1
241
- end
242
-
243
312
  def draw
244
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)
245
319
  padding = horizontal_padding
246
- visible_lines.each_with_index do |line, row|
247
- ::Curses.setpos(row, padding)
248
- render_line(line, highlight_ranges_for(@scroll + row))
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))
249
323
  end
250
324
  draw_status_bar
251
325
  ::Curses.refresh
252
326
  end
253
327
 
254
328
  def draw_status_bar
329
+ tab = current_tab
255
330
  ::Curses.setpos(::Curses.lines - 1, 0)
256
- top = @scroll + 1
257
- bottom = [@scroll + page_size, @lines.size].min
258
- edit_hint = @file ? " e edit" : ""
259
- status = " #{top}-#{bottom}/#{@lines.size} j/k scroll g/G top/bottom / search#{search_hint}#{edit_hint} q quit"
260
- updated_hint = @file_updated ? " updated (r to reload) " : ""
261
- 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
262
338
 
263
339
  ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
264
340
  ::Curses.addstr(status.ljust(available)[0, available])
265
341
  end
266
- unless updated_hint.empty?
342
+ unless right_hint.empty?
267
343
  ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:yellow]) | ::Curses::A_BOLD) do
268
- ::Curses.addstr(updated_hint)
344
+ ::Curses.addstr(right_hint)
269
345
  end
270
346
  end
271
347
  end
@@ -344,31 +420,23 @@ module Marvi
344
420
  attr
345
421
  end
346
422
 
347
- def visible_lines
348
- @lines[@scroll, page_size] || []
349
- end
350
-
351
423
  def page_size
352
- [::Curses.lines - 1, 1].max
353
- end
354
-
355
- def max_scroll
356
- [@lines.size - page_size, 0].max
424
+ [::Curses.lines - 1 - tab_bar_height, 1].max
357
425
  end
358
426
 
359
427
  def scroll_by(delta)
360
- @scroll = (@scroll + delta).clamp(0, max_scroll)
428
+ current_tab.scroll_by(delta, page_size)
361
429
  draw
362
430
  end
363
431
 
364
- # --- Incremental search ---
432
+ # --- line-editing prompts (search and open) ---
365
433
 
366
434
  BACKSPACE_KEYS = [127, 8].freeze
367
435
 
368
436
  def start_search
369
437
  @search_input = ""
370
438
  update_search(@search_input)
371
- draw_search_prompt
439
+ draw_prompt("/#{@search_input}")
372
440
 
373
441
  loop do
374
442
  key = ::Curses.getch
@@ -376,118 +444,126 @@ module Marvi
376
444
 
377
445
  case key
378
446
  when 27 # ESC cancels and clears the search
379
- clear_search
447
+ current_tab.clear_search
448
+ @search_input = nil
380
449
  draw
381
450
  return
382
451
  when "\n", "\r", 10, 13, ::Curses::Key::ENTER
383
- commit_search
452
+ current_tab.commit_search
453
+ @search_input = nil
454
+ draw
384
455
  return
385
456
  when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
386
457
  @search_input = @search_input[0...-1] || ""
387
458
  update_search(@search_input)
388
- draw_search_prompt
459
+ draw_prompt("/#{@search_input}")
389
460
  else
390
461
  next unless key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
391
462
  @search_input += key
392
463
  update_search(@search_input)
393
- draw_search_prompt
464
+ draw_prompt("/#{@search_input}")
394
465
  end
395
466
  end
396
467
  end
397
468
 
398
469
  def update_search(query)
399
- @search_query = query
400
- recompute_matches
401
- focus_match_from(@scroll) unless @matches.empty?
470
+ current_tab.update_search(query, page_size)
402
471
  draw
403
472
  end
404
473
 
405
- def commit_search
406
- if @search_query.to_s.empty? || @matches.empty?
407
- clear_search
408
- end
409
- @search_input = nil
474
+ def jump_match(direction)
475
+ current_tab.jump_match(direction, page_size)
410
476
  draw
411
477
  end
412
478
 
413
- def clear_search
414
- @search_query = nil
415
- @search_input = nil
416
- @matches = []
417
- @current_match = nil
418
- end
479
+ def start_open
480
+ picker = FilePicker.new
481
+ input = ""
482
+ selected = 0
419
483
 
420
- def recompute_matches
421
- @matches = []
422
- @current_match = nil
423
- needle = @search_query.to_s.downcase
424
- return if needle.empty?
484
+ loop do
485
+ candidates = picker.candidates(input)
486
+ selected = candidates.empty? ? 0 : selected % candidates.size
487
+ draw_picker(input, candidates, selected)
425
488
 
426
- @lines.each_with_index do |line, line_index|
427
- haystack = line.plain_text.downcase
428
- next if haystack.empty?
489
+ key = ::Curses.getch
490
+ next if key.nil? || key == -1
429
491
 
430
- pos = 0
431
- while (idx = haystack.index(needle, pos))
432
- @matches << [line_index, idx, idx + needle.length]
433
- pos = idx + needle.length
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
434
529
  end
435
530
  end
436
531
  end
437
532
 
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
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])
453
541
  end
454
- end
455
542
 
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)
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
462
555
  end
463
- draw
464
- end
465
556
 
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
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])
477
561
  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"
562
+ ::Curses.setpos(::Curses.lines - 1, [prompt.length, width - 1].min)
563
+ ::Curses.refresh
487
564
  end
488
565
 
489
- def draw_search_prompt
490
- prompt = "/#{@search_input}"
566
+ def draw_prompt(prompt)
491
567
  ::Curses.setpos(::Curses.lines - 1, 0)
492
568
  ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
493
569
  ::Curses.addstr(prompt.ljust(::Curses.cols)[0, ::Curses.cols])
@@ -498,3 +574,7 @@ module Marvi
498
574
  end
499
575
  end
500
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.7.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.7.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