marvi 0.7.0 → 0.8.1

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: ce10cf349eaa081b2f4fc1f2f9c038fd62c7e3aaa5f01ed782e03e6a05a93e8c
4
+ data.tar.gz: 7e4c20a1f8f53dd60e21ed27ad3f95ae08f5be365e6eda5fba514fb947b25f0e
5
5
  SHA512:
6
- metadata.gz: 7ae44f8bdbc6ab8326b986b5663cbf81a79ef6682871af01157c7d390e75f77de907cae36e4a761d4f46e82aefbd42d070c991d98a0fb498300c6eff856ff1bf
7
- data.tar.gz: a2554b23a557bdec28fcca02afa213edf865f5354ce7673faa16db15d494781a9d9eb7e9d1bcb482b0752806ee943bf486b318c36bab73d7e573b19890a2c54a
6
+ metadata.gz: e6ebac4779d6b0d88b3473d26d9455322cd0680defe3c94e7784dc6506c23173d4dc8236be67f28ff8feae4b25f9526b3a53502decb8a16e107285414cf4b4f3
7
+ data.tar.gz: bf8467ba67c1f3ff3cd87c02cb577492c76ff36ff8b67efbc89e5e9cd219f5388175ced544043008ea1d4bf1b2f1e3c94cc20f9473ec8f9377e6798e4e63aae7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.1] - 2026-07-07
4
+
5
+ - Add a blank margin row between the tab bar and the document. Clicks on the margin row select the tab directly above it, giving each tab a taller effective click target.
6
+
7
+ ## [0.8.0] - 2026-07-06
8
+
9
+ - 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.
10
+ - 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.
11
+
3
12
  ## [0.7.0] - 2026-06-15
4
13
 
5
14
  - 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,68 @@
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
+ # Resolve a click including the margin row directly below the bar,
46
+ # which is attributed to the tab above it — a taller click target.
47
+ def item_for_click(items, y, x)
48
+ item_at(items, y, x) || ((y == rows(items)) ? item_at(items, y - 1, x) : nil)
49
+ end
50
+
51
+ def truncate_to_width(text, max_width)
52
+ return text if Unicode::DisplayWidth.of(text) <= max_width
53
+
54
+ result = +""
55
+ width = 0
56
+ text.each_char do |ch|
57
+ ch_width = Unicode::DisplayWidth.of(ch)
58
+ break if width + ch_width > max_width
59
+
60
+ result << ch
61
+ width += ch_width
62
+ end
63
+ result
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -24,30 +24,44 @@ 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
 
35
+ # Blank row between the tab bar and the document. Clicks landing on it
36
+ # are attributed to the tab directly above, giving each tab a taller
37
+ # effective click target than the single character cell.
38
+ TAB_BAR_MARGIN_ROWS = 1
39
+
32
40
  def render(markdown, file: nil)
33
- @file = file
34
- @markdown = markdown
35
- @scroll = 0
36
- @search_query = nil
41
+ run([Tab.new(file: file, markdown: markdown)])
42
+ end
43
+
44
+ def render_files(files)
45
+ run(files.map { |file| Tab.new(file: file) })
46
+ end
47
+
48
+ private
49
+
50
+ def run(tabs)
51
+ @tabs = tabs
52
+ @current = 0
37
53
  @search_input = nil
38
- @matches = []
39
- @current_match = nil
40
- mark_reloaded
54
+ @status_message = nil
41
55
 
42
56
  init_curses_state
43
- rewalk
57
+ current_tab.ensure_walked(content_width, page_size)
44
58
  draw
45
59
 
46
60
  catch(:quit) do
47
61
  loop do
48
62
  key = ::Curses.getch
49
63
  if key.nil? || key == -1
50
- check_file_updated
64
+ poll_files
51
65
  else
52
66
  handle_key(key)
53
67
  end
@@ -57,7 +71,9 @@ module Marvi
57
71
  ::Curses.close_screen
58
72
  end
59
73
 
60
- private
74
+ def current_tab
75
+ @tabs[@current]
76
+ end
61
77
 
62
78
  # ncurses uses the terminfo `rep` capability ("ESC[Nb") to compress runs of
63
79
  # identical glyphs, but ghostty mishandles it and drops the run from the
@@ -115,6 +131,7 @@ module Marvi
115
131
  end
116
132
 
117
133
  def handle_key(key)
134
+ @status_message = nil
118
135
  case key
119
136
  when "q", "Q", 27 then throw :quit
120
137
  when "j", ::Curses::Key::DOWN then scroll_by(1)
@@ -123,28 +140,120 @@ module Marvi
123
140
  when "u", CTRL_U then scroll_by(-page_size / 2)
124
141
  when "f", " ", ::Curses::Key::NPAGE then scroll_by(page_size)
125
142
  when "b", ::Curses::Key::PPAGE then scroll_by(-page_size)
126
- when "g" then @scroll = 0
143
+ when "g" then current_tab.scroll = 0
127
144
  draw
128
- when "G" then @scroll = max_scroll
145
+ when "G" then current_tab.scroll = current_tab.max_scroll(page_size)
129
146
  draw
130
147
  when "/" then start_search
131
148
  when "n" then jump_match(1)
132
149
  when "N" then jump_match(-1)
133
- when "e" then launch_editor if @file
134
- when "r", "R" then reload_from_key if @file
150
+ when "e" then launch_editor if current_tab.file
151
+ when "r", "R" then reload_from_key if current_tab.file
152
+ when "\t", TAB_KEY then cycle_tab(1)
153
+ when ::Curses::Key::BTAB then cycle_tab(-1)
154
+ when "1".."9" then switch_to(key.to_i - 1)
155
+ when "o" then start_open
156
+ when "x" then close_current_tab
157
+ when ::Curses::Key::MOUSE then handle_mouse
135
158
  when ::Curses::Key::RESIZE then handle_resize
136
159
  end
137
160
  end
138
161
 
139
- def handle_resize
140
- rewalk
141
- @scroll = [@scroll, max_scroll].min
142
- refresh_search_after_rewalk
162
+ # --- tabs ---
163
+
164
+ def cycle_tab(delta)
165
+ return if @tabs.size <= 1
166
+
167
+ switch_to((@current + delta) % @tabs.size)
168
+ end
169
+
170
+ def switch_to(index)
171
+ return if index.negative? || index >= @tabs.size
172
+
173
+ @current = index
174
+ current_tab.ensure_walked(content_width, page_size)
143
175
  draw
144
176
  end
145
177
 
146
- def rewalk
147
- @lines = ASTWalker.new.walk(@markdown, max_width: content_width)
178
+ def close_current_tab
179
+ throw :quit if @tabs.size == 1
180
+
181
+ @tabs.delete_at(@current)
182
+ @current = [@current, @tabs.size - 1].min
183
+ update_mousemask
184
+ # The bar may have lost a row (or disappeared), changing the page size.
185
+ current_tab.ensure_walked(content_width, page_size)
186
+ draw
187
+ end
188
+
189
+ def open_tab(path)
190
+ path = File.expand_path(path)
191
+ existing = @tabs.index { |tab| tab.file == path }
192
+ return switch_to(existing) if existing
193
+
194
+ unless File.file?(path) && File.readable?(path)
195
+ @status_message = " cannot open: #{path} "
196
+ draw
197
+ return
198
+ end
199
+
200
+ @tabs << Tab.new(file: path)
201
+ update_mousemask
202
+ switch_to(@tabs.size - 1)
203
+ end
204
+
205
+ def update_mousemask
206
+ return unless ::Curses.respond_to?(:mousemask)
207
+
208
+ # Claim the mouse only when the tab bar is shown, so single-document
209
+ # sessions keep native terminal text selection.
210
+ mask = (@tabs.size > 1) ? (::Curses::BUTTON1_CLICKED | ::Curses::BUTTON1_PRESSED) : 0
211
+ ::Curses.mousemask(mask)
212
+ end
213
+
214
+ def handle_mouse
215
+ event = ::Curses.getmouse
216
+ return unless event
217
+ return if (event.bstate & (::Curses::BUTTON1_CLICKED | ::Curses::BUTTON1_PRESSED)).zero?
218
+
219
+ item = TabBar.item_for_click(tab_layout, event.y, event.x)
220
+ switch_to(item.index) if item
221
+ end
222
+
223
+ def tab_layout
224
+ return [] if @tabs.size <= 1
225
+
226
+ labels = @tabs.each_with_index.map { |tab, i| tab_label(tab, i) }
227
+ TabBar.layout(labels, ::Curses.cols)
228
+ end
229
+
230
+ def tab_label(tab, index)
231
+ updated_mark = tab.file_updated? ? " ●" : ""
232
+ "#{index + 1}:#{tab.label}#{updated_mark}"
233
+ end
234
+
235
+ def tab_bar_height
236
+ rows = TabBar.rows(tab_layout)
237
+ rows.zero? ? 0 : rows + TAB_BAR_MARGIN_ROWS
238
+ end
239
+
240
+ def draw_tab_bar(layout)
241
+ layout.each do |item|
242
+ ::Curses.setpos(item.row, item.col)
243
+ attr = if item.index == @current
244
+ ::Curses::A_REVERSE | ::Curses::A_BOLD
245
+ else
246
+ ::Curses.color_pair(COLOR_PAIRS[:cyan])
247
+ end
248
+ ::Curses.attron(attr) { ::Curses.addstr(item.text) }
249
+ rescue ::Curses::Error
250
+ # ignore write errors at screen edge
251
+ end
252
+ end
253
+
254
+ def handle_resize
255
+ current_tab.rewalk(content_width, page_size)
256
+ draw
148
257
  end
149
258
 
150
259
  def horizontal_padding
@@ -158,56 +267,28 @@ module Marvi
158
267
  end
159
268
 
160
269
  def reload_from_key
161
- reload
162
- mark_reloaded
270
+ current_tab.reload(content_width, page_size)
163
271
  draw
164
272
  end
165
273
 
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
274
+ def poll_files
275
+ updated = @tabs.select(&:check_file_updated)
276
+ draw unless updated.empty?
189
277
  end
190
278
 
191
279
  def launch_editor
280
+ tab = current_tab
192
281
  editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
193
- line = current_source_line
194
- cmd = build_editor_command(editor, @file, line)
282
+ line = tab.current_source_line(page_size)
283
+ cmd = build_editor_command(editor, tab.file, line)
195
284
 
196
285
  ::Curses.close_screen
197
286
  system(cmd)
198
287
  init_curses_state
199
- reload
200
- mark_reloaded
288
+ tab.reload(content_width, page_size)
201
289
  draw
202
290
  end
203
291
 
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
292
  def init_curses_state
212
293
  with_safe_term { ::Curses.init_screen }
213
294
  ::Curses.start_color
@@ -217,6 +298,7 @@ module Marvi
217
298
  ::Curses.stdscr.keypad(true)
218
299
  ::Curses.stdscr.timeout = FILE_POLL_INTERVAL_MS
219
300
  setup_colors
301
+ update_mousemask
220
302
  end
221
303
 
222
304
  def build_editor_command(editor, file, line)
@@ -233,39 +315,40 @@ module Marvi
233
315
  end
234
316
  end
235
317
 
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
318
  def draw
244
319
  ::Curses.clear
320
+ layout = tab_layout
321
+ draw_tab_bar(layout)
322
+ bar_rows = TabBar.rows(layout)
323
+ offset = bar_rows.zero? ? 0 : bar_rows + TAB_BAR_MARGIN_ROWS
324
+ tab = current_tab
325
+ tab.clamp_scroll(page_size)
245
326
  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))
327
+ tab.visible_lines(page_size).each_with_index do |line, row|
328
+ ::Curses.setpos(row + offset, padding)
329
+ render_line(line, tab.highlight_ranges_for(tab.scroll + row))
249
330
  end
250
331
  draw_status_bar
251
332
  ::Curses.refresh
252
333
  end
253
334
 
254
335
  def draw_status_bar
336
+ tab = current_tab
255
337
  ::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
338
+ top = tab.scroll + 1
339
+ bottom = [tab.scroll + page_size, tab.lines.size].min
340
+ tab_hint = (@tabs.size > 1) ? " [#{@current + 1}/#{@tabs.size}] Tab next" : ""
341
+ edit_hint = tab.file ? " e edit" : ""
342
+ 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"
343
+ right_hint = @status_message || (tab.file_updated? ? " ● updated (r to reload) " : "")
344
+ available = [::Curses.cols - right_hint.length, 0].max
262
345
 
263
346
  ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
264
347
  ::Curses.addstr(status.ljust(available)[0, available])
265
348
  end
266
- unless updated_hint.empty?
349
+ unless right_hint.empty?
267
350
  ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:yellow]) | ::Curses::A_BOLD) do
268
- ::Curses.addstr(updated_hint)
351
+ ::Curses.addstr(right_hint)
269
352
  end
270
353
  end
271
354
  end
@@ -344,31 +427,23 @@ module Marvi
344
427
  attr
345
428
  end
346
429
 
347
- def visible_lines
348
- @lines[@scroll, page_size] || []
349
- end
350
-
351
430
  def page_size
352
- [::Curses.lines - 1, 1].max
353
- end
354
-
355
- def max_scroll
356
- [@lines.size - page_size, 0].max
431
+ [::Curses.lines - 1 - tab_bar_height, 1].max
357
432
  end
358
433
 
359
434
  def scroll_by(delta)
360
- @scroll = (@scroll + delta).clamp(0, max_scroll)
435
+ current_tab.scroll_by(delta, page_size)
361
436
  draw
362
437
  end
363
438
 
364
- # --- Incremental search ---
439
+ # --- line-editing prompts (search and open) ---
365
440
 
366
441
  BACKSPACE_KEYS = [127, 8].freeze
367
442
 
368
443
  def start_search
369
444
  @search_input = ""
370
445
  update_search(@search_input)
371
- draw_search_prompt
446
+ draw_prompt("/#{@search_input}")
372
447
 
373
448
  loop do
374
449
  key = ::Curses.getch
@@ -376,118 +451,126 @@ module Marvi
376
451
 
377
452
  case key
378
453
  when 27 # ESC cancels and clears the search
379
- clear_search
454
+ current_tab.clear_search
455
+ @search_input = nil
380
456
  draw
381
457
  return
382
458
  when "\n", "\r", 10, 13, ::Curses::Key::ENTER
383
- commit_search
459
+ current_tab.commit_search
460
+ @search_input = nil
461
+ draw
384
462
  return
385
463
  when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
386
464
  @search_input = @search_input[0...-1] || ""
387
465
  update_search(@search_input)
388
- draw_search_prompt
466
+ draw_prompt("/#{@search_input}")
389
467
  else
390
468
  next unless key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
391
469
  @search_input += key
392
470
  update_search(@search_input)
393
- draw_search_prompt
471
+ draw_prompt("/#{@search_input}")
394
472
  end
395
473
  end
396
474
  end
397
475
 
398
476
  def update_search(query)
399
- @search_query = query
400
- recompute_matches
401
- focus_match_from(@scroll) unless @matches.empty?
477
+ current_tab.update_search(query, page_size)
402
478
  draw
403
479
  end
404
480
 
405
- def commit_search
406
- if @search_query.to_s.empty? || @matches.empty?
407
- clear_search
408
- end
409
- @search_input = nil
481
+ def jump_match(direction)
482
+ current_tab.jump_match(direction, page_size)
410
483
  draw
411
484
  end
412
485
 
413
- def clear_search
414
- @search_query = nil
415
- @search_input = nil
416
- @matches = []
417
- @current_match = nil
418
- end
486
+ def start_open
487
+ picker = FilePicker.new
488
+ input = ""
489
+ selected = 0
419
490
 
420
- def recompute_matches
421
- @matches = []
422
- @current_match = nil
423
- needle = @search_query.to_s.downcase
424
- return if needle.empty?
491
+ loop do
492
+ candidates = picker.candidates(input)
493
+ selected = candidates.empty? ? 0 : selected % candidates.size
494
+ draw_picker(input, candidates, selected)
425
495
 
426
- @lines.each_with_index do |line, line_index|
427
- haystack = line.plain_text.downcase
428
- next if haystack.empty?
496
+ key = ::Curses.getch
497
+ next if key.nil? || key == -1
429
498
 
430
- pos = 0
431
- while (idx = haystack.index(needle, pos))
432
- @matches << [line_index, idx, idx + needle.length]
433
- pos = idx + needle.length
499
+ case key
500
+ when 27 # ESC cancels
501
+ draw
502
+ return
503
+ when "\n", "\r", 10, 13, ::Curses::Key::ENTER
504
+ choice = candidates[selected] || input.strip
505
+ if choice.empty?
506
+ draw
507
+ return
508
+ elsif choice.end_with?("/")
509
+ # descend into the selected directory and keep picking
510
+ input = choice
511
+ selected = 0
512
+ else
513
+ open_tab(choice)
514
+ return
515
+ end
516
+ when "\t", TAB_KEY
517
+ if picker.path_mode?(input)
518
+ input = picker.complete(input)
519
+ else
520
+ selected += 1
521
+ end
522
+ when ::Curses::Key::BTAB
523
+ selected -= 1
524
+ when CTRL_N, ::Curses::Key::DOWN
525
+ selected += 1
526
+ when CTRL_P, ::Curses::Key::UP
527
+ selected -= 1
528
+ when *BACKSPACE_KEYS, ::Curses::Key::BACKSPACE
529
+ input = input[0...-1] || ""
530
+ selected = 0
531
+ else
532
+ if key.is_a?(String) && key.match?(/\A[[:print:]]\z/)
533
+ input += key
534
+ selected = 0
535
+ end
434
536
  end
435
537
  end
436
538
  end
437
539
 
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
540
+ def draw_picker(input, candidates, selected)
541
+ ::Curses.clear
542
+ width = ::Curses.cols
543
+ position = candidates.empty? ? 0 : selected + 1
544
+ header = " open file (#{position}/#{candidates.size}) C-n/C-p select Enter open ESC cancel"
545
+ ::Curses.setpos(0, 0)
546
+ ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan]) | ::Curses::A_BOLD) do
547
+ ::Curses.addstr(header.ljust(width)[0, width])
453
548
  end
454
- end
455
549
 
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)
550
+ list_rows = [::Curses.lines - 2, 1].max
551
+ offset = (selected < list_rows) ? 0 : selected - list_rows + 1
552
+ (candidates[offset, list_rows] || []).each_with_index do |candidate, i|
553
+ ::Curses.setpos(1 + i, 0)
554
+ text = " #{candidate} ".ljust(width)[0, width]
555
+ if offset + i == selected
556
+ ::Curses.attron(::Curses::A_REVERSE) { ::Curses.addstr(text) }
557
+ else
558
+ ::Curses.addstr(text)
559
+ end
560
+ rescue ::Curses::Error
561
+ # ignore write errors at screen edge
462
562
  end
463
- draw
464
- end
465
563
 
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
564
+ prompt = "open: #{input}"
565
+ ::Curses.setpos(::Curses.lines - 1, 0)
566
+ ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
567
+ ::Curses.addstr(prompt.ljust(width)[0, width])
477
568
  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"
569
+ ::Curses.setpos(::Curses.lines - 1, [prompt.length, width - 1].min)
570
+ ::Curses.refresh
487
571
  end
488
572
 
489
- def draw_search_prompt
490
- prompt = "/#{@search_input}"
573
+ def draw_prompt(prompt)
491
574
  ::Curses.setpos(::Curses.lines - 1, 0)
492
575
  ::Curses.attron(::Curses.color_pair(COLOR_PAIRS[:cyan])) do
493
576
  ::Curses.addstr(prompt.ljust(::Curses.cols)[0, ::Curses.cols])
@@ -498,3 +581,7 @@ module Marvi
498
581
  end
499
582
  end
500
583
  end
584
+
585
+ require_relative "curses/tab"
586
+ require_relative "curses/tab_bar"
587
+ 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.1"
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.1
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