brrowser 0.1.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.
data/img/brrowser.svg ADDED
@@ -0,0 +1,79 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#1a1a2e"/>
5
+ <stop offset="100%" style="stop-color:#0a0a14"/>
6
+ </linearGradient>
7
+ <linearGradient id="glow" x1="0%" y1="0%" x2="0%" y2="100%">
8
+ <stop offset="0%" style="stop-color:#e2b714"/>
9
+ <stop offset="100%" style="stop-color:#c49000"/>
10
+ </linearGradient>
11
+ </defs>
12
+
13
+ <style>
14
+ @keyframes cursor-blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
15
+ @keyframes scan { 0% { transform: translateY(-60px); opacity: 0; } 20% { opacity: 0.6; } 80% { opacity: 0.6; } 100% { transform: translateY(60px); opacity: 0; } }
16
+ .cursor { animation: cursor-blink 1.2s step-end infinite; }
17
+ .scan-line { animation: scan 3s ease-in-out infinite; }
18
+ </style>
19
+
20
+ <!-- Background -->
21
+ <circle cx="128" cy="128" r="120" fill="url(#bg)" stroke="#e2b714" stroke-width="3"/>
22
+
23
+ <!-- Globe/web lines (world wide web) -->
24
+ <circle cx="128" cy="128" r="70" fill="none" stroke="#51afef" stroke-width="0.8" opacity="0.3"/>
25
+ <ellipse cx="128" cy="128" rx="35" ry="70" fill="none" stroke="#51afef" stroke-width="0.8" opacity="0.3"/>
26
+ <ellipse cx="128" cy="128" rx="55" ry="70" fill="none" stroke="#51afef" stroke-width="0.6" opacity="0.2"/>
27
+ <line x1="58" y1="128" x2="198" y2="128" stroke="#51afef" stroke-width="0.6" opacity="0.3"/>
28
+ <line x1="72" y1="90" x2="184" y2="90" stroke="#51afef" stroke-width="0.5" opacity="0.2"/>
29
+ <line x1="72" y1="166" x2="184" y2="166" stroke="#51afef" stroke-width="0.5" opacity="0.2"/>
30
+
31
+ <!-- Terminal window frame -->
32
+ <rect x="48" y="60" width="160" height="110" rx="6" fill="#0d0d0d" stroke="#e2b714" stroke-width="2"/>
33
+ <!-- Title bar -->
34
+ <rect x="48" y="60" width="160" height="16" rx="6" fill="#1a1a2e"/>
35
+ <rect x="48" y="70" width="160" height="6" fill="#1a1a2e"/>
36
+ <!-- Window buttons -->
37
+ <circle cx="60" cy="68" r="3" fill="#e55"/>
38
+ <circle cx="72" cy="68" r="3" fill="#e2b714"/>
39
+ <circle cx="84" cy="68" r="3" fill="#4c4"/>
40
+
41
+ <!-- Browser content lines -->
42
+ <!-- URL bar -->
43
+ <rect x="56" y="80" width="144" height="10" rx="2" fill="#1a1a2e"/>
44
+ <text x="60" y="88" font-family="monospace" font-size="6" fill="#51afef">https://</text>
45
+ <rect class="cursor" x="96" y="81" width="4" height="8" fill="#e2b714" opacity="0.8"/>
46
+
47
+ <!-- Heading -->
48
+ <rect x="56" y="96" width="80" height="6" rx="1" fill="#e2b714" opacity="0.8"/>
49
+
50
+ <!-- Text lines -->
51
+ <rect x="56" y="108" width="136" height="3" rx="1" fill="#aab" opacity="0.4"/>
52
+ <rect x="56" y="114" width="120" height="3" rx="1" fill="#aab" opacity="0.4"/>
53
+ <rect x="56" y="120" width="128" height="3" rx="1" fill="#aab" opacity="0.4"/>
54
+
55
+ <!-- Links (cyan) -->
56
+ <rect x="56" y="130" width="40" height="4" rx="1" fill="#51afef" opacity="0.7"/>
57
+ <rect x="102" y="130" width="50" height="4" rx="1" fill="#51afef" opacity="0.7"/>
58
+
59
+ <!-- Image placeholder -->
60
+ <rect x="56" y="140" width="44" height="24" rx="2" fill="#1a1a2e" stroke="#e2b714" stroke-width="0.8"/>
61
+ <polygon points="62,158 72,148 78,154 86,146 94,158" fill="#4c4" opacity="0.5"/>
62
+ <circle cx="88" cy="148" r="3" fill="#e2b714" opacity="0.5"/>
63
+
64
+ <!-- More text -->
65
+ <rect x="106" y="142" width="90" height="3" rx="1" fill="#aab" opacity="0.4"/>
66
+ <rect x="106" y="148" width="80" height="3" rx="1" fill="#aab" opacity="0.4"/>
67
+ <rect x="106" y="154" width="86" height="3" rx="1" fill="#aab" opacity="0.4"/>
68
+
69
+ <!-- Scan line effect -->
70
+ <rect class="scan-line" x="48" y="128" width="160" height="1" fill="#51afef" opacity="0.15"/>
71
+
72
+ <!-- Status bar at bottom -->
73
+ <rect x="48" y="164" width="160" height="6" fill="#1a1a2e"/>
74
+ <text x="54" y="169" font-family="monospace" font-size="4.5" fill="#888">101 links | ? help | : command</text>
75
+
76
+ <!-- brrowser text -->
77
+ <text x="128" y="210" font-family="monospace" font-size="20" font-weight="bold" fill="url(#glow)" text-anchor="middle" letter-spacing="2">brrowser</text>
78
+ <text x="128" y="226" font-family="monospace" font-size="8" fill="#51afef" text-anchor="middle" opacity="0.7">terminal web browser</text>
79
+ </svg>
Binary file
@@ -0,0 +1,111 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'openssl'
4
+ require 'yaml'
5
+
6
+ module Brrowser
7
+ class Fetcher
8
+ MAX_REDIRECTS = 10
9
+ TIMEOUT = 15
10
+ USER_AGENT = "brrowser/0.1 (terminal browser)"
11
+ COOKIE_FILE = File.join(Dir.home, ".brrowser", "cookies.yml")
12
+
13
+ def initialize
14
+ @cookies = load_cookies
15
+ end
16
+
17
+ def fetch(url, method: :get, params: nil)
18
+ url = "https://#{url}" unless url.match?(%r{^https?://})
19
+ uri = URI.parse(url)
20
+ redirects = 0
21
+
22
+ loop do
23
+ raise "Too many redirects" if redirects >= MAX_REDIRECTS
24
+
25
+ http = Net::HTTP.new(uri.host, uri.port)
26
+ http.open_timeout = TIMEOUT
27
+ http.read_timeout = TIMEOUT
28
+ if uri.scheme == "https"
29
+ http.use_ssl = true
30
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
31
+ end
32
+
33
+ path = uri.request_uri.empty? ? "/" : uri.request_uri
34
+ if method == :post && params
35
+ req = Net::HTTP::Post.new(path)
36
+ req.set_form_data(params)
37
+ else
38
+ req = Net::HTTP::Get.new(path)
39
+ end
40
+ req["User-Agent"] = USER_AGENT
41
+ req["Accept"] = "text/html,application/xhtml+xml,*/*"
42
+ req["Accept-Language"] = "en-US,en;q=0.9"
43
+ req["Accept-Encoding"] = "identity"
44
+ cookie_str = cookies_for(uri)
45
+ req["Cookie"] = cookie_str unless cookie_str.empty?
46
+
47
+ response = http.request(req)
48
+ store_cookies(uri, response)
49
+
50
+ case response
51
+ when Net::HTTPRedirection
52
+ location = response["location"]
53
+ uri = location.start_with?("http") ? URI.parse(location) : URI.join(uri, location)
54
+ redirects += 1
55
+ when Net::HTTPSuccess
56
+ ct = response["content-type"] || ""
57
+ body = response.body
58
+ body = body.force_encoding("UTF-8") if ct.match?(/text|html|json|xml/)
59
+ return {
60
+ body: body,
61
+ url: uri.to_s,
62
+ content_type: ct,
63
+ status: response.code.to_i
64
+ }
65
+ else
66
+ return {
67
+ body: "Error #{response.code}: #{response.message}",
68
+ url: uri.to_s,
69
+ content_type: "text/plain",
70
+ status: response.code.to_i
71
+ }
72
+ end
73
+ end
74
+ rescue => e
75
+ {
76
+ body: "Error: #{e.message}",
77
+ url: url,
78
+ content_type: "text/plain",
79
+ status: 0
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def store_cookies(uri, response)
86
+ Array(response.get_fields("set-cookie")).each do |raw|
87
+ name_val = raw.split(";").first.strip
88
+ name, val = name_val.split("=", 2)
89
+ @cookies[uri.host] ||= {}
90
+ @cookies[uri.host][name] = val
91
+ end
92
+ save_cookies
93
+ end
94
+
95
+ def cookies_for(uri)
96
+ return "" unless @cookies[uri.host]
97
+ @cookies[uri.host].map { |k, v| "#{k}=#{v}" }.join("; ")
98
+ end
99
+
100
+ def load_cookies
101
+ return {} unless File.exist?(COOKIE_FILE)
102
+ YAML.safe_load(File.read(COOKIE_FILE), permitted_classes: [Symbol]) rescue {}
103
+ end
104
+
105
+ def save_cookies
106
+ dir = File.dirname(COOKIE_FILE)
107
+ Dir.mkdir(dir) unless Dir.exist?(dir)
108
+ File.write(COOKIE_FILE, @cookies.to_yaml)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,453 @@
1
+ require 'nokogiri'
2
+
3
+ module Brrowser
4
+ class Renderer
5
+ SKIP_ELEMENTS = %w[script style noscript svg head].freeze
6
+ BLOCK_ELEMENTS = %w[
7
+ p div section article aside main header footer nav
8
+ h1 h2 h3 h4 h5 h6 ul ol li dl dt dd
9
+ blockquote pre figure figcaption address
10
+ table thead tbody tfoot tr
11
+ form fieldset details summary hr br
12
+ ].freeze
13
+
14
+ IMG_RESERVE = 10 # Blank lines reserved for each image
15
+
16
+ attr_reader :links, :images, :forms
17
+
18
+ def initialize(width)
19
+ @width = width
20
+ @links = []
21
+ @images = []
22
+ @forms = []
23
+ @output = []
24
+ @line = ""
25
+ @col = 0
26
+ @indent = 0
27
+ @pre = false
28
+ @ol_count = []
29
+ @current_form = nil
30
+ end
31
+
32
+ def render(html, base_url = nil)
33
+ @base_url = base_url
34
+ @links = []
35
+ @images = []
36
+ @forms = []
37
+ @output = []
38
+ @line = ""
39
+ @col = 0
40
+
41
+ doc = Nokogiri::HTML(html)
42
+ title = doc.at_css("title")&.text&.strip || ""
43
+
44
+ body = doc.at_css("body") || doc
45
+ walk(body)
46
+ flush_line
47
+
48
+ { text: @output.join("\n"), links: @links, images: @images, forms: @forms, title: title }
49
+ end
50
+
51
+ private
52
+
53
+ def walk(node)
54
+ node.children.each { |child| process(child) }
55
+ end
56
+
57
+ def process(node)
58
+ if node.text?
59
+ handle_text(node.text)
60
+ elsif node.element?
61
+ handle_element(node)
62
+ end
63
+ end
64
+
65
+ def handle_text(text)
66
+ if @pre
67
+ text.each_line do |line|
68
+ @line << line.chomp.fg(186)
69
+ if line.end_with?("\n")
70
+ flush_line
71
+ end
72
+ end
73
+ else
74
+ text = text.gsub(/\s+/, " ")
75
+ return if text == " " && @col == 0
76
+ words = text.split(/( )/)
77
+ words.each do |word|
78
+ next if word.empty?
79
+ visible = word.gsub(/\e\[[0-9;]*m/, "")
80
+ if @col + visible.length > @width && @col > 0 && word != " "
81
+ flush_line
82
+ end
83
+ @line << apply_style(word)
84
+ @col += visible.length
85
+ end
86
+ end
87
+ end
88
+
89
+ def handle_element(node)
90
+ tag = node.name.downcase
91
+ return if SKIP_ELEMENTS.include?(tag)
92
+
93
+ case tag
94
+ when "br"
95
+ flush_line
96
+ when "hr"
97
+ ensure_blank_line
98
+ @line << ("─" * @width).fg(240)
99
+ flush_line
100
+ ensure_blank_line
101
+ when "h1"
102
+ ensure_blank_line
103
+ text = collect_text(node)
104
+ @line << text.b.fg(220)
105
+ flush_line
106
+ @line << ("═" * [text.gsub(/\e\[[0-9;]*m/, "").length, @width].min).fg(220)
107
+ flush_line
108
+ ensure_blank_line
109
+ when "h2"
110
+ ensure_blank_line
111
+ inline_walk(node, :b, 214)
112
+ flush_line
113
+ ensure_blank_line
114
+ when "h3"
115
+ ensure_blank_line
116
+ inline_walk(node, :b, 208)
117
+ flush_line
118
+ ensure_blank_line
119
+ when "h4", "h5", "h6"
120
+ ensure_blank_line
121
+ inline_walk(node, :b, 252)
122
+ flush_line
123
+ ensure_blank_line
124
+ when "p", "div", "section", "article", "aside", "main",
125
+ "header", "footer", "nav", "address", "figure", "figcaption",
126
+ "details", "summary"
127
+ ensure_blank_line
128
+ walk(node)
129
+ flush_line
130
+ ensure_blank_line
131
+ when "blockquote"
132
+ ensure_blank_line
133
+ old_indent = @indent
134
+ @indent += 2
135
+ walk(node)
136
+ flush_line
137
+ @indent = old_indent
138
+ ensure_blank_line
139
+ when "pre"
140
+ ensure_blank_line
141
+ @pre = true
142
+ walk(node)
143
+ flush_line
144
+ @pre = false
145
+ ensure_blank_line
146
+ when "code"
147
+ if @pre
148
+ walk(node)
149
+ else
150
+ text = collect_text(node)
151
+ @line << text.fg(186)
152
+ @col += text.length
153
+ end
154
+ when "ul"
155
+ ensure_blank_line
156
+ old_indent = @indent
157
+ @indent += 2
158
+ walk(node)
159
+ flush_line
160
+ @indent = old_indent
161
+ when "ol"
162
+ ensure_blank_line
163
+ old_indent = @indent
164
+ @indent += 2
165
+ @ol_count.push(0)
166
+ walk(node)
167
+ flush_line
168
+ @ol_count.pop
169
+ @indent = old_indent
170
+ when "li"
171
+ flush_line if @col > 0
172
+ if !@ol_count.empty?
173
+ @ol_count[-1] += 1
174
+ prefix = "#{@ol_count[-1]}. "
175
+ else
176
+ prefix = "\u2022 "
177
+ end
178
+ @line << prefix.fg(245)
179
+ @col += prefix.length
180
+ walk(node)
181
+ flush_line
182
+ when "dl"
183
+ ensure_blank_line
184
+ walk(node)
185
+ ensure_blank_line
186
+ when "dt"
187
+ flush_line if @col > 0
188
+ inline_walk(node, :b, 252)
189
+ flush_line
190
+ when "dd"
191
+ old_indent = @indent
192
+ @indent += 4
193
+ walk(node)
194
+ flush_line
195
+ @indent = old_indent
196
+ when "a"
197
+ href = node["href"]
198
+ text = collect_text(node)
199
+ has_img = node.at_css("img")
200
+ return if text.strip.empty? && !has_img
201
+
202
+ if href && !href.start_with?("#", "javascript:")
203
+ href = resolve_url(href)
204
+ link_index = @links.length
205
+ link_line = @output.length
206
+ @links << { index: link_index, href: href, text: text.strip, line: link_line }
207
+ if has_img
208
+ # Walk children so <img> elements get processed
209
+ @in_link = link_index
210
+ walk(node)
211
+ @in_link = nil
212
+ else
213
+ @line << text.fg(81).u
214
+ @col += text.length
215
+ end
216
+ label = "[#{link_index}]"
217
+ @line << label.fg(39)
218
+ @col += label.length
219
+ else
220
+ if has_img
221
+ walk(node)
222
+ else
223
+ @line << text.fg(81)
224
+ @col += text.length
225
+ end
226
+ end
227
+ when "strong", "b"
228
+ inline_walk(node, :b)
229
+ when "em", "i"
230
+ inline_walk(node, :i)
231
+ when "u"
232
+ inline_walk(node, :u)
233
+ when "s", "strike", "del"
234
+ text = collect_text(node)
235
+ @line << text.fg(240)
236
+ @col += text.length
237
+ when "img"
238
+ alt = node["alt"] || "image"
239
+ src = node["src"] || node["data-src"] || ""
240
+ src = src.strip
241
+ if !src.empty?
242
+ src = resolve_url(src)
243
+ flush_line if @col > 0
244
+ line_num = @output.length
245
+ @images << { src: src, alt: alt, line: line_num, height: IMG_RESERVE }
246
+ @output << "[image]".fg(236)
247
+ (IMG_RESERVE - 1).times { @output << "" }
248
+ end
249
+ when "iframe"
250
+ src = node["src"] || ""
251
+ src = resolve_url(src) unless src.empty?
252
+ if src.match?(%r{youtube\.com/embed/|youtube-nocookie\.com/embed/})
253
+ video_id = src[%r{/embed/([^?&/]+)}, 1]
254
+ if video_id
255
+ ensure_blank_line
256
+ # Add YouTube thumbnail as image
257
+ thumb_url = "https://img.youtube.com/vi/#{video_id}/hqdefault.jpg"
258
+ flush_line if @col > 0
259
+ line_num = @output.length
260
+ @images << { src: thumb_url, alt: "YouTube video", line: line_num, height: IMG_RESERVE }
261
+ @output << "[YouTube video]".fg(236)
262
+ (IMG_RESERVE - 1).times { @output << "" }
263
+ # Add link to video
264
+ video_url = "https://www.youtube.com/watch?v=#{video_id}"
265
+ link_index = @links.length
266
+ link_line = @output.length
267
+ @links << { index: link_index, href: video_url, text: "Watch on YouTube", line: link_line }
268
+ @line << "\u25b6 Watch on YouTube".fg(196).b + "[#{link_index}]".fg(39)
269
+ @col += 19 + "[#{link_index}]".length
270
+ flush_line
271
+ ensure_blank_line
272
+ end
273
+ elsif !src.empty?
274
+ ensure_blank_line
275
+ link_index = @links.length
276
+ link_line = @output.length
277
+ @links << { index: link_index, href: src, text: "Embedded content", line: link_line }
278
+ @line << "[Embedded: #{src[0..50]}]".fg(245) + "[#{link_index}]".fg(39)
279
+ @col += 63 + "[#{link_index}]".length
280
+ flush_line
281
+ ensure_blank_line
282
+ end
283
+ when "table"
284
+ render_table(node)
285
+ when "form"
286
+ ensure_blank_line
287
+ action = node["action"] || ""
288
+ action = resolve_url(action) unless action.empty?
289
+ method = (node["method"] || "get").downcase
290
+ @current_form = { action: action, method: method, fields: [], line: @output.length }
291
+ @line << "[Form]".fg(208).b
292
+ flush_line
293
+ walk(node)
294
+ # Check if form has password field
295
+ has_pw = @current_form[:fields].any? { |f| f[:type] == "password" }
296
+ @current_form[:has_password] = has_pw
297
+ @forms << @current_form
298
+ @current_form = nil
299
+ ensure_blank_line
300
+ when "input"
301
+ type = node["type"] || "text"
302
+ name = node["name"] || ""
303
+ value = node["value"] || ""
304
+ case type
305
+ when "submit", "button"
306
+ label = value.empty? ? "Submit" : value
307
+ @line << " [#{label}] ".fg(0).bg(252)
308
+ @col += label.length + 4
309
+ @current_form[:fields] << { type: "submit", name: name, value: value } if @current_form
310
+ when "hidden"
311
+ @current_form[:fields] << { type: "hidden", name: name, value: value } if @current_form
312
+ else
313
+ placeholder = node["placeholder"] || name
314
+ display_type = type == "password" ? "\u2022" : ""
315
+ label = "#{placeholder}#{display_type}"
316
+ field = "[#{label}: ________]".fg(252)
317
+ @line << field
318
+ @col += label.length + 14
319
+ @current_form[:fields] << { type: type, name: name, value: value, placeholder: placeholder } if @current_form
320
+ end
321
+ when "select"
322
+ name = node["name"] || "select"
323
+ @line << "[#{name} v]".fg(252)
324
+ @col += name.length + 4
325
+ options = node.css("option").map { |o| { value: o["value"] || o.text, text: o.text.strip } }
326
+ selected = node.at_css("option[selected]")
327
+ val = selected ? (selected["value"] || selected.text) : options.first&.dig(:value)
328
+ @current_form[:fields] << { type: "select", name: name, value: val.to_s, options: options } if @current_form
329
+ when "textarea"
330
+ name = node["name"] || "text"
331
+ @line << "[#{name}: ________]".fg(252)
332
+ @col += name.length + 14
333
+ @current_form[:fields] << { type: "textarea", name: name, value: node.text } if @current_form
334
+ when "label"
335
+ walk(node)
336
+ when "span"
337
+ walk(node)
338
+ else
339
+ walk(node)
340
+ end
341
+ end
342
+
343
+ def inline_walk(node, style = nil, color = nil)
344
+ text = collect_text(node)
345
+ styled = text
346
+ styled = styled.send(style) if style
347
+ styled = styled.fg(color) if color
348
+ @line << styled
349
+ @col += text.length
350
+ end
351
+
352
+ def collect_text(node)
353
+ node.text.gsub(/\s+/, " ").strip
354
+ end
355
+
356
+ def flush_line
357
+ return if @line.empty? && @col == 0
358
+ prefix = " " * @indent
359
+ if @pre
360
+ prefix += " "
361
+ end
362
+ @output << prefix + @line
363
+ @line = ""
364
+ @col = 0
365
+ end
366
+
367
+ def ensure_blank_line
368
+ flush_line if @col > 0
369
+ @output << "" unless @output.empty? || @output.last == ""
370
+ end
371
+
372
+ def apply_style(text)
373
+ text
374
+ end
375
+
376
+ def resolve_url(href)
377
+ return "https:#{href}" if href.start_with?("//")
378
+ return href if href.match?(%r{^https?://})
379
+ return href unless @base_url
380
+ begin
381
+ URI.join(@base_url, href).to_s
382
+ rescue
383
+ href
384
+ end
385
+ end
386
+
387
+ def render_table(table_node)
388
+ ensure_blank_line
389
+ rows = []
390
+
391
+ table_node.css("tr").each do |tr|
392
+ cells = tr.css("th, td").map { |cell| collect_text(cell) }
393
+ rows << cells
394
+ end
395
+
396
+ return if rows.empty?
397
+
398
+ max_cols = rows.map(&:length).max
399
+ rows.each { |r| r.fill("", r.length...max_cols) }
400
+
401
+ # For very wide tables (too many columns), use vertical layout
402
+ available = @width - @indent
403
+ if max_cols > (available / 8)
404
+ render_table_vertical(rows, table_node)
405
+ return
406
+ end
407
+
408
+ col_widths = Array.new(max_cols, 0)
409
+ rows.each do |row|
410
+ row.each_with_index do |cell, i|
411
+ col_widths[i] = [col_widths[i], cell.length].max
412
+ end
413
+ end
414
+
415
+ space = available - (max_cols - 1) * 3
416
+ total = col_widths.sum
417
+ if total > space && total > 0
418
+ col_widths = col_widths.map { |w| [(w.to_f / total * space).floor, 6].max }
419
+ end
420
+
421
+ rows.each_with_index do |row, ri|
422
+ parts = row.each_with_index.map do |cell, ci|
423
+ w = col_widths[ci] || cell.length
424
+ cell.length > w ? cell[0...w] : cell.ljust(w)
425
+ end
426
+ line = parts.join(" \u2502 ".fg(240))
427
+ line = line.b if ri == 0 && table_node.at_css("th")
428
+ @output << (" " * @indent) + line
429
+
430
+ if ri == 0 && table_node.at_css("th")
431
+ sep = col_widths.map { |w| "\u2500" * w }.join("\u2500\u253c\u2500").fg(240)
432
+ @output << (" " * @indent) + sep
433
+ end
434
+ end
435
+
436
+ ensure_blank_line
437
+ end
438
+
439
+ def render_table_vertical(rows, table_node)
440
+ # Render each row as a block with label: value pairs
441
+ headers = table_node.css("th").any? ? rows.shift : nil
442
+
443
+ rows.each do |row|
444
+ row.each_with_index do |cell, ci|
445
+ next if cell.strip.empty?
446
+ label = headers && headers[ci] ? headers[ci] : "Col #{ci + 1}"
447
+ @output << (" " * @indent) + "#{label}: ".fg(245).b + cell
448
+ end
449
+ @output << (" " * @indent) + ("\u2500" * 20).fg(240)
450
+ end
451
+ end
452
+ end
453
+ end
@@ -0,0 +1,48 @@
1
+ module Brrowser
2
+ class Tab
3
+ attr_accessor :url, :title, :content, :ix, :links, :forms, :images
4
+ attr_reader :back_history, :forward_history
5
+
6
+ def initialize(url = nil)
7
+ @url = url
8
+ @title = ""
9
+ @content = ""
10
+ @ix = 0
11
+ @links = []
12
+ @forms = []
13
+ @images = []
14
+ @back_history = []
15
+ @forward_history = []
16
+ end
17
+
18
+ def navigate(new_url)
19
+ if @url
20
+ @back_history.push({ url: @url, ix: @ix })
21
+ end
22
+ @forward_history.clear
23
+ @url = new_url
24
+ @ix = 0
25
+ end
26
+
27
+ def go_back
28
+ return nil if @back_history.empty?
29
+ @forward_history.push({ url: @url, ix: @ix })
30
+ prev = @back_history.pop
31
+ @url = prev[:url]
32
+ @ix = prev[:ix]
33
+ @url
34
+ end
35
+
36
+ def go_forward
37
+ return nil if @forward_history.empty?
38
+ @back_history.push({ url: @url, ix: @ix })
39
+ nxt = @forward_history.pop
40
+ @url = nxt[:url]
41
+ @ix = nxt[:ix]
42
+ @url
43
+ end
44
+
45
+ def can_go_back? = !@back_history.empty?
46
+ def can_go_forward? = !@forward_history.empty?
47
+ end
48
+ end