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/bin/brrowser ADDED
@@ -0,0 +1,1715 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+ # brrowser - Terminal web browser with vim-style keybindings
4
+ # License: Unlicense
5
+
6
+ require 'rcurses'
7
+ require 'io/wait'
8
+ require 'termpix'
9
+ require 'tmpdir'
10
+ require 'digest'
11
+ require 'fileutils'
12
+ require 'yaml'
13
+ require 'shellwords'
14
+ require 'set'
15
+ require 'json'
16
+
17
+ require_relative '../lib/brrowser/tab'
18
+ require_relative '../lib/brrowser/fetcher'
19
+ require_relative '../lib/brrowser/renderer'
20
+
21
+ class Object
22
+ include Rcurses
23
+ include Rcurses::Input
24
+ end
25
+
26
+ # brrowser {{{1
27
+ module Brrowser
28
+ VERSION = "0.1.0"
29
+ HOMEPAGE = "about:blank"
30
+ CONFIG_DIR = File.join(Dir.home, ".brrowser")
31
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
32
+
33
+ DEFAULTS = {
34
+ "show_images" => true,
35
+ "image_mode" => "auto", # auto, termpix, ascii, off
36
+ "homepage" => "about:blank",
37
+ "search_engine" => "g",
38
+ "info_fg" => 252,
39
+ "info_bg" => 236,
40
+ "tab_fg" => 252,
41
+ "tab_bg" => 234,
42
+ "tab_active" => 220,
43
+ "content_fg" => 255,
44
+ "content_bg" => 0,
45
+ "cmd_fg" => 252,
46
+ "cmd_bg" => 234,
47
+ "link_color" => 81,
48
+ "link_num" => 39,
49
+ "heading_h1" => 220,
50
+ "heading_h2" => 214,
51
+ "heading_h3" => 208,
52
+ }
53
+
54
+ HELP_HTML = <<~HTML
55
+ <html><body>
56
+ <h1>brrowser help</h1>
57
+ <h2>Navigation</h2>
58
+ <table>
59
+ <tr><th>Key</th><th>Action</th></tr>
60
+ <tr><td>j / Down</td><td>Scroll down</td></tr>
61
+ <tr><td>k / Up</td><td>Scroll up</td></tr>
62
+ <tr><td>&lt; / h</td><td>Scroll left</td></tr>
63
+ <tr><td>&gt; / l</td><td>Scroll right</td></tr>
64
+ <tr><td>Left</td><td>Previous tab</td></tr>
65
+ <tr><td>Right</td><td>Next tab</td></tr>
66
+ <tr><td>gg</td><td>Go to top</td></tr>
67
+ <tr><td>G</td><td>Go to bottom</td></tr>
68
+ <tr><td>Ctrl-d</td><td>Half page down</td></tr>
69
+ <tr><td>Ctrl-u</td><td>Half page up</td></tr>
70
+ <tr><td>PgDn / Space</td><td>Page down</td></tr>
71
+ <tr><td>PgUp</td><td>Page up</td></tr>
72
+ </table>
73
+ <h2>Browsing</h2>
74
+ <table>
75
+ <tr><th>Key</th><th>Action</th></tr>
76
+ <tr><td>o</td><td>Open URL</td></tr>
77
+ <tr><td>O</td><td>Open URL (pre-filled with current)</td></tr>
78
+ <tr><td>t</td><td>Open URL in new tab</td></tr>
79
+ <tr><td>Tab</td><td>Jump to next link/field</td></tr>
80
+ <tr><td>Shift-Tab</td><td>Jump to previous link/field</td></tr>
81
+ <tr><td>Enter</td><td>Follow link or edit field</td></tr>
82
+ <tr><td>y</td><td>Copy page URL to clipboard</td></tr>
83
+ <tr><td>Y</td><td>Copy focused link URL or field value</td></tr>
84
+ <tr><td>e</td><td>Edit page source in $EDITOR</td></tr>
85
+ <tr><td>f</td><td>Fill and submit form</td></tr>
86
+ <tr><td>Ctrl-g</td><td>Edit focused field in $EDITOR</td></tr>
87
+ <tr><td>H / Backspace</td><td>Go back</td></tr>
88
+ <tr><td>L</td><td>Go forward</td></tr>
89
+ <tr><td>r</td><td>Reload page</td></tr>
90
+ </table>
91
+ <h2>Tabs</h2>
92
+ <table>
93
+ <tr><th>Key</th><th>Action</th></tr>
94
+ <tr><td>J / Right</td><td>Next tab</td></tr>
95
+ <tr><td>K / Left</td><td>Previous tab</td></tr>
96
+ <tr><td>d</td><td>Close tab</td></tr>
97
+ <tr><td>u</td><td>Undo close tab</td></tr>
98
+ </table>
99
+ <h2>Search</h2>
100
+ <table>
101
+ <tr><th>Key</th><th>Action</th></tr>
102
+ <tr><td>/</td><td>Search page</td></tr>
103
+ <tr><td>n</td><td>Next search match</td></tr>
104
+ <tr><td>N</td><td>Previous search match</td></tr>
105
+ </table>
106
+ <h2>Commands (: mode)</h2>
107
+ <table>
108
+ <tr><th>Command</th><th>Action</th></tr>
109
+ <tr><td>:open URL</td><td>Navigate to URL</td></tr>
110
+ <tr><td>:tabopen URL</td><td>Open URL in new tab</td></tr>
111
+ <tr><td>:close / :q</td><td>Close tab</td></tr>
112
+ <tr><td>:quit / :qa</td><td>Quit brrowser</td></tr>
113
+ <tr><td>:back</td><td>Go back</td></tr>
114
+ <tr><td>:forward</td><td>Go forward</td></tr>
115
+ <tr><td>:bookmark / :bm</td><td>Bookmark (or open by name)</td></tr>
116
+ <tr><td>:bookmarks / :bms</td><td>List bookmarks</td></tr>
117
+ <tr><td>:download / :dl</td><td>Download file</td></tr>
118
+ <tr><td>:adblock</td><td>Update ad blocklist</td></tr>
119
+ <tr><td>:password / :pw</td><td>Save password for site</td></tr>
120
+ </table>
121
+ <h2>Other</h2>
122
+ <table>
123
+ <tr><th>Key</th><th>Action</th></tr>
124
+ <tr><td>b</td><td>Bookmark current page</td></tr>
125
+ <tr><td>B</td><td>Show bookmarks</td></tr>
126
+ <tr><td>m + 0-9</td><td>Set quickmark</td></tr>
127
+ <tr><td>' + 0-9</td><td>Go to quickmark</td></tr>
128
+ <tr><td>i</td><td>Toggle images</td></tr>
129
+ <tr><td>p</td><td>Show stored password for site</td></tr>
130
+ <tr><td>I</td><td>AI summary of page</td></tr>
131
+ <tr><td>P</td><td>Preferences</td></tr>
132
+ <tr><td>?</td><td>This help page</td></tr>
133
+ <tr><td>q</td><td>Quit</td></tr>
134
+ </table>
135
+ </body></html>
136
+ HTML
137
+
138
+ SEARCH_ENGINES = {
139
+ "g" => "https://www.google.com/search?q=",
140
+ "ddg" => "https://duckduckgo.com/?q=",
141
+ "w" => "https://en.wikipedia.org/wiki/Special:Search?search=",
142
+ }
143
+
144
+ class Browser # {{{1
145
+ def initialize(url = nil)
146
+ @fetcher = Fetcher.new
147
+ @tabs = []
148
+ @current_tab = 0
149
+ @closed_tabs = []
150
+ @mode = :normal
151
+ @g_pressed = false
152
+ @search_term = nil
153
+ @search_matches = []
154
+ @search_index = 0
155
+ @status_msg = ""
156
+ @running = true
157
+ load_config
158
+ load_adblock
159
+ @termpix = Termpix::Display.new
160
+ @img_cache = {} # url => local path
161
+ @img_thread = nil
162
+ @img_dir = File.join(Dir.tmpdir, "brrowser_images_#{Process.pid}")
163
+ Dir.mkdir(@img_dir) unless Dir.exist?(@img_dir)
164
+
165
+ main_loop(url || HOMEPAGE)
166
+ end
167
+
168
+ # Pane layout {{{2
169
+ def setup_panes
170
+ @h, @w = IO.console.winsize
171
+
172
+ @pInfo = Pane.new(1, 1, @w, 1, 252, 236)
173
+ @pTab = Pane.new(1, 2, @w, 1, 252, 234)
174
+ @pMain = Pane.new(1, 3, @w, @h - 3, 255, 0)
175
+ @pCmd = Pane.new(1, @h, @w, 1, 252, 234)
176
+
177
+ @pMain.border = false
178
+ @pMain.scroll = true
179
+
180
+ @pInput = Pane.new(2, @h, @w - 1, 1, 255, 234)
181
+ @pInput.record = true
182
+
183
+ @pUrlInput = Pane.new(2, @h, @w - 1, 1, 255, 234)
184
+ @pUrlInput.record = true
185
+
186
+ Signal.trap("WINCH") { @winch = true }
187
+ end
188
+
189
+ def resize
190
+ @h, @w = IO.console.winsize
191
+ @pInfo.w = @w; @pInfo.h = 1; @pInfo.x = 1; @pInfo.y = 1
192
+ @pTab.w = @w; @pTab.h = 1; @pTab.x = 1; @pTab.y = 2
193
+ @pMain.w = @w; @pMain.h = @h - 3; @pMain.x = 1; @pMain.y = 3
194
+ @pCmd.w = @w; @pCmd.h = 1; @pCmd.x = 1; @pCmd.y = @h
195
+ @pInput.w = @w - 1; @pInput.x = 2; @pInput.y = @h
196
+
197
+ re_render
198
+ refresh_all
199
+ end
200
+
201
+ # Tab management {{{2
202
+ def current_tab
203
+ @tabs[@current_tab]
204
+ end
205
+
206
+ def new_tab(url)
207
+ tab = Tab.new
208
+ @tabs.insert(@current_tab + (@tabs.empty? ? 0 : 1), tab)
209
+ @current_tab = @tabs.index(tab)
210
+ navigate(url)
211
+ end
212
+
213
+ def close_tab
214
+ return if @tabs.length <= 1
215
+ @closed_tabs.push(@tabs[@current_tab])
216
+ @tabs.delete_at(@current_tab)
217
+ @current_tab = [@current_tab, @tabs.length - 1].min
218
+ @pMain.ix = current_tab.ix
219
+ refresh_all
220
+ end
221
+
222
+ def undo_close_tab
223
+ return if @closed_tabs.empty?
224
+ tab = @closed_tabs.pop
225
+ @tabs.insert(@current_tab + 1, tab)
226
+ @current_tab += 1
227
+ @pMain.ix = current_tab.ix
228
+ refresh_all
229
+ end
230
+
231
+ def next_tab
232
+ clear_images
233
+ @current_tab = (@current_tab + 1) % @tabs.length
234
+ @pMain.ix = current_tab.ix
235
+ refresh_all
236
+ end
237
+
238
+ def prev_tab
239
+ clear_images
240
+ @current_tab = (@current_tab - 1) % @tabs.length
241
+ @pMain.ix = current_tab.ix
242
+ refresh_all
243
+ end
244
+
245
+ # Navigation {{{2
246
+ def navigate(url)
247
+ url = resolve_search(url)
248
+ @status_msg = "Loading #{url}..."
249
+ refresh_cmd
250
+
251
+ current_tab.navigate(url)
252
+ result = @fetcher.fetch(url)
253
+ current_tab.url = result[:url]
254
+
255
+ render_page(result)
256
+ refresh_all
257
+ end
258
+
259
+ def render_page(result)
260
+ ct = result[:content_type].to_s
261
+
262
+ # Always clear old state
263
+ clear_images
264
+ @current_link = -1
265
+ @focus_index = -1
266
+ @focused_element = nil
267
+ @highlighted_line = nil
268
+
269
+ # Handle image URLs: display the image instead of raw binary
270
+ if ct.match?(/image\//) || result[:url].to_s.match?(/\.(png|jpe?g|gif|bmp|webp|svg|ico)(\?|$)/i)
271
+ render_image_page(result)
272
+ return
273
+ end
274
+
275
+ # Handle other binary content: prompt open or download
276
+ if ct.match?(/octet-stream|application\/pdf|application\/zip|audio\/|video\//) ||
277
+ result[:url].to_s.match?(/\.(pdf|zip|tar|gz|mp[34]|wav|avi|mov|mkv)(\?|$)/i)
278
+ filename = File.basename(URI.parse(result[:url]).path) rescue "file"
279
+ # Save to temp first
280
+ tmppath = File.join(@img_dir, filename)
281
+ File.binwrite(tmppath, result[:body])
282
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
283
+ choice = @pInput.ask("#{filename} (#{result[:body]&.length} bytes) - [o]pen / [d]ownload / [c]ancel: ", "o")
284
+ case choice&.strip&.downcase
285
+ when "o"
286
+ system("xdg-open #{Shellwords.escape(tmppath)} &")
287
+ @status_msg = "Opened #{filename}"
288
+ when "d"
289
+ download_file(result[:url])
290
+ else
291
+ @status_msg = "Cancelled"
292
+ end
293
+ refresh_cmd
294
+ go_back
295
+ return
296
+ end
297
+
298
+ body = result[:body].to_s.dup.force_encoding("UTF-8").scrub("")
299
+ result[:body] = body
300
+ if ct.include?("text/html") || body.match?(/<html/i)
301
+ renderer = Renderer.new(@w - 2)
302
+ page = renderer.render(result[:body], result[:url])
303
+ current_tab.title = page[:title]
304
+ current_tab.content = page[:text]
305
+ current_tab.links = page[:links]
306
+ current_tab.images = page[:images]
307
+ current_tab.forms = page[:forms]
308
+ check_autofill
309
+ else
310
+ current_tab.title = result[:url]
311
+ current_tab.content = body
312
+ current_tab.links = []
313
+ current_tab.images = []
314
+ current_tab.forms = []
315
+ end
316
+ current_tab.ix = 0
317
+ @pMain.ix = 0
318
+ @status_msg = ""
319
+ @last_img_count = 0
320
+ start_image_downloads
321
+ end
322
+
323
+ def render_image_page(result)
324
+ # Save the image to temp and display it
325
+ filename = File.basename(URI.parse(result[:url]).path) rescue "image"
326
+ ext = File.extname(filename)
327
+ ext = ".png" if ext.empty?
328
+ local = File.join(@img_dir, "#{Digest::MD5.hexdigest(result[:url])}#{ext}")
329
+ File.binwrite(local, result[:body])
330
+ @img_cache[result[:url]] = local
331
+
332
+ # Use most of the screen for the image
333
+ img_h = @pMain.h - 4
334
+ current_tab.title = filename
335
+ lines = ["#{filename} #{result[:body].length} bytes".fg(245)]
336
+ lines += Array.new(img_h, "")
337
+ current_tab.content = lines.join("\n")
338
+ current_tab.links = []
339
+ current_tab.images = [{ src: result[:url], alt: filename, line: 1, height: img_h }]
340
+ current_tab.forms = []
341
+ current_tab.ix = 0
342
+ @pMain.ix = 0
343
+ @status_msg = ""
344
+ @last_img_count = 0
345
+ start_image_downloads
346
+ end
347
+
348
+ def re_render
349
+ return unless current_tab.url && current_tab.url != HOMEPAGE
350
+ result = @fetcher.fetch(current_tab.url)
351
+ render_page(result)
352
+ end
353
+
354
+ def go_back
355
+ url = current_tab.go_back
356
+ if url
357
+ result = @fetcher.fetch(url)
358
+ render_page(result)
359
+ @pMain.ix = current_tab.ix
360
+ refresh_all
361
+ else
362
+ @status_msg = "No back history"
363
+ refresh_cmd
364
+ end
365
+ end
366
+
367
+ def go_forward
368
+ url = current_tab.go_forward
369
+ if url
370
+ result = @fetcher.fetch(url)
371
+ render_page(result)
372
+ @pMain.ix = current_tab.ix
373
+ refresh_all
374
+ else
375
+ @status_msg = "No forward history"
376
+ refresh_cmd
377
+ end
378
+ end
379
+
380
+ def reload
381
+ return unless current_tab.url
382
+ navigate_no_history(current_tab.url)
383
+ end
384
+
385
+ def navigate_no_history(url)
386
+ @status_msg = "Loading #{url}..."
387
+ refresh_cmd
388
+ result = @fetcher.fetch(url)
389
+ current_tab.url = result[:url]
390
+ render_page(result)
391
+ refresh_all
392
+ end
393
+
394
+ def resolve_search(input)
395
+ return input if input.match?(%r{^https?://}) || input.match?(%r{^file://})
396
+ return input if input.include?(".") && !input.include?(" ")
397
+
398
+ parts = input.split(" ", 2)
399
+ if parts.length == 2 && SEARCH_ENGINES[parts[0]]
400
+ SEARCH_ENGINES[parts[0]] + URI.encode_www_form_component(parts[1])
401
+ elsif parts.length == 1 && !input.include?(" ")
402
+ "https://#{input}"
403
+ else
404
+ SEARCH_ENGINES["g"] + URI.encode_www_form_component(input)
405
+ end
406
+ end
407
+
408
+ # Rendering {{{2
409
+ def refresh_all
410
+ refresh_info
411
+ refresh_tabs
412
+ refresh_main
413
+ refresh_cmd
414
+ end
415
+
416
+ def refresh_info
417
+ tab = current_tab
418
+ mode_str = @mode == :normal ? "" : " [#{@mode.upcase}]"
419
+ back_indicator = tab.can_go_back? ? "\u25c0 " : ""
420
+ fwd_indicator = tab.can_go_forward? ? " \u25b6" : ""
421
+ title = tab.title.to_s
422
+ url = tab.url.to_s
423
+ info = " #{back_indicator}#{url}#{fwd_indicator} #{title}#{mode_str}"
424
+ info = info[0...@w] if info.length > @w
425
+ @pInfo.text = info
426
+ @pInfo.refresh
427
+ end
428
+
429
+ def refresh_tabs
430
+ parts = @tabs.each_with_index.map do |tab, i|
431
+ label = tab.title.to_s.empty? ? "New Tab" : tab.title.to_s
432
+ label = label[0..20] + "..." if label.length > 23
433
+ if i == @current_tab
434
+ " #{label} ".fg(220).b
435
+ else
436
+ " #{label} ".fg(245)
437
+ end
438
+ end
439
+ @pTab.text = parts.join("\u2502".fg(240))
440
+ @pTab.refresh
441
+ end
442
+
443
+ def refresh_main
444
+ @pMain.text = current_tab.content.to_s
445
+ @pMain.refresh
446
+ end
447
+
448
+ def refresh_cmd
449
+ if @status_msg.empty?
450
+ link_count = current_tab.links.length
451
+ info = " #{link_count} links | ? help | : command"
452
+ @pCmd.text = info.fg(245)
453
+ else
454
+ @pCmd.text = " " + @status_msg.fg(214)
455
+ end
456
+ @pCmd.refresh
457
+ end
458
+
459
+ # Scrolling {{{2
460
+ def scroll_down
461
+ had_images = @showing_image
462
+ clear_images if @showing_image
463
+ @pMain.linedown
464
+ current_tab.ix = @pMain.ix
465
+ show_visible_image if had_images
466
+ end
467
+
468
+ def scroll_up
469
+ had_images = @showing_image
470
+ clear_images if @showing_image
471
+ @pMain.lineup
472
+ current_tab.ix = @pMain.ix
473
+ show_visible_image if had_images
474
+ end
475
+
476
+ def page_down
477
+ clear_images if @showing_image
478
+ @pMain.pagedown
479
+ current_tab.ix = @pMain.ix
480
+ show_visible_image
481
+ end
482
+
483
+ def page_up
484
+ clear_images if @showing_image
485
+ @pMain.pageup
486
+ current_tab.ix = @pMain.ix
487
+ show_visible_image
488
+ end
489
+
490
+ def half_page_down
491
+ clear_images if @showing_image
492
+ half = (@h - 3) / 2
493
+ half.times { @pMain.linedown }
494
+ current_tab.ix = @pMain.ix
495
+ show_visible_image
496
+ end
497
+
498
+ def half_page_up
499
+ clear_images if @showing_image
500
+ half = (@h - 3) / 2
501
+ half.times { @pMain.lineup }
502
+ current_tab.ix = @pMain.ix
503
+ show_visible_image
504
+ end
505
+
506
+ def go_top
507
+ clear_images if @showing_image
508
+ @pMain.ix = 0
509
+ current_tab.ix = 0
510
+ refresh_main
511
+ show_visible_image
512
+ end
513
+
514
+ def go_bottom
515
+ clear_images if @showing_image
516
+ @pMain.bottom
517
+ current_tab.ix = @pMain.ix
518
+ show_visible_image
519
+ end
520
+
521
+ def redraw_screen
522
+ clear_images
523
+ @pInfo.full_refresh
524
+ @pTab.full_refresh
525
+ @pMain.full_refresh
526
+ @pCmd.full_refresh
527
+ show_visible_image
528
+ end
529
+
530
+ # Images {{{2
531
+ # Images display in the reserved blank lines from the renderer.
532
+ # Only one image shown at a time to avoid kitty protocol conflicts.
533
+
534
+ def start_image_downloads
535
+ return unless @termpix.supported?
536
+ images = current_tab.images
537
+ return if images.empty?
538
+
539
+ @img_thread = Thread.new do
540
+ images.each do |img|
541
+ break unless @running
542
+ next if @img_cache[img[:src]]
543
+ src = img[:src]
544
+ src = "https:#{src}" if src.start_with?("//")
545
+ next unless src.match?(%r{^https?://})
546
+ next if blocked?(src)
547
+ begin
548
+ uri = URI.parse(src)
549
+ ext = File.extname(uri.path)[0..5]
550
+ ext = ".png" if ext.empty?
551
+ local = File.join(@img_dir, "#{Digest::MD5.hexdigest(src)}#{ext}")
552
+ unless File.exist?(local)
553
+ response = @fetcher.fetch(src)
554
+ next if response[:status] != 200
555
+ ct = response[:content_type].to_s
556
+ next unless ct.match?(/image/) || src.match?(/\.(png|jpe?g|gif|bmp|webp|svg|ico)/i)
557
+ File.binwrite(local, response[:body])
558
+ if src.match?(/\.svg/i) || ct.include?("svg")
559
+ png_path = local.sub(/\.[^.]+$/, ".png")
560
+ system("convert", local, png_path, [:out, :err] => "/dev/null")
561
+ local = png_path if File.exist?(png_path)
562
+ end
563
+ end
564
+ @img_cache[src] = local
565
+ rescue
566
+ # skip failed downloads
567
+ end
568
+ end
569
+ end
570
+ end
571
+
572
+ def image_mode
573
+ mode = @conf["image_mode"]
574
+ return :off unless @conf["show_images"]
575
+ case mode
576
+ when "termpix" then @termpix.supported? ? :termpix : :off
577
+ when "ascii" then :ascii
578
+ when "off" then :off
579
+ else # auto
580
+ @termpix.supported? ? :termpix : (chafa_available? ? :ascii : :off)
581
+ end
582
+ end
583
+
584
+ def chafa_available?
585
+ @chafa_available = system("which chafa > /dev/null 2>&1") if @chafa_available.nil?
586
+ @chafa_available
587
+ end
588
+
589
+ def show_visible_image
590
+ mode = image_mode
591
+ return if mode == :off
592
+ images = current_tab.images
593
+ return if images.empty?
594
+
595
+ viewport_top = @pMain.ix
596
+ viewport_bottom = viewport_top + @pMain.h - 1
597
+ reserve = Brrowser::Renderer::IMG_RESERVE
598
+
599
+ images.each do |img|
600
+ line = img[:line] || 0
601
+ img_bottom = line + reserve - 1
602
+ next unless img_bottom >= viewport_top && line <= viewport_bottom
603
+ local = @img_cache[img[:src]]
604
+ next unless local && File.exist?(local)
605
+
606
+ top_in_view = [line, viewport_top].max
607
+ bot_in_view = [img_bottom, viewport_bottom].min
608
+ screen_y = @pMain.y + (top_in_view - viewport_top)
609
+ visible_h = bot_in_view - top_in_view + 1
610
+ next if visible_h < 3
611
+
612
+ # Full width for standalone image pages, half for inline
613
+ standalone = current_tab.images.length == 1 && current_tab.links.empty?
614
+ mw = standalone ? @pMain.w - 2 : [@pMain.w / 2, 60].min
615
+
616
+ if mode == :termpix
617
+ @termpix.show(local,
618
+ x: @pMain.x,
619
+ y: screen_y,
620
+ max_width: mw,
621
+ max_height: visible_h)
622
+ elsif mode == :ascii
623
+ show_ascii_image(local, screen_y, visible_h, mw)
624
+ end
625
+ @showing_image = true
626
+ end
627
+ end
628
+
629
+ def show_ascii_image(path, screen_y, max_h, cols = nil)
630
+ cols ||= [@pMain.w / 2, 60].min
631
+ art = `chafa -s #{cols}x#{max_h} --format=symbols #{Shellwords.escape(path)} 2>/dev/null`
632
+ return if art.empty?
633
+ lines = art.split("\n")
634
+ lines.each_with_index do |line, i|
635
+ break if i >= max_h
636
+ print "\e[#{screen_y + i};#{@pMain.x}H#{line}"
637
+ end
638
+ $stdout.flush
639
+ end
640
+
641
+ def clear_images
642
+ return unless @showing_image
643
+ if @termpix.supported?
644
+ @termpix.clear(
645
+ x: @pMain.x,
646
+ y: @pMain.y,
647
+ width: @pMain.w,
648
+ height: @pMain.h,
649
+ term_width: @w,
650
+ term_height: @h)
651
+ end
652
+ # For ASCII art, a full_refresh redraws text over the art
653
+ @pMain.full_refresh if image_mode == :ascii
654
+ @showing_image = false
655
+ end
656
+
657
+ # Search {{{2
658
+ def search_page
659
+ @pInput.x = 2
660
+ @pInput.w = @w - 1
661
+ @pInput.y = @h
662
+ term = @pInput.ask("/", "")
663
+ if term.nil? || term.strip.empty?
664
+ refresh_cmd
665
+ return
666
+ end
667
+ @search_term = term.strip
668
+ find_matches
669
+ search_next
670
+ refresh_cmd
671
+ end
672
+
673
+ def find_matches
674
+ @search_matches = []
675
+ return unless @search_term
676
+ lines = current_tab.content.to_s.split("\n")
677
+ lines.each_with_index do |line, i|
678
+ clean = line.gsub(/\e\[[0-9;]*m/, "")
679
+ if clean.downcase.include?(@search_term.downcase)
680
+ @search_matches << i
681
+ end
682
+ end
683
+ @search_index = 0
684
+ end
685
+
686
+ def search_next
687
+ return unless @search_matches.any?
688
+ @search_index = (@search_index + 1) % @search_matches.length if @pMain.ix >= @search_matches[@search_index]
689
+ @pMain.ix = @search_matches[@search_index]
690
+ current_tab.ix = @pMain.ix
691
+ @status_msg = "Match #{@search_index + 1}/#{@search_matches.length}"
692
+ refresh_main
693
+ refresh_cmd
694
+ end
695
+
696
+ def search_prev
697
+ return unless @search_matches.any?
698
+ @search_index = (@search_index - 1) % @search_matches.length
699
+ @pMain.ix = @search_matches[@search_index]
700
+ current_tab.ix = @pMain.ix
701
+ @status_msg = "Match #{@search_index + 1}/#{@search_matches.length}"
702
+ refresh_main
703
+ refresh_cmd
704
+ end
705
+
706
+ # Link/field navigation {{{2
707
+ # TAB cycles through links and form fields together, sorted by line.
708
+ @focus_index = -1
709
+
710
+ def focusable_elements
711
+ elements = []
712
+ (current_tab.links || []).each do |l|
713
+ next unless l[:line]
714
+ elements << { type: :link, data: l, line: l[:line] }
715
+ end
716
+ (current_tab.forms || []).each do |form|
717
+ (form[:fields] || []).each do |field|
718
+ next if field[:type] == "hidden" || field[:type] == "submit"
719
+ next unless field[:line] || form[:line]
720
+ elements << { type: :field, data: field, form: form, line: field[:line] || form[:line] }
721
+ end
722
+ end
723
+ elements.sort_by { |e| e[:line] }
724
+ end
725
+
726
+ def next_link
727
+ elems = focusable_elements
728
+ return if elems.empty?
729
+ if @focus_index < 0
730
+ # First TAB: jump to first visible element
731
+ vt = @pMain.ix
732
+ @focus_index = elems.index { |e| e[:line] >= vt } || 0
733
+ else
734
+ @focus_index = (@focus_index + 1) % elems.length
735
+ end
736
+ jump_to_element(elems[@focus_index])
737
+ end
738
+
739
+ def prev_link
740
+ elems = focusable_elements
741
+ return if elems.empty?
742
+ if @focus_index < 0
743
+ # First S-TAB: jump to last visible element
744
+ vb = @pMain.ix + @pMain.h - 1
745
+ @focus_index = elems.rindex { |e| e[:line] <= vb } || elems.length - 1
746
+ else
747
+ @focus_index = (@focus_index - 1) % elems.length
748
+ end
749
+ jump_to_element(elems[@focus_index])
750
+ end
751
+
752
+ def jump_to_element(elem)
753
+ lines = current_tab.content.split("\n")
754
+ # Remove old highlight
755
+ if @highlighted_line && @highlighted_line < lines.length
756
+ lines[@highlighted_line] = lines[@highlighted_line]
757
+ .gsub("\e[7m", "").gsub("\e[27m", "")
758
+ end
759
+ # Highlight current element line
760
+ if elem[:line] < lines.length
761
+ lines[elem[:line]] = "\e[7m#{lines[elem[:line]]}\e[27m"
762
+ @highlighted_line = elem[:line]
763
+ end
764
+ current_tab.content = lines.join("\n")
765
+ @pMain.ix = [elem[:line] - 3, 0].max
766
+ current_tab.ix = @pMain.ix
767
+ @focused_element = elem
768
+
769
+ if elem[:type] == :link
770
+ l = elem[:data]
771
+ @current_link = l[:index]
772
+ @status_msg = "[#{l[:index]}] #{l[:text][0..60]} -> #{l[:href][0..60]}"
773
+ else
774
+ f = elem[:data]
775
+ val = f[:value].to_s
776
+ @current_link = -1
777
+ @status_msg = "[#{f[:type]}] #{f[:placeholder] || f[:name]}: #{val[0..40]}"
778
+ end
779
+ refresh_main
780
+ refresh_cmd
781
+ end
782
+
783
+ def follow_link_prompt
784
+ # If focused on a link, follow it
785
+ if @focused_element && @focused_element[:type] == :link
786
+ navigate(@focused_element[:data][:href])
787
+ return
788
+ end
789
+ # If focused on a form field, edit it
790
+ if @focused_element && @focused_element[:type] == :field
791
+ edit_focused_field
792
+ return
793
+ end
794
+ return if current_tab.links.empty?
795
+ @pInput.x = 2
796
+ @pInput.w = @w - 1
797
+ @pInput.y = @h
798
+ num = @pInput.ask("Link #: ", "")
799
+ if num.nil? || num.strip.empty?
800
+ refresh_cmd
801
+ return
802
+ end
803
+ idx = num.strip.to_i
804
+ link = current_tab.links.find { |l| l[:index] == idx }
805
+ if link
806
+ navigate(link[:href])
807
+ else
808
+ @status_msg = "No link ##{idx}"
809
+ refresh_cmd
810
+ end
811
+ end
812
+
813
+ def edit_focused_field
814
+ return unless @focused_element && @focused_element[:type] == :field
815
+ field = @focused_element[:data]
816
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
817
+ val = @pInput.ask("#{field[:placeholder] || field[:name]}: ", field[:value].to_s)
818
+ if val && !val.strip.empty?
819
+ field[:value] = val.strip
820
+ @status_msg = "Set #{field[:name]} = #{val.strip}"
821
+ end
822
+ refresh_cmd
823
+ end
824
+
825
+ def edit_field_in_editor
826
+ return unless @focused_element && @focused_element[:type] == :field
827
+ field = @focused_element[:data]
828
+ tmpfile = File.join(Dir.tmpdir, "brrowser_field_#{Process.pid}.txt")
829
+ File.write(tmpfile, field[:value].to_s)
830
+ editor = ENV["EDITOR"] || "vim"
831
+ Rcurses.cleanup!
832
+ system("#{editor} #{Shellwords.escape(tmpfile)}")
833
+ Rcurses.init!
834
+ if File.exist?(tmpfile)
835
+ field[:value] = File.read(tmpfile).strip
836
+ File.delete(tmpfile)
837
+ @status_msg = "Set #{field[:name]} from editor"
838
+ end
839
+ refresh_all
840
+ end
841
+
842
+ # Yank/copy {{{2
843
+ def yank_url
844
+ url = current_tab.url.to_s
845
+ copy_to_clipboard(url)
846
+ @status_msg = "Copied: #{url}"
847
+ refresh_cmd
848
+ end
849
+
850
+ def yank_element
851
+ if @focused_element
852
+ if @focused_element[:type] == :link
853
+ text = @focused_element[:data][:href]
854
+ else
855
+ text = @focused_element[:data][:value].to_s
856
+ end
857
+ else
858
+ text = current_tab.url.to_s
859
+ end
860
+ copy_to_clipboard(text)
861
+ @status_msg = "Copied: #{text[0..60]}"
862
+ refresh_cmd
863
+ end
864
+
865
+ def copy_to_clipboard(text)
866
+ # Try xclip, xsel, wl-copy in order
867
+ %w[xclip xsel wl-copy].each do |cmd|
868
+ if system("which #{cmd} > /dev/null 2>&1")
869
+ case cmd
870
+ when "xclip"
871
+ IO.popen("xclip -selection clipboard", "w") { |io| io.write(text) }
872
+ when "xsel"
873
+ IO.popen("xsel --clipboard --input", "w") { |io| io.write(text) }
874
+ when "wl-copy"
875
+ IO.popen("wl-copy", "w") { |io| io.write(text) }
876
+ end
877
+ return
878
+ end
879
+ end
880
+ end
881
+
882
+ def new_tab_with(url)
883
+ tab = Tab.new
884
+ @tabs.insert(@current_tab + 1, tab)
885
+ @current_tab += 1
886
+ navigate(url)
887
+ end
888
+
889
+ # Open URL {{{2
890
+ def open_url(prefill = "")
891
+ @pUrlInput.x = 2
892
+ @pUrlInput.w = @w - 1
893
+ @pUrlInput.y = @h
894
+ url = @pUrlInput.ask(":open ", prefill)
895
+ if url.nil? || url.strip.empty?
896
+ refresh_cmd
897
+ return
898
+ end
899
+ navigate(url.strip)
900
+ end
901
+
902
+ def tabopen_url
903
+ @pUrlInput.x = 2
904
+ @pUrlInput.w = @w - 1
905
+ @pUrlInput.y = @h
906
+ url = @pUrlInput.ask(":tabopen ", "")
907
+ if url.nil? || url.strip.empty?
908
+ refresh_cmd
909
+ return
910
+ end
911
+ new_tab_with(url.strip)
912
+ end
913
+
914
+ # Command mode {{{2
915
+ def command_mode
916
+ @pInput.x = 2
917
+ @pInput.w = @w - 1
918
+ @pInput.y = @h
919
+ cmd = @pInput.ask(":", "")
920
+ if cmd.nil? || cmd.strip.empty?
921
+ refresh_cmd
922
+ return
923
+ end
924
+ execute_command(cmd.strip)
925
+ end
926
+
927
+ def execute_command(cmd)
928
+ parts = cmd.split(" ", 2)
929
+ command = parts[0].downcase
930
+ args = parts[1]
931
+
932
+ case command
933
+ when "open", "o"
934
+ navigate(args) if args
935
+ when "tabopen", "to"
936
+ new_tab_with(args) if args
937
+ when "back"
938
+ go_back
939
+ when "forward"
940
+ go_forward
941
+ when "close", "q"
942
+ if @tabs.length > 1
943
+ close_tab
944
+ else
945
+ @running = false
946
+ end
947
+ when "quit", "qa"
948
+ @running = false
949
+ when "reload"
950
+ reload
951
+ when "help"
952
+ show_help
953
+ when "about"
954
+ new_tab_with("https://github.com/isene/brrowser")
955
+ when "bookmark", "bm"
956
+ if args
957
+ open_bookmark(args)
958
+ else
959
+ add_bookmark
960
+ end
961
+ when "bookmarks", "bms"
962
+ show_bookmarks
963
+ when "download", "dl"
964
+ download_file(args) if args
965
+ when "adblock"
966
+ update_adblock
967
+ when "password", "pw"
968
+ save_password
969
+ else
970
+ @status_msg = "Unknown command: #{command}"
971
+ refresh_cmd
972
+ end
973
+ end
974
+
975
+ # Help {{{2
976
+ def show_help
977
+ tab = Tab.new
978
+ @tabs.insert(@current_tab + 1, tab)
979
+ @current_tab += 1
980
+ current_tab.url = "about:help"
981
+ renderer = Renderer.new(@w - 2)
982
+ page = renderer.render(HELP_HTML)
983
+ current_tab.title = "Help"
984
+ current_tab.content = page[:text]
985
+ current_tab.links = page[:links]
986
+ current_tab.images = []
987
+ current_tab.ix = 0
988
+ @pMain.ix = 0
989
+ refresh_all
990
+ end
991
+
992
+ # AI summary {{{2
993
+ def ai_summarize
994
+ key_file = "/home/.safe/openai.txt"
995
+ unless File.exist?(key_file)
996
+ @status_msg = "No OpenAI key found at #{key_file}"
997
+ refresh_cmd
998
+ return
999
+ end
1000
+ api_key = File.read(key_file).strip
1001
+
1002
+ # Get page text (strip ANSI)
1003
+ text = current_tab.content.to_s.gsub(/\e\[[0-9;]*m/, "")
1004
+ if text.strip.empty?
1005
+ @status_msg = "No content to summarize"
1006
+ refresh_cmd
1007
+ return
1008
+ end
1009
+ # Truncate to ~4000 chars to fit in context
1010
+ text = text[0..4000] if text.length > 4000
1011
+
1012
+ @status_msg = "AI is thinking..."
1013
+ refresh_cmd
1014
+
1015
+ prompt = "Summarize the following web page concisely. Include key points, main topics, and any important links or actions. Page title: #{current_tab.title}\nURL: #{current_tab.url}\n\nContent:\n#{text}"
1016
+
1017
+ begin
1018
+ uri = URI.parse("https://api.openai.com/v1/chat/completions")
1019
+ http = Net::HTTP.new(uri.host, uri.port)
1020
+ http.use_ssl = true
1021
+ http.read_timeout = 30
1022
+ req = Net::HTTP::Post.new(uri.path)
1023
+ req["Authorization"] = "Bearer #{api_key}"
1024
+ req["Content-Type"] = "application/json"
1025
+ req.body = JSON.generate({
1026
+ model: "gpt-4o-mini",
1027
+ messages: [{ role: "user", content: prompt }],
1028
+ max_tokens: 600
1029
+ })
1030
+ response = http.request(req)
1031
+ data = JSON.parse(response.body)
1032
+ answer = data.dig("choices", 0, "message", "content") || "No response from AI"
1033
+ rescue => e
1034
+ answer = "AI error: #{e.message}"
1035
+ end
1036
+
1037
+ # Show in popup
1038
+ pw = [@w - 10, 80].min
1039
+ ph = [@h - 6, 30].min
1040
+ px = (@w - pw) / 2
1041
+ py = (@h - ph) / 2
1042
+ popup = Pane.new(px, py, pw, ph, 230, 234)
1043
+ popup.border = true
1044
+ popup.scroll = true
1045
+ popup.text = " AI Summary\n\n#{answer}"
1046
+ popup.refresh
1047
+
1048
+ loop do
1049
+ chr = getchr
1050
+ case chr
1051
+ when "ESC", "q", "I"
1052
+ break
1053
+ when "j", "DOWN"
1054
+ popup.linedown
1055
+ when "k", "UP"
1056
+ popup.lineup
1057
+ when "PgDOWN", " "
1058
+ popup.pagedown
1059
+ when "PgUP"
1060
+ popup.pageup
1061
+ end
1062
+ end
1063
+
1064
+ popup.clear
1065
+ @pInfo.full_refresh
1066
+ @pTab.full_refresh
1067
+ @pMain.full_refresh
1068
+ @pCmd.full_refresh
1069
+ @status_msg = ""
1070
+ show_visible_image
1071
+ end
1072
+
1073
+ # Edit source {{{2
1074
+ def edit_source
1075
+ return unless current_tab.url
1076
+ result = @fetcher.fetch(current_tab.url)
1077
+ return if result[:status] != 200
1078
+
1079
+ tmpfile = File.join(Dir.tmpdir, "brrowser_edit_#{Process.pid}.html")
1080
+ File.write(tmpfile, result[:body])
1081
+
1082
+ editor = ENV["EDITOR"] || "vim"
1083
+ Rcurses.cleanup!
1084
+ system("#{editor} #{Shellwords.escape(tmpfile)}")
1085
+ Rcurses.init!
1086
+
1087
+ # Re-render the edited content
1088
+ if File.exist?(tmpfile)
1089
+ edited = File.read(tmpfile)
1090
+ render_page({ body: edited, url: current_tab.url, content_type: "text/html", status: 200 })
1091
+ File.delete(tmpfile)
1092
+ end
1093
+ refresh_all
1094
+ end
1095
+
1096
+ # Bookmarks {{{2
1097
+ BOOKMARKS_FILE = File.join(CONFIG_DIR, "bookmarks.yml")
1098
+
1099
+ def load_bookmarks
1100
+ @bookmarks = if File.exist?(BOOKMARKS_FILE)
1101
+ YAML.safe_load(File.read(BOOKMARKS_FILE)) rescue []
1102
+ else
1103
+ []
1104
+ end
1105
+ @bookmarks = [] unless @bookmarks.is_a?(Array)
1106
+ end
1107
+
1108
+ def save_bookmarks
1109
+ Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
1110
+ File.write(BOOKMARKS_FILE, @bookmarks.to_yaml)
1111
+ end
1112
+
1113
+ def add_bookmark
1114
+ return unless current_tab.url
1115
+ entry = { "url" => current_tab.url, "title" => current_tab.title.to_s }
1116
+ load_bookmarks
1117
+ unless @bookmarks.any? { |b| b["url"] == entry["url"] }
1118
+ @bookmarks << entry
1119
+ save_bookmarks
1120
+ @status_msg = "Bookmarked: #{entry["title"]}"
1121
+ else
1122
+ @status_msg = "Already bookmarked"
1123
+ end
1124
+ refresh_cmd
1125
+ end
1126
+
1127
+ def open_bookmark(query)
1128
+ load_bookmarks
1129
+ match = @bookmarks.find { |b| b["title"]&.downcase&.include?(query.downcase) || b["url"]&.include?(query) }
1130
+ if match
1131
+ navigate(match["url"])
1132
+ else
1133
+ @status_msg = "No bookmark matching: #{query}"
1134
+ refresh_cmd
1135
+ end
1136
+ end
1137
+
1138
+ def show_bookmarks
1139
+ load_bookmarks
1140
+ if @bookmarks.empty?
1141
+ @status_msg = "No bookmarks"
1142
+ refresh_cmd
1143
+ return
1144
+ end
1145
+ html = "<html><body><h1>Bookmarks</h1><ul>"
1146
+ @bookmarks.each { |b| html << "<li><a href=\"#{b["url"]}\">#{b["title"] || b["url"]}</a></li>" }
1147
+ html << "</ul></body></html>"
1148
+
1149
+ tab = Tab.new
1150
+ @tabs.insert(@current_tab + 1, tab)
1151
+ @current_tab += 1
1152
+ current_tab.url = "about:bookmarks"
1153
+ renderer = Renderer.new(@w - 2)
1154
+ page = renderer.render(html)
1155
+ current_tab.title = "Bookmarks"
1156
+ current_tab.content = page[:text]
1157
+ current_tab.links = page[:links]
1158
+ current_tab.images = []
1159
+ current_tab.ix = 0
1160
+ @pMain.ix = 0
1161
+ refresh_all
1162
+ end
1163
+
1164
+ # Downloads {{{2
1165
+ def download_file(url)
1166
+ url = resolve_search(url) unless url.match?(%r{^https?://})
1167
+ @status_msg = "Downloading #{url}..."
1168
+ refresh_cmd
1169
+ result = @fetcher.fetch(url)
1170
+ if result[:status] == 200
1171
+ filename = File.basename(URI.parse(result[:url]).path)
1172
+ filename = "download" if filename.empty?
1173
+ dir = File.expand_path("~/Downloads")
1174
+ Dir.mkdir(dir) unless Dir.exist?(dir)
1175
+ path = File.join(dir, filename)
1176
+ # Avoid overwriting
1177
+ if File.exist?(path)
1178
+ ext = File.extname(filename)
1179
+ base = File.basename(filename, ext)
1180
+ i = 1
1181
+ i += 1 while File.exist?(File.join(dir, "#{base}_#{i}#{ext}"))
1182
+ path = File.join(dir, "#{base}_#{i}#{ext}")
1183
+ end
1184
+ File.binwrite(path, result[:body])
1185
+ @status_msg = "Saved: #{path} (#{result[:body].length} bytes)"
1186
+ else
1187
+ @status_msg = "Download failed: #{result[:status]}"
1188
+ end
1189
+ refresh_cmd
1190
+ end
1191
+
1192
+ # Quickmarks {{{2
1193
+ QUICKMARKS_FILE = File.join(CONFIG_DIR, "quickmarks.yml")
1194
+
1195
+ def load_quickmarks
1196
+ @quickmarks = if File.exist?(QUICKMARKS_FILE)
1197
+ YAML.safe_load(File.read(QUICKMARKS_FILE)) rescue {}
1198
+ else
1199
+ {}
1200
+ end
1201
+ @quickmarks = {} unless @quickmarks.is_a?(Hash)
1202
+ end
1203
+
1204
+ def save_quickmarks
1205
+ Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
1206
+ File.write(QUICKMARKS_FILE, @quickmarks.to_yaml)
1207
+ end
1208
+
1209
+ def set_quickmark
1210
+ @status_msg = "Set quickmark (0-9):"
1211
+ refresh_cmd
1212
+ chr = getchr
1213
+ if chr && chr.match?(/^[0-9]$/)
1214
+ load_quickmarks
1215
+ @quickmarks[chr] = { "url" => current_tab.url, "title" => current_tab.title.to_s }
1216
+ save_quickmarks
1217
+ @status_msg = "Quickmark #{chr} set: #{current_tab.title}"
1218
+ else
1219
+ @status_msg = "Cancelled"
1220
+ end
1221
+ refresh_cmd
1222
+ end
1223
+
1224
+ def goto_quickmark
1225
+ @status_msg = "Go to quickmark (0-9):"
1226
+ refresh_cmd
1227
+ chr = getchr
1228
+ if chr && chr.match?(/^[0-9]$/)
1229
+ load_quickmarks
1230
+ if @quickmarks[chr]
1231
+ navigate(@quickmarks[chr]["url"])
1232
+ else
1233
+ @status_msg = "No quickmark #{chr}"
1234
+ refresh_cmd
1235
+ end
1236
+ else
1237
+ @status_msg = "Cancelled"
1238
+ refresh_cmd
1239
+ end
1240
+ end
1241
+
1242
+ # Ad blocking {{{2
1243
+ ADBLOCK_FILE = File.join(CONFIG_DIR, "adblock.txt")
1244
+ ADBLOCK_DEFAULT_URL = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
1245
+
1246
+ def load_adblock
1247
+ @adblock_domains = Set.new
1248
+ if File.exist?(ADBLOCK_FILE)
1249
+ File.readlines(ADBLOCK_FILE).each do |line|
1250
+ line = line.strip
1251
+ next if line.empty? || line.start_with?("#")
1252
+ @adblock_domains << line
1253
+ end
1254
+ end
1255
+ end
1256
+
1257
+ def update_adblock
1258
+ @status_msg = "Downloading adblock list..."
1259
+ refresh_cmd
1260
+ result = @fetcher.fetch(ADBLOCK_DEFAULT_URL)
1261
+ if result[:status] == 200
1262
+ domains = []
1263
+ result[:body].each_line do |line|
1264
+ line = line.strip
1265
+ next if line.empty? || line.start_with?("#")
1266
+ parts = line.split(/\s+/)
1267
+ if parts.length >= 2 && parts[0] == "0.0.0.0"
1268
+ domain = parts[1]
1269
+ next if domain == "0.0.0.0"
1270
+ domains << domain
1271
+ end
1272
+ end
1273
+ Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
1274
+ File.write(ADBLOCK_FILE, domains.join("\n"))
1275
+ load_adblock
1276
+ @status_msg = "Adblock updated: #{domains.length} domains blocked"
1277
+ else
1278
+ @status_msg = "Adblock update failed"
1279
+ end
1280
+ refresh_cmd
1281
+ end
1282
+
1283
+ def blocked?(url)
1284
+ return false if @adblock_domains.nil? || @adblock_domains.empty?
1285
+ begin
1286
+ host = URI.parse(url).host
1287
+ @adblock_domains.include?(host)
1288
+ rescue
1289
+ false
1290
+ end
1291
+ end
1292
+
1293
+ # Passwords {{{2
1294
+ PASSWORDS_FILE = File.join(CONFIG_DIR, "passwords.yml")
1295
+
1296
+ def load_passwords
1297
+ @passwords = if File.exist?(PASSWORDS_FILE)
1298
+ YAML.safe_load(File.read(PASSWORDS_FILE)) rescue {}
1299
+ else
1300
+ {}
1301
+ end
1302
+ @passwords = {} unless @passwords.is_a?(Hash)
1303
+ end
1304
+
1305
+ def save_passwords
1306
+ Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
1307
+ File.write(PASSWORDS_FILE, @passwords.to_yaml)
1308
+ File.chmod(0600, PASSWORDS_FILE)
1309
+ end
1310
+
1311
+ def save_password
1312
+ return unless current_tab.url
1313
+ host = URI.parse(current_tab.url).host rescue nil
1314
+ return unless host
1315
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
1316
+ user = @pInput.ask("Username for #{host}: ", "")
1317
+ if user.nil? || user.strip.empty?
1318
+ refresh_cmd; return
1319
+ end
1320
+ pass = @pInput.ask("Password for #{host}: ", "")
1321
+ if pass.nil? || pass.strip.empty?
1322
+ refresh_cmd; return
1323
+ end
1324
+ load_passwords
1325
+ @passwords[host] = { "username" => user.strip, "password" => pass.strip }
1326
+ save_passwords
1327
+ @status_msg = "Password saved for #{host}"
1328
+ refresh_cmd
1329
+ end
1330
+
1331
+ def show_password
1332
+ return unless current_tab.url
1333
+ host = URI.parse(current_tab.url).host rescue nil
1334
+ return unless host
1335
+ load_passwords
1336
+ if @passwords[host]
1337
+ @status_msg = "#{host} - user: #{@passwords[host]["username"]} pass: #{@passwords[host]["password"]}"
1338
+ else
1339
+ @status_msg = "No password stored for #{host}"
1340
+ end
1341
+ refresh_cmd
1342
+ end
1343
+
1344
+ # Form interaction {{{2
1345
+ def check_autofill
1346
+ forms = current_tab.forms || []
1347
+ pw_form = forms.find { |f| f[:has_password] }
1348
+ return unless pw_form
1349
+ host = URI.parse(current_tab.url).host rescue nil
1350
+ return unless host
1351
+ load_passwords
1352
+ if @passwords[host]
1353
+ @status_msg = "Credentials available for #{host}. Press 'e' to fill form."
1354
+ refresh_cmd
1355
+ end
1356
+ end
1357
+
1358
+ def fill_form
1359
+ forms = current_tab.forms || []
1360
+ return if forms.empty?
1361
+
1362
+ # Prefer login forms, fall back to first form
1363
+ form = forms.find { |f| f[:has_password] } || forms.first
1364
+ host = URI.parse(current_tab.url).host rescue nil
1365
+
1366
+ # Try auto-fill from stored passwords
1367
+ load_passwords
1368
+ stored = host ? @passwords[host] : nil
1369
+
1370
+ params = {}
1371
+ has_password_field = false
1372
+
1373
+ form[:fields].each do |field|
1374
+ case field[:type]
1375
+ when "hidden"
1376
+ params[field[:name]] = field[:value] unless field[:name].empty?
1377
+ when "submit"
1378
+ # skip
1379
+ when "password"
1380
+ has_password_field = true
1381
+ prefill = stored ? stored["password"] : ""
1382
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
1383
+ val = @pInput.ask("#{field[:placeholder] || field[:name]}: ", prefill)
1384
+ if val.nil?
1385
+ refresh_cmd; return
1386
+ end
1387
+ params[field[:name]] = val.strip unless field[:name].empty?
1388
+ when "select"
1389
+ opts = (field[:options] || []).map { |o| o[:text] }
1390
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
1391
+ val = @pInput.ask("#{field[:name]} (#{opts.join('/')}): ", field[:value].to_s)
1392
+ if val.nil?
1393
+ refresh_cmd; return
1394
+ end
1395
+ # Match by text or value
1396
+ opt = field[:options]&.find { |o| o[:text].downcase.start_with?(val.strip.downcase) }
1397
+ params[field[:name]] = opt ? opt[:value] : val.strip unless field[:name].empty?
1398
+ else
1399
+ # text, email, etc.
1400
+ prefill = ""
1401
+ if stored && field[:type] =~ /email|text/ && field[:name] =~ /user|email|login|name/i
1402
+ prefill = stored["username"]
1403
+ end
1404
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
1405
+ val = @pInput.ask("#{field[:placeholder] || field[:name]}: ", prefill)
1406
+ if val.nil?
1407
+ refresh_cmd; return
1408
+ end
1409
+ params[field[:name]] = val.strip unless field[:name].empty?
1410
+ end
1411
+ end
1412
+
1413
+ # Submit
1414
+ action = form[:action]
1415
+ action = current_tab.url if action.nil? || action.empty?
1416
+ method = form[:method] == "post" ? :post : :get
1417
+
1418
+ @status_msg = "Submitting form..."
1419
+ refresh_cmd
1420
+
1421
+ if method == :post
1422
+ result = @fetcher.fetch(action, method: :post, params: params)
1423
+ else
1424
+ query = URI.encode_www_form(params)
1425
+ url = action.include?("?") ? "#{action}&#{query}" : "#{action}?#{query}"
1426
+ result = @fetcher.fetch(url)
1427
+ end
1428
+
1429
+ # Offer to save password if this was a new login
1430
+ if has_password_field && host && !stored
1431
+ username = params.find { |k, _| k =~ /user|email|login|name/i }&.last
1432
+ password = params.find { |k, _| k =~ /pass/i }&.last
1433
+ if username && password
1434
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
1435
+ save = @pInput.ask("Save password for #{host}? (y/n): ", "y")
1436
+ if save&.strip&.downcase == "y"
1437
+ @passwords[host] = { "username" => username, "password" => password }
1438
+ save_passwords
1439
+ @status_msg = "Password saved for #{host}"
1440
+ refresh_cmd
1441
+ end
1442
+ end
1443
+ end
1444
+
1445
+ current_tab.navigate(action)
1446
+ current_tab.url = result[:url]
1447
+ render_page(result)
1448
+ refresh_all
1449
+ end
1450
+
1451
+ # Config {{{2
1452
+ def load_config
1453
+ @conf = DEFAULTS.dup
1454
+ if File.exist?(CONFIG_FILE)
1455
+ saved = YAML.safe_load(File.read(CONFIG_FILE)) rescue {}
1456
+ @conf.merge!(saved) if saved.is_a?(Hash)
1457
+ end
1458
+ end
1459
+
1460
+ def save_config
1461
+ Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
1462
+ File.write(CONFIG_FILE, @conf.to_yaml)
1463
+ end
1464
+
1465
+ def apply_colors
1466
+ @pInfo.fg = @conf["info_fg"]; @pInfo.bg = @conf["info_bg"]
1467
+ @pTab.fg = @conf["tab_fg"]; @pTab.bg = @conf["tab_bg"]
1468
+ @pMain.fg = @conf["content_fg"]; @pMain.bg = @conf["content_bg"]
1469
+ @pCmd.fg = @conf["cmd_fg"]; @pCmd.bg = @conf["cmd_bg"]
1470
+ refresh_all
1471
+ end
1472
+
1473
+ # Preferences {{{2
1474
+ PREF_SETTINGS = [
1475
+ ["Image mode", "image_mode", %w[auto termpix ascii off]],
1476
+ ["Show images", "show_images", [true, false]],
1477
+ ["Homepage", "homepage", :text],
1478
+ ["Search engine", "search_engine", %w[g ddg w]],
1479
+ ["Info bar fg", "info_fg", :color],
1480
+ ["Info bar bg", "info_bg", :color],
1481
+ ["Tab bar fg", "tab_fg", :color],
1482
+ ["Tab bar bg", "tab_bg", :color],
1483
+ ["Active tab", "tab_active", :color],
1484
+ ["Content fg", "content_fg", :color],
1485
+ ["Content bg", "content_bg", :color],
1486
+ ["Status bar fg", "cmd_fg", :color],
1487
+ ["Status bar bg", "cmd_bg", :color],
1488
+ ["Link color", "link_color", :color],
1489
+ ["Link number", "link_num", :color],
1490
+ ["Heading h1", "heading_h1", :color],
1491
+ ["Heading h2", "heading_h2", :color],
1492
+ ["Heading h3", "heading_h3", :color],
1493
+ ]
1494
+
1495
+ def show_preferences
1496
+ pw = 44
1497
+ ph = PREF_SETTINGS.length + 4
1498
+ px = (@w - pw) / 2
1499
+ py = (@h - ph) / 2
1500
+ popup = Pane.new(px, py, pw, ph, 252, 234)
1501
+ popup.border = true
1502
+ sel = 0
1503
+
1504
+ build = lambda do
1505
+ lines = [" brrowser Preferences".b, ""]
1506
+ PREF_SETTINGS.each_with_index do |(label, key, type), i|
1507
+ val = @conf[key]
1508
+ disp = case type
1509
+ when :color
1510
+ "\u2588\u2588".fg(val) + " #{val}"
1511
+ else
1512
+ val.to_s
1513
+ end
1514
+ prefix = i == sel ? " \u25b6 ".fg(220) : " "
1515
+ line = "#{prefix}#{label.ljust(16)} \u25c4 #{disp.to_s.ljust(12)} \u25b6"
1516
+ line = line.r if i == sel
1517
+ lines << line
1518
+ end
1519
+ lines << ""
1520
+ lines << " h/l:change Enter:edit q:save & close".fg(245)
1521
+ popup.text = lines.join("\n")
1522
+ popup.refresh
1523
+ end
1524
+
1525
+ build.call
1526
+
1527
+ loop do
1528
+ chr = getchr
1529
+ case chr
1530
+ when "ESC"
1531
+ save_config
1532
+ apply_colors
1533
+ break
1534
+ when "j", "DOWN"
1535
+ sel = (sel + 1) % PREF_SETTINGS.length
1536
+ when "k", "UP"
1537
+ sel = (sel - 1) % PREF_SETTINGS.length
1538
+ when "l", "RIGHT"
1539
+ label, key, type = PREF_SETTINGS[sel]
1540
+ case type
1541
+ when Array
1542
+ idx = type.index(@conf[key]) || 0
1543
+ @conf[key] = type[(idx + 1) % type.length]
1544
+ when :color
1545
+ @conf[key] = (@conf[key] + 1) % 256
1546
+ end
1547
+ when "h", "LEFT"
1548
+ label, key, type = PREF_SETTINGS[sel]
1549
+ case type
1550
+ when Array
1551
+ idx = type.index(@conf[key]) || 0
1552
+ @conf[key] = type[(idx - 1) % type.length]
1553
+ when :color
1554
+ @conf[key] = (@conf[key] - 1) % 256
1555
+ end
1556
+ when "L" # Shift-L: +10 for colors
1557
+ label, key, type = PREF_SETTINGS[sel]
1558
+ @conf[key] = (@conf[key] + 10) % 256 if type == :color
1559
+ when "H" # Shift-H: -10 for colors
1560
+ label, key, type = PREF_SETTINGS[sel]
1561
+ @conf[key] = (@conf[key] - 10) % 256 if type == :color
1562
+ when "ENTER"
1563
+ label, key, type = PREF_SETTINGS[sel]
1564
+ if type == :text
1565
+ @pCmd.text = ""
1566
+ @pCmd.refresh
1567
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
1568
+ val = @pInput.ask("#{label}: ", @conf[key].to_s)
1569
+ @conf[key] = val.strip unless val.nil? || val.strip.empty?
1570
+ elsif type == :color
1571
+ @pCmd.text = ""
1572
+ @pCmd.refresh
1573
+ @pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
1574
+ val = @pInput.ask("#{label} (0-255): ", @conf[key].to_s)
1575
+ if val && val.strip.match?(/^\d+$/)
1576
+ @conf[key] = val.strip.to_i % 256
1577
+ end
1578
+ refresh_cmd
1579
+ end
1580
+ end
1581
+ build.call
1582
+ end
1583
+
1584
+ # Restore screen
1585
+ popup.clear
1586
+ @pInfo.full_refresh
1587
+ @pTab.full_refresh
1588
+ @pMain.full_refresh
1589
+ @pCmd.full_refresh
1590
+ show_visible_image
1591
+ end
1592
+
1593
+ # Main loop {{{2
1594
+ NORMAL_KEYMAP = {
1595
+ "j" => :scroll_down, "k" => :scroll_up,
1596
+ "DOWN" => :scroll_down, "UP" => :scroll_up,
1597
+ "LEFT" => :prev_tab, "RIGHT" => :next_tab,
1598
+ "G" => :go_bottom,
1599
+ "d" => :close_tab, "u" => :undo_close_tab,
1600
+ "o" => :open_url,
1601
+ "t" => :tabopen_url,
1602
+ "H" => :go_back, "L" => :go_forward,
1603
+ "BACK" => :go_back,
1604
+ "r" => :reload,
1605
+ "J" => :next_tab, "K" => :prev_tab,
1606
+ "/" => :search_page,
1607
+ "n" => :search_next, "N" => :search_prev,
1608
+ "?" => :show_help,
1609
+ ":" => :command_mode,
1610
+ "TAB" => :next_link, "S-TAB" => :prev_link,
1611
+ "ENTER" => :follow_link_prompt,
1612
+ "C-L" => :redraw_screen,
1613
+ "C-D" => :half_page_down, "C-U" => :half_page_up,
1614
+ "PgDOWN" => :page_down, "PgUP" => :page_up,
1615
+ " " => :page_down,
1616
+ "y" => :yank_url,
1617
+ "Y" => :yank_element,
1618
+ "e" => :edit_source,
1619
+ "f" => :fill_form,
1620
+ "C-G" => :edit_field_in_editor,
1621
+ "b" => :add_bookmark,
1622
+ "B" => :show_bookmarks,
1623
+ "m" => :set_quickmark,
1624
+ "'" => :goto_quickmark,
1625
+ "i" => :toggle_images,
1626
+ "p" => :show_password,
1627
+ "I" => :ai_summarize,
1628
+ "P" => :show_preferences,
1629
+ "q" => :quit_browser,
1630
+ }
1631
+
1632
+ def main_loop(initial_url)
1633
+ Rcurses.init!
1634
+ @h, @w = IO.console.winsize
1635
+ setup_panes
1636
+ new_tab(initial_url)
1637
+ refresh_all
1638
+ $stdin.getc while $stdin.wait_readable(0)
1639
+
1640
+ @showing_image = false
1641
+ @last_img_count = 0
1642
+
1643
+ while @running
1644
+ if @winch
1645
+ @winch = false
1646
+ resize
1647
+ end
1648
+
1649
+ # Auto-show first visible image when downloads complete
1650
+ if @termpix.supported? && !@showing_image
1651
+ new_count = @img_cache.size
1652
+ if new_count > @last_img_count
1653
+ @last_img_count = new_count
1654
+ show_visible_image
1655
+ end
1656
+ end
1657
+
1658
+ chr = getchr(1)
1659
+ next unless chr
1660
+
1661
+ if @g_pressed
1662
+ @g_pressed = false
1663
+ if chr == "g"
1664
+ go_top
1665
+ end
1666
+ next
1667
+ end
1668
+
1669
+ if chr == "g"
1670
+ @g_pressed = true
1671
+ next
1672
+ end
1673
+
1674
+ if chr == "O"
1675
+ open_url(current_tab.url.to_s)
1676
+ next
1677
+ end
1678
+
1679
+ handler = NORMAL_KEYMAP[chr]
1680
+ if handler
1681
+ m = method(handler)
1682
+ m.arity == 0 ? m.call : m.call
1683
+ end
1684
+ end
1685
+ rescue => e
1686
+ Rcurses.cleanup!
1687
+ puts "brrowser error: #{e.message}"
1688
+ puts e.backtrace.first(5).join("\n")
1689
+ exit 1
1690
+ ensure
1691
+ clear_images
1692
+ FileUtils.rm_rf(@img_dir) if @img_dir && Dir.exist?(@img_dir)
1693
+ Rcurses.cleanup!
1694
+ end
1695
+
1696
+ def toggle_images
1697
+ if @showing_image
1698
+ clear_images
1699
+ @status_msg = "Images hidden"
1700
+ else
1701
+ show_visible_image
1702
+ @status_msg = @showing_image ? "Images shown" : "No images in view"
1703
+ end
1704
+ refresh_cmd
1705
+ end
1706
+
1707
+ def quit_browser
1708
+ @running = false
1709
+ end
1710
+ end # }}}1
1711
+ end
1712
+
1713
+ # Entry point
1714
+ url = ARGV[0]
1715
+ Brrowser::Browser.new(url)