marvi 0.4.1 → 0.5.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: 519a600dd8ba2e4c6934cf57f37914337ce252ced2a51d6b05dff2ab2c1dda4b
4
- data.tar.gz: '02669ac614cde421459555a5068429e1371036dd90eb549f5a90d6f3e3aea90c'
3
+ metadata.gz: 99ac52daecfd730bdd05ef63523075b5e4ad3229637208a99a083199518864dd
4
+ data.tar.gz: 4daae68331c1edf707b6c520c3defe941e9273b937e6618711b145e13568eed4
5
5
  SHA512:
6
- metadata.gz: 37a67589e93f21f02d2c7cec09130e2a63ce83e88ebe21c61cf643b4810506ff4ece12ca39d8c36a40cbccaca35ecf8984e1c8276f953b0e17bf2b9caf4f98de
7
- data.tar.gz: 12fa9234e6fb3dd1b13b72b5008a239acea242b049ae4086d6e2a8565435b4727fd5850c5766d79f05bbc48332719f7a2b97609725c8834ca1f6083d4a7acf02
6
+ metadata.gz: 775e51510484dac676928c537175db222f682d3d66c74691e248616442f042f294ae896c270531475e1cdc989dde1c1a5cb957df6acdf4159e0157b4d51f3d1f
7
+ data.tar.gz: b00bffa8ef8adaa9e5dab84df060e31d96ffad92e473bfb544df2962c3986cb26db06c35299902a4e341dd9c91422642b06ea9e3ab15e1c8089e64f64f352d56
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-06-02
4
+
5
+ - Syntax-highlight fenced code blocks via [Rouge](https://github.com/rouge-ruby/rouge). Blocks without a language (or with an unknown one) fall back to the previous single-color rendering.
6
+ - Extend the dark code block background to the longest line so the block reads as a solid pane instead of a ragged shape.
7
+
8
+ ## [0.4.2] - 2026-05-31
9
+
10
+ - Add left/right padding in the curses pager so content no longer sits flush against the terminal edges. Padding scales with terminal width.
11
+ - Render every line of a multi-line blockquote with its `│ ` prefix.
12
+ - Stop emitting an empty `│ ` line at the end of blockquotes.
13
+ - Collapse consecutive blank lines so block-level elements are separated by a single blank line.
14
+
3
15
  ## [0.4.1] - 2026-05-26
4
16
 
5
17
  - Wrap long text in bulleted/ordered lists, paragraphs, headers, and blockquotes so it no longer overflows the terminal width. List items and headers use a hanging indent for continuation lines. (#1)
@@ -16,7 +16,7 @@ module Marvi
16
16
  doc = Kramdown::Document.new(markdown, input: "GFM")
17
17
  lines = render_block(doc.root)
18
18
  lines.pop while lines.last&.plain_text&.empty?
19
- lines
19
+ collapse_consecutive_blanks(lines)
20
20
  end
21
21
 
22
22
  private
@@ -112,11 +112,24 @@ module Marvi
112
112
  lang = el.options[:lang]
113
113
  lines = []
114
114
  lines << RichLine.new([Span.new(text: lang, color: :yellow)], source_line: src) if lang
115
- el.value.chomp.split("\n").each_with_index do |line, i|
116
- line_src = if src
117
- src + i + (lang ? 1 : 0)
115
+
116
+ code = el.value.chomp
117
+ unless code.empty?
118
+ line_offset = lang ? 1 : 0
119
+ indent = Span.new(text: " ", bg_color: :dark)
120
+ indent_width = spans_display_width([indent])
121
+
122
+ highlighted = Highlighter.lines(code, lang)
123
+ content_widths = highlighted.map { |spans| spans_display_width(spans) }
124
+ block_width = [content_widths.max || 0, @max_width - indent_width].min
125
+
126
+ highlighted.each_with_index do |spans, i|
127
+ line_src = src ? src + i + line_offset : nil
128
+ line_spans = [indent] + spans
129
+ pad = block_width - content_widths[i]
130
+ line_spans << Span.new(text: " " * pad, bg_color: :dark) if pad > 0
131
+ lines << RichLine.new(line_spans, source_line: line_src)
118
132
  end
119
- lines << RichLine.new([Span.new(text: " #{line}", color: :green, bg_color: :dark)], source_line: line_src)
120
133
  end
121
134
  lines << RichLine.blank
122
135
  lines
@@ -131,9 +144,26 @@ module Marvi
131
144
  @max_width = [saved_width - prefix_width, MIN_COL_WIDTH].max
132
145
  inner = el.children.flat_map { |child| render_block(child) }
133
146
  @max_width = saved_width
147
+ # Trim trailing blanks and collapse runs of blanks so the │ prefix doesn't produce
148
+ # dangling/duplicated "│ " lines (kramdown emits both :p trailing blanks and explicit
149
+ # :blank elements between siblings).
150
+ inner.pop while inner.last&.plain_text&.empty?
151
+ inner = collapse_consecutive_blanks(inner)
134
152
  inner.map { |line| RichLine.new([prefix] + line.spans, source_line: line.source_line) } + [RichLine.blank]
135
153
  end
136
154
 
155
+ def collapse_consecutive_blanks(lines)
156
+ out = []
157
+ prev_blank = false
158
+ lines.each do |line|
159
+ is_blank = line.plain_text.empty?
160
+ next if is_blank && prev_blank
161
+ out << line
162
+ prev_blank = is_blank
163
+ end
164
+ out
165
+ end
166
+
137
167
  def render_table(el)
138
168
  src = el.options[:location]
139
169
  rows = el.children.flat_map(&:children)
@@ -210,34 +240,43 @@ module Marvi
210
240
  lines = [[]]
211
241
  current_width = 0
212
242
  spans.each do |span|
213
- text = span.text.dup
214
- until text.empty?
215
- remaining = width - current_width
216
- if remaining <= 0
243
+ # Split on \n so embedded hard line breaks become separate RichLines downstream,
244
+ # otherwise Curses.addstr resets the cursor to column 0 and bypasses padding/prefix.
245
+ parts = span.text.split("\n", -1)
246
+ parts.each_with_index do |part, idx|
247
+ if idx > 0 && !lines.last.empty?
217
248
  lines << []
218
249
  current_width = 0
219
- remaining = width
220
- end
221
- taken = 0
222
- chunk_width = 0
223
- text.each_char do |c|
224
- cw = Unicode::DisplayWidth.of(c)
225
- break if chunk_width + cw > remaining
226
- chunk_width += cw
227
- taken += c.bytesize
228
250
  end
229
- if taken.zero?
230
- first_char = text.each_char.first
231
- taken = first_char.bytesize
232
- chunk_width = Unicode::DisplayWidth.of(first_char)
233
- end
234
- chunk = text.byteslice(0, taken)
235
- text = text.byteslice(taken..) || ""
236
- lines.last << Span.new(text: chunk, bold: span.bold, italic: span.italic, color: span.color, bg_color: span.bg_color)
237
- current_width += chunk_width
238
- unless text.empty?
239
- lines << []
240
- current_width = 0
251
+ text = part.dup
252
+ until text.empty?
253
+ remaining = width - current_width
254
+ if remaining <= 0
255
+ lines << []
256
+ current_width = 0
257
+ remaining = width
258
+ end
259
+ taken = 0
260
+ chunk_width = 0
261
+ text.each_char do |c|
262
+ cw = Unicode::DisplayWidth.of(c)
263
+ break if chunk_width + cw > remaining
264
+ chunk_width += cw
265
+ taken += c.bytesize
266
+ end
267
+ if taken.zero?
268
+ first_char = text.each_char.first
269
+ taken = first_char.bytesize
270
+ chunk_width = Unicode::DisplayWidth.of(first_char)
271
+ end
272
+ chunk = text.byteslice(0, taken)
273
+ text = text.byteslice(taken..) || ""
274
+ lines.last << Span.new(text: chunk, bold: span.bold, italic: span.italic, color: span.color, bg_color: span.bg_color)
275
+ current_width += chunk_width
276
+ unless text.empty?
277
+ lines << []
278
+ current_width = 0
279
+ end
241
280
  end
242
281
  end
243
282
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module Marvi
6
+ class Highlighter
7
+ DEFAULT_COLOR = :white
8
+ FALLBACK_COLOR = :green
9
+
10
+ # Ordered so longer qualnames win over their prefixes (e.g. "Literal.String" before "Literal").
11
+ TOKEN_COLOR_RULES = [
12
+ ["Comment", :cyan],
13
+ ["Keyword", :yellow],
14
+ ["Literal.String", :green],
15
+ ["Literal.Number", :magenta],
16
+ ["Literal", :magenta],
17
+ ["Name.Function", :cyan],
18
+ ["Name.Class", :cyan],
19
+ ["Name.Namespace", :cyan],
20
+ ["Name.Tag", :cyan],
21
+ ["Name.Attribute", :cyan],
22
+ ["Name.Decorator", :cyan],
23
+ ["Name.Constant", :magenta],
24
+ ["Name.Builtin", :magenta],
25
+ ["Operator", :white],
26
+ ["Punctuation", :white],
27
+ ["Error", :magenta]
28
+ ].freeze
29
+
30
+ COMMENT_PREFIX = "Comment"
31
+
32
+ def self.lines(code, lang, bg_color: :dark)
33
+ lexer = resolve_lexer(lang)
34
+ return fallback(code, bg_color) if lexer.nil?
35
+ tokens_to_lines(lexer.lex(code), bg_color)
36
+ end
37
+
38
+ def self.resolve_lexer(lang)
39
+ return nil if lang.nil? || lang.empty?
40
+ Rouge::Lexer.find(lang)
41
+ rescue
42
+ nil
43
+ end
44
+
45
+ def self.fallback(code, bg_color)
46
+ code.split("\n", -1).map { |line| [Span.new(text: line, color: FALLBACK_COLOR, bg_color: bg_color)] }
47
+ end
48
+
49
+ def self.tokens_to_lines(tokens, bg_color)
50
+ current = []
51
+ lines = []
52
+ tokens.each do |tok, val|
53
+ color = token_color(tok)
54
+ italic = comment?(tok)
55
+ val.split("\n", -1).each_with_index do |part, idx|
56
+ if idx > 0
57
+ lines << current
58
+ current = []
59
+ end
60
+ next if part.empty?
61
+ current << Span.new(text: part, italic: italic, color: color, bg_color: bg_color)
62
+ end
63
+ end
64
+ lines << current
65
+ lines
66
+ end
67
+
68
+ def self.token_color(token)
69
+ qual = token.qualname
70
+ TOKEN_COLOR_RULES.each do |prefix, color|
71
+ return color if qual == prefix || qual.start_with?("#{prefix}.")
72
+ end
73
+ DEFAULT_COLOR
74
+ end
75
+
76
+ def self.comment?(token)
77
+ qual = token.qualname
78
+ qual == COMMENT_PREFIX || qual.start_with?("#{COMMENT_PREFIX}.")
79
+ end
80
+ end
81
+ end
@@ -12,8 +12,11 @@ module Marvi
12
12
  yellow: 3,
13
13
  magenta: 4,
14
14
  white: 5,
15
- green_on_dark: 6,
16
- cyan_on_dark: 7
15
+ cyan_on_dark: 6,
16
+ green_on_dark: 7,
17
+ yellow_on_dark: 8,
18
+ magenta_on_dark: 9,
19
+ white_on_dark: 10
17
20
  }.freeze
18
21
 
19
22
  ITALIC_ATTR = (defined?(::Curses::A_ITALIC) ? ::Curses::A_ITALIC : 0)
@@ -23,6 +26,9 @@ module Marvi
23
26
  CTRL_D = 4
24
27
  CTRL_U = 21
25
28
 
29
+ MIN_HORIZONTAL_PADDING = 2
30
+ HORIZONTAL_PADDING_DIVISOR = 12
31
+
26
32
  def render(markdown, file: nil)
27
33
  @file = file
28
34
  @markdown = markdown
@@ -97,8 +103,11 @@ module Marvi
97
103
  ::Curses.init_pair(COLOR_PAIRS[:yellow], ::Curses::COLOR_YELLOW, -1)
98
104
  ::Curses.init_pair(COLOR_PAIRS[:magenta], ::Curses::COLOR_MAGENTA, -1)
99
105
  ::Curses.init_pair(COLOR_PAIRS[:white], ::Curses::COLOR_WHITE, -1)
100
- ::Curses.init_pair(COLOR_PAIRS[:green_on_dark], ::Curses::COLOR_GREEN, ::Curses::COLOR_BLACK)
101
106
  ::Curses.init_pair(COLOR_PAIRS[:cyan_on_dark], ::Curses::COLOR_CYAN, ::Curses::COLOR_BLACK)
107
+ ::Curses.init_pair(COLOR_PAIRS[:green_on_dark], ::Curses::COLOR_GREEN, ::Curses::COLOR_BLACK)
108
+ ::Curses.init_pair(COLOR_PAIRS[:yellow_on_dark], ::Curses::COLOR_YELLOW, ::Curses::COLOR_BLACK)
109
+ ::Curses.init_pair(COLOR_PAIRS[:magenta_on_dark], ::Curses::COLOR_MAGENTA, ::Curses::COLOR_BLACK)
110
+ ::Curses.init_pair(COLOR_PAIRS[:white_on_dark], ::Curses::COLOR_WHITE, ::Curses::COLOR_BLACK)
102
111
  end
103
112
 
104
113
  def handle_key(key)
@@ -127,7 +136,17 @@ module Marvi
127
136
  end
128
137
 
129
138
  def rewalk
130
- @lines = ASTWalker.new.walk(@markdown, max_width: ::Curses.cols)
139
+ @lines = ASTWalker.new.walk(@markdown, max_width: content_width)
140
+ end
141
+
142
+ def horizontal_padding
143
+ cols = ::Curses.cols
144
+ return 0 if cols <= MIN_HORIZONTAL_PADDING * 2
145
+ [MIN_HORIZONTAL_PADDING, cols / HORIZONTAL_PADDING_DIVISOR].max
146
+ end
147
+
148
+ def content_width
149
+ [::Curses.cols - horizontal_padding * 2, 1].max
131
150
  end
132
151
 
133
152
  def reload_from_key
@@ -214,8 +233,9 @@ module Marvi
214
233
 
215
234
  def draw
216
235
  ::Curses.clear
236
+ padding = horizontal_padding
217
237
  visible_lines.each_with_index do |line, row|
218
- ::Curses.setpos(row, 0)
238
+ ::Curses.setpos(row, padding)
219
239
  render_line(line)
220
240
  end
221
241
  draw_status_bar
@@ -264,7 +284,7 @@ module Marvi
264
284
  attr |= ITALIC_ATTR if span.italic
265
285
 
266
286
  pair_key = if span.bg_color == :dark
267
- (span.color == :cyan) ? :cyan_on_dark : :green_on_dark
287
+ :"#{span.color || :green}_on_dark"
268
288
  elsif span.color
269
289
  span.color
270
290
  end
data/lib/marvi/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Marvi
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/marvi.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "marvi/version"
4
4
  require_relative "marvi/ansi"
5
5
  require_relative "marvi/document"
6
+ require_relative "marvi/highlighter"
6
7
  require_relative "marvi/ast_walker"
7
8
  require_relative "marvi/renderer/ansi"
8
9
  require_relative "marvi/renderer/curses"
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.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mitsutaka Mimura
@@ -65,6 +65,26 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '3.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rouge
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '4.0'
75
+ - - "<"
76
+ - !ruby/object:Gem::Version
77
+ version: '6'
78
+ type: :runtime
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '4.0'
85
+ - - "<"
86
+ - !ruby/object:Gem::Version
87
+ version: '6'
68
88
  description: Renders Markdown with ANSI colors in pipes and an interactive curses
69
89
  pager in TTY.
70
90
  email:
@@ -86,6 +106,7 @@ files:
86
106
  - lib/marvi/ansi.rb
87
107
  - lib/marvi/ast_walker.rb
88
108
  - lib/marvi/document.rb
109
+ - lib/marvi/highlighter.rb
89
110
  - lib/marvi/renderer/ansi.rb
90
111
  - lib/marvi/renderer/curses.rb
91
112
  - lib/marvi/version.rb