brrowser 0.1.1 → 0.1.4

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: 8db3fcfbe72fe0a1f0ef54a5b99b9e7c1b3bd4030f4cc37639aa623ca492f17d
4
- data.tar.gz: 19079bf7bf6ab1b632b193bf6847a81530524aac6d526864b9467eb2e1020e37
3
+ metadata.gz: b3fa096f37a4682bd1edb910013f6a6273acd8f46ee52304eaf140a4963527a5
4
+ data.tar.gz: 0b266827e97d189f85cbdc4bd33318419c03a1fc7e684b5b7eb7d0460f5093ce
5
5
  SHA512:
6
- metadata.gz: 2151be2c0a51f2b207594c4b86b9f79b4c9c8507dee66ddc9b52e8b7da149a56ad44db2f33fad762342687fae74d5699beee0f83cca4b3b354342caefbc17823
7
- data.tar.gz: 21df370567d8d3e33cf0826ad240f6ddff96aab5252c671172596fadf9d6b5645673f631e74883e9af74ff6dcaef51a1e9cb6dfc515bbe382fc8a7578d51a134
6
+ metadata.gz: 78c2c33f3db1bbbc77230e3a4ba98e691b1434c5566a74c7a97a4d8caa5fe7f55fb779082f1fcf09bdb99e00c7310838169a8a06ac1ca2600f57dd6e16f90d53
7
+ data.tar.gz: 4c1d3863f3830fc1e7f43d69810d2ed558922869d5da489322e953b525abec3a608300778530910c473a37991aea8ec7129598ba24e3061fb943721a69e97298
data/README.html ADDED
@@ -0,0 +1,95 @@
1
+ <html><body>
2
+ <h1>brrowser</h1>
3
+ <p><strong>The terminal web browser with vim-style keys.</strong></p>
4
+ <p>A terminal web browser combining w3m-style rendering with qutebrowser-style keyboard navigation. Renders HTML with styled headings, links, tables, forms, and inline images. No modes, no mouse, just keys.</p>
5
+
6
+ <h2>Features</h2>
7
+ <p><strong>Browsing:</strong> Full HTML rendering, inline images (termpix/chafa), YouTube embeds, tabs, back/forward history, search engines, ad blocking, cookie persistence.</p>
8
+ <p><strong>Navigation:</strong> vim-style keys (j/k/gg/G), TAB through links and form fields, search with /, bookmarks and quickmarks.</p>
9
+ <p><strong>Forms:</strong> Fill and submit with auto-fill from stored passwords. Firefox-style save prompt for new logins.</p>
10
+ <p><strong>Images:</strong> Kitty/sixel/w3m via termpix, ASCII art via chafa. Toggle with i.</p>
11
+ <p><strong>AI:</strong> Press I for an AI summary of the current page.</p>
12
+ <p><strong>Extras:</strong> Edit source in $EDITOR, download files, xdg-open for binaries, configurable colors, site color matching.</p>
13
+
14
+ <h2>Installation</h2>
15
+ <pre>gem install brrowser</pre>
16
+
17
+ <h3>Dependencies</h3>
18
+ <ul>
19
+ <li>Ruby 3.0+</li>
20
+ <li><a href="https://github.com/isene/rcurses">rcurses</a> (terminal UI)</li>
21
+ <li><a href="https://nokogiri.org/">nokogiri</a> (HTML parsing)</li>
22
+ <li><a href="https://github.com/isene/termpix">termpix</a> (image display, optional)</li>
23
+ </ul>
24
+ <p>Optional: <a href="https://hpjansson.org/chafa/">chafa</a> for ASCII art, ImageMagick for image scaling, xclip/xsel/wl-copy for clipboard.</p>
25
+
26
+ <h2>Usage</h2>
27
+ <pre>brrowser # Open this page
28
+ brrowser https://example.com # Open URL directly
29
+ brrowser isene.org # Auto-adds https://</pre>
30
+
31
+ <h2>Keybindings</h2>
32
+ <table>
33
+ <tr><th>Key</th><th>Action</th></tr>
34
+ <tr><td>j / k / arrows</td><td>Scroll up/down</td></tr>
35
+ <tr><td>&lt; / &gt;</td><td>Scroll left/right</td></tr>
36
+ <tr><td>Left / Right</td><td>Previous/next tab</td></tr>
37
+ <tr><td>gg / G</td><td>Top / bottom of page</td></tr>
38
+ <tr><td>Ctrl-d / Ctrl-u</td><td>Half page down/up</td></tr>
39
+ <tr><td>Space / PgDn / PgUp</td><td>Page down/up</td></tr>
40
+ <tr><td>TAB / S-TAB</td><td>Next/previous link or field</td></tr>
41
+ <tr><td>Enter</td><td>Follow link or edit field</td></tr>
42
+ <tr><td>o / O</td><td>Open URL / edit current URL</td></tr>
43
+ <tr><td>t</td><td>Open URL in new tab</td></tr>
44
+ <tr><td>H / Backspace</td><td>Go back</td></tr>
45
+ <tr><td>L</td><td>Go forward</td></tr>
46
+ <tr><td>r</td><td>Reload page</td></tr>
47
+ <tr><td>d</td><td>Close tab</td></tr>
48
+ <tr><td>u</td><td>Undo close tab</td></tr>
49
+ <tr><td>/</td><td>Search page</td></tr>
50
+ <tr><td>n / N</td><td>Next/previous search match</td></tr>
51
+ <tr><td>b / B</td><td>Bookmark / show bookmarks</td></tr>
52
+ <tr><td>m + key</td><td>Set quickmark (0-9, a-z)</td></tr>
53
+ <tr><td>' + key</td><td>Go to quickmark (0-9, a-z)</td></tr>
54
+ <tr><td>f</td><td>Fill and submit form</td></tr>
55
+ <tr><td>y / Y</td><td>Copy URL / copy focused element</td></tr>
56
+ <tr><td>e</td><td>Edit page source in $EDITOR</td></tr>
57
+ <tr><td>Ctrl-g</td><td>Edit field in $EDITOR</td></tr>
58
+ <tr><td>i</td><td>Toggle images</td></tr>
59
+ <tr><td>I</td><td>AI page summary</td></tr>
60
+ <tr><td>p</td><td>Show stored password</td></tr>
61
+ <tr><td>P</td><td>Preferences</td></tr>
62
+ <tr><td>Ctrl-l</td><td>Redraw screen</td></tr>
63
+ <tr><td>?</td><td>Help (detailed)</td></tr>
64
+ <tr><td>q</td><td>Quit</td></tr>
65
+ </table>
66
+
67
+ <h2>Commands</h2>
68
+ <p>Type <code>:</code> to enter command mode.</p>
69
+ <table>
70
+ <tr><th>Command</th><th>Action</th></tr>
71
+ <tr><td>:open URL / :o</td><td>Navigate</td></tr>
72
+ <tr><td>:tabopen URL / :to</td><td>Open in new tab</td></tr>
73
+ <tr><td>:close / :q</td><td>Close tab</td></tr>
74
+ <tr><td>:quit / :qa</td><td>Quit</td></tr>
75
+ <tr><td>:back / :forward</td><td>History navigation</td></tr>
76
+ <tr><td>:bookmark / :bm</td><td>Add bookmark or open by name</td></tr>
77
+ <tr><td>:bookmarks / :bms</td><td>List bookmarks</td></tr>
78
+ <tr><td>:download / :dl</td><td>Download file</td></tr>
79
+ <tr><td>:adblock</td><td>Update ad blocklist</td></tr>
80
+ <tr><td>:password / :pw</td><td>Save password for site</td></tr>
81
+ <tr><td>:about</td><td>Open project GitHub page</td></tr>
82
+ </table>
83
+
84
+ <h2>Configuration</h2>
85
+ <p>Press <strong>P</strong> for preferences: image mode, homepage, search engine, download folder, all colors.</p>
86
+ <p>Data stored in <code>~/.brrowser/</code>: config.yml, bookmarks.yml, quickmarks.yml, passwords.yml, cookies.yml, adblock.txt</p>
87
+
88
+ <h2>Links</h2>
89
+ <ul>
90
+ <li><a href="https://github.com/isene/brrowser">GitHub</a></li>
91
+ <li><a href="https://rubygems.org/gems/brrowser">RubyGems</a></li>
92
+ <li><a href="https://isene.org">Author: Geir Isene</a></li>
93
+ </ul>
94
+ <p>Created by Geir Isene with extensive pair-programming with Claude Code.</p>
95
+ </body></html>
data/bin/brrowser CHANGED
@@ -25,15 +25,15 @@ end
25
25
 
26
26
  # brrowser {{{1
27
27
  module Brrowser
28
- VERSION = "0.1.1"
29
- HOMEPAGE = "https://github.com/isene/brrowser"
28
+ VERSION = "0.1.4"
29
+ HOMEPAGE = "about:home"
30
30
  CONFIG_DIR = File.join(Dir.home, ".brrowser")
31
31
  CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
32
32
 
33
33
  DEFAULTS = {
34
34
  "show_images" => true,
35
35
  "image_mode" => "auto", # auto, termpix, ascii, off
36
- "homepage" => "https://github.com/isene/brrowser",
36
+ "homepage" => "about:home",
37
37
  "search_engine" => "g",
38
38
  "download_dir" => "~/Downloads",
39
39
  "info_fg" => 252,
@@ -50,6 +50,7 @@ module Brrowser
50
50
  "heading_h1" => 220,
51
51
  "heading_h2" => 214,
52
52
  "heading_h3" => 208,
53
+ "match_site_colors" => false,
53
54
  }
54
55
 
55
56
  HELP_HTML = <<~HTML
@@ -86,7 +87,7 @@ module Brrowser
86
87
  <tr><td>f</td><td>Fill and submit form</td></tr>
87
88
  <tr><td>Ctrl-g</td><td>Edit focused field in $EDITOR</td></tr>
88
89
  <tr><td>H / Backspace</td><td>Go back</td></tr>
89
- <tr><td>L</td><td>Go forward</td></tr>
90
+ <tr><td>L / Delete</td><td>Go forward</td></tr>
90
91
  <tr><td>r</td><td>Reload page</td></tr>
91
92
  </table>
92
93
  <h2>Tabs</h2>
@@ -246,6 +247,19 @@ module Brrowser
246
247
  # Navigation {{{2
247
248
  def navigate(url)
248
249
  url = resolve_search(url)
250
+ # Handle about: URLs internally
251
+ if url == "about:blank"
252
+ current_tab.navigate(url)
253
+ current_tab.url = url
254
+ current_tab.title = ""; current_tab.content = ""
255
+ current_tab.links = []; current_tab.images = []; current_tab.forms = []
256
+ refresh_all
257
+ return
258
+ end
259
+ if url == "about:home"
260
+ show_homepage
261
+ return
262
+ end
249
263
  @status_msg = "Loading #{url}..."
250
264
  refresh_cmd
251
265
 
@@ -307,6 +321,13 @@ module Brrowser
307
321
  current_tab.links = page[:links]
308
322
  current_tab.images = page[:images]
309
323
  current_tab.forms = page[:forms]
324
+ colors = page[:colors]
325
+ # If no colors found in HTML, try first external stylesheet
326
+ if @conf["match_site_colors"] && !colors[:bg] && !colors[:fg]
327
+ colors = fetch_external_css_colors(result[:body], result[:url])
328
+ end
329
+ current_tab.instance_variable_set(:@site_colors, colors)
330
+ apply_site_colors(colors)
310
331
  check_autofill
311
332
  else
312
333
  current_tab.title = result[:url]
@@ -314,6 +335,8 @@ module Brrowser
314
335
  current_tab.links = []
315
336
  current_tab.images = []
316
337
  current_tab.forms = []
338
+ @pMain.fg = @conf["content_fg"]
339
+ @pMain.bg = @conf["content_bg"]
317
340
  end
318
341
  current_tab.ix = 0
319
342
  @pMain.ix = 0
@@ -381,6 +404,7 @@ module Brrowser
381
404
 
382
405
  def reload
383
406
  return unless current_tab.url
407
+ @fetcher.invalidate_cache(current_tab.url)
384
408
  navigate_no_history(current_tab.url)
385
409
  end
386
410
 
@@ -474,7 +498,7 @@ module Brrowser
474
498
  return unless @h_scroll && @h_scroll > 0 && @original_content
475
499
  lines = @original_content.split("\n")
476
500
  shifted = lines.map do |line|
477
- pure = line.gsub(/\e\[[0-9;]*m/, "")
501
+ pure = line.gsub(Rcurses::ANSI_RE, "")
478
502
  pure.length > @h_scroll ? line[@h_scroll..] || "" : ""
479
503
  end
480
504
  @pMain.text = shifted.join("\n")
@@ -482,33 +506,41 @@ module Brrowser
482
506
  end
483
507
 
484
508
  def scroll_down
485
- had_images = @showing_image
486
- clear_images if @showing_image
487
- @pMain.linedown
488
- current_tab.ix = @pMain.ix
489
- show_visible_image if had_images
509
+ if @showing_image
510
+ clear_images
511
+ @pMain.linedown
512
+ current_tab.ix = @pMain.ix
513
+ show_visible_image
514
+ else
515
+ @pMain.linedown
516
+ current_tab.ix = @pMain.ix
517
+ end
490
518
  end
491
519
 
492
520
  def scroll_up
493
- had_images = @showing_image
494
- clear_images if @showing_image
495
- @pMain.lineup
496
- current_tab.ix = @pMain.ix
497
- show_visible_image if had_images
521
+ if @showing_image
522
+ clear_images
523
+ @pMain.lineup
524
+ current_tab.ix = @pMain.ix
525
+ show_visible_image
526
+ else
527
+ @pMain.lineup
528
+ current_tab.ix = @pMain.ix
529
+ end
498
530
  end
499
531
 
500
532
  def page_down
501
533
  clear_images if @showing_image
502
534
  @pMain.pagedown
503
535
  current_tab.ix = @pMain.ix
504
- show_visible_image
536
+ show_visible_image if image_mode != :off
505
537
  end
506
538
 
507
539
  def page_up
508
540
  clear_images if @showing_image
509
541
  @pMain.pageup
510
542
  current_tab.ix = @pMain.ix
511
- show_visible_image
543
+ show_visible_image if image_mode != :off
512
544
  end
513
545
 
514
546
  def half_page_down
@@ -516,7 +548,7 @@ module Brrowser
516
548
  half = (@h - 3) / 2
517
549
  half.times { @pMain.linedown }
518
550
  current_tab.ix = @pMain.ix
519
- show_visible_image
551
+ show_visible_image if image_mode != :off
520
552
  end
521
553
 
522
554
  def half_page_up
@@ -524,7 +556,7 @@ module Brrowser
524
556
  half = (@h - 3) / 2
525
557
  half.times { @pMain.lineup }
526
558
  current_tab.ix = @pMain.ix
527
- show_visible_image
559
+ show_visible_image if image_mode != :off
528
560
  end
529
561
 
530
562
  def go_top
@@ -532,23 +564,23 @@ module Brrowser
532
564
  @pMain.ix = 0
533
565
  current_tab.ix = 0
534
566
  refresh_main
535
- show_visible_image
567
+ show_visible_image if image_mode != :off
536
568
  end
537
569
 
538
570
  def go_bottom
539
571
  clear_images if @showing_image
540
572
  @pMain.bottom
541
573
  current_tab.ix = @pMain.ix
542
- show_visible_image
574
+ show_visible_image if image_mode != :off
543
575
  end
544
576
 
545
577
  def redraw_screen
546
- clear_images
578
+ clear_images if @showing_image
547
579
  @pInfo.full_refresh
548
580
  @pTab.full_refresh
549
581
  @pMain.full_refresh
550
582
  @pCmd.full_refresh
551
- show_visible_image
583
+ show_visible_image if image_mode != :off
552
584
  end
553
585
 
554
586
  # Images {{{2
@@ -594,14 +626,18 @@ module Brrowser
594
626
  end
595
627
 
596
628
  def image_mode
629
+ return @image_mode_cache if @image_mode_cache_conf == [@conf["image_mode"], @conf["show_images"]]
630
+ @image_mode_cache_conf = [@conf["image_mode"], @conf["show_images"]]
597
631
  mode = @conf["image_mode"]
598
- return :off unless @conf["show_images"]
599
- case mode
600
- when "termpix" then @termpix.supported? ? :termpix : :off
601
- when "ascii" then :ascii
602
- when "off" then :off
603
- else # auto
604
- @termpix.supported? ? :termpix : (chafa_available? ? :ascii : :off)
632
+ @image_mode_cache = if !@conf["show_images"]
633
+ :off
634
+ else
635
+ case mode
636
+ when "termpix" then @termpix.supported? ? :termpix : :off
637
+ when "ascii" then :ascii
638
+ when "off" then :off
639
+ else @termpix.supported? ? :termpix : (chafa_available? ? :ascii : :off)
640
+ end
605
641
  end
606
642
  end
607
643
 
@@ -699,7 +735,7 @@ module Brrowser
699
735
  return unless @search_term
700
736
  lines = current_tab.content.to_s.split("\n")
701
737
  lines.each_with_index do |line, i|
702
- clean = line.gsub(/\e\[[0-9;]*m/, "")
738
+ clean = line.gsub(Rcurses::ANSI_RE, "")
703
739
  if clean.downcase.include?(@search_term.downcase)
704
740
  @search_matches << i
705
741
  end
@@ -810,9 +846,14 @@ module Brrowser
810
846
  navigate(@focused_element[:data][:href])
811
847
  return
812
848
  end
813
- # If focused on a form field, edit it
849
+ # If focused on a form field: edit if empty, submit if filled
814
850
  if @focused_element && @focused_element[:type] == :field
815
- edit_focused_field
851
+ field = @focused_element[:data]
852
+ if field[:value].to_s.strip.empty?
853
+ edit_focused_field
854
+ else
855
+ submit_focused_form
856
+ end
816
857
  return
817
858
  end
818
859
  return if current_tab.links.empty?
@@ -841,9 +882,54 @@ module Brrowser
841
882
  val = @pInput.ask("#{field[:placeholder] || field[:name]}: ", field[:value].to_s)
842
883
  if val && !val.strip.empty?
843
884
  field[:value] = val.strip
844
- @status_msg = "Set #{field[:name]} = #{val.strip}"
885
+ # Update the rendered content to show the value
886
+ if field[:line]
887
+ lines = current_tab.content.split("\n")
888
+ if field[:line] < lines.length
889
+ old_line = lines[field[:line]].gsub("\e[7m", "").gsub("\e[27m", "")
890
+ # Replace the ________ placeholder with the value
891
+ old_line = old_line.gsub(/________\]/, "#{val.strip}]")
892
+ lines[field[:line]] = "\e[7m#{old_line}\e[27m"
893
+ current_tab.content = lines.join("\n")
894
+ end
895
+ end
896
+ @status_msg = "#{field[:name]} = #{val.strip} (Enter to submit)"
897
+ refresh_main
898
+ end
899
+ refresh_cmd
900
+ end
901
+
902
+ def submit_focused_form
903
+ return unless @focused_element && @focused_element[:type] == :field
904
+ form = @focused_element[:form]
905
+ return unless form
906
+
907
+ params = {}
908
+ form[:fields].each do |f|
909
+ next if f[:name].to_s.empty?
910
+ next if f[:type] == "submit"
911
+ params[f[:name]] = f[:value].to_s
845
912
  end
913
+
914
+ action = form[:action]
915
+ action = current_tab.url if action.nil? || action.empty?
916
+ method = form[:method] == "post" ? :post : :get
917
+
918
+ @status_msg = "Submitting..."
846
919
  refresh_cmd
920
+
921
+ if method == :post
922
+ result = @fetcher.fetch(action, method: :post, params: params)
923
+ else
924
+ query = URI.encode_www_form(params)
925
+ url = action.include?("?") ? "#{action}&#{query}" : "#{action}?#{query}"
926
+ result = @fetcher.fetch(url)
927
+ end
928
+
929
+ current_tab.navigate(action)
930
+ current_tab.url = result[:url]
931
+ render_page(result)
932
+ refresh_all
847
933
  end
848
934
 
849
935
  def edit_field_in_editor
@@ -997,6 +1083,37 @@ module Brrowser
997
1083
  end
998
1084
 
999
1085
  # Help {{{2
1086
+ def show_homepage
1087
+ html_file = nil
1088
+ [File.join(__dir__, "..", "README.html"),
1089
+ File.join(__dir__, "..", "..", "README.html")].each do |path|
1090
+ if File.exist?(path)
1091
+ html_file = path
1092
+ break
1093
+ end
1094
+ end
1095
+
1096
+ unless html_file
1097
+ navigate("https://github.com/isene/brrowser")
1098
+ return
1099
+ end
1100
+
1101
+ html = File.read(html_file)
1102
+ current_tab.navigate("about:home") unless current_tab.url == "about:home"
1103
+ current_tab.url = "about:home"
1104
+ renderer = Renderer.new(@w - 2)
1105
+ page = renderer.render(html)
1106
+ current_tab.title = "brrowser"
1107
+ current_tab.content = page[:text]
1108
+ current_tab.links = page[:links]
1109
+ current_tab.images = []
1110
+ current_tab.forms = []
1111
+ current_tab.ix = 0
1112
+ @pMain.ix = 0
1113
+ @status_msg = ""
1114
+ refresh_all
1115
+ end
1116
+
1000
1117
  def show_help
1001
1118
  tab = Tab.new
1002
1119
  @tabs.insert(@current_tab + 1, tab)
@@ -1024,7 +1141,7 @@ module Brrowser
1024
1141
  api_key = File.read(key_file).strip
1025
1142
 
1026
1143
  # Get page text (strip ANSI)
1027
- text = current_tab.content.to_s.gsub(/\e\[[0-9;]*m/, "")
1144
+ text = current_tab.content.to_s.gsub(Rcurses::ANSI_RE, "")
1028
1145
  if text.strip.empty?
1029
1146
  @status_msg = "No content to summarize"
1030
1147
  refresh_cmd
@@ -1494,8 +1611,95 @@ module Brrowser
1494
1611
  refresh_all
1495
1612
  end
1496
1613
 
1614
+ def apply_site_colors(colors = nil)
1615
+ colors ||= current_tab.instance_variable_get(:@site_colors) rescue nil
1616
+ if colors && @conf["match_site_colors"]
1617
+ bg = colors[:bg] || @conf["content_bg"]
1618
+ fg = colors[:fg] || @conf["content_fg"]
1619
+ # Ensure high contrast: if bg and fg are too close, pick white or black
1620
+ fg = high_contrast_fg(bg, fg)
1621
+ @pMain.bg = bg
1622
+ @pMain.fg = fg
1623
+ else
1624
+ @pMain.bg = @conf["content_bg"]
1625
+ @pMain.fg = @conf["content_fg"]
1626
+ end
1627
+ end
1628
+
1629
+ # Map xterm-256 color to approximate luminance (0.0 dark, 1.0 bright)
1630
+ def color_luminance(c)
1631
+ r, g, b = xterm256_to_rgb(c)
1632
+ (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
1633
+ end
1634
+
1635
+ def xterm256_to_rgb(c)
1636
+ if c < 16
1637
+ # Standard colors: approximate
1638
+ basic = [
1639
+ [0,0,0],[128,0,0],[0,128,0],[128,128,0],[0,0,128],[128,0,128],[0,128,128],[192,192,192],
1640
+ [128,128,128],[255,0,0],[0,255,0],[255,255,0],[0,0,255],[255,0,255],[0,255,255],[255,255,255]
1641
+ ]
1642
+ basic[c]
1643
+ elsif c < 232
1644
+ # 6x6x6 color cube
1645
+ c -= 16
1646
+ b = (c % 6) * 51
1647
+ g = ((c / 6) % 6) * 51
1648
+ r = (c / 36) * 51
1649
+ [r, g, b]
1650
+ else
1651
+ # Grayscale ramp
1652
+ v = (c - 232) * 10 + 8
1653
+ [v, v, v]
1654
+ end
1655
+ end
1656
+
1657
+ def high_contrast_fg(bg, fg)
1658
+ bg_lum = color_luminance(bg)
1659
+ fg_lum = color_luminance(fg)
1660
+ contrast = (bg_lum - fg_lum).abs
1661
+ if contrast < 0.3
1662
+ # Not enough contrast: use white for dark bg, black for light bg
1663
+ bg_lum < 0.5 ? 255 : 0
1664
+ else
1665
+ fg
1666
+ end
1667
+ end
1668
+
1669
+ def fetch_external_css_colors(html, base_url)
1670
+ doc = Nokogiri::HTML(html)
1671
+ # Find first local stylesheet
1672
+ doc.css("link[rel=stylesheet]").each do |link|
1673
+ href = link["href"].to_s
1674
+ next if href.empty?
1675
+ next if href.match?(/fonts\.googleapis|font-awesome|calendly|bootstrap/i)
1676
+ href = "https:#{href}" if href.start_with?("//")
1677
+ href = URI.join(base_url, href).to_s unless href.match?(%r{^https?://})
1678
+ begin
1679
+ css_result = @fetcher.fetch(href)
1680
+ next unless css_result[:status] == 200
1681
+ css = css_result[:body].to_s.force_encoding("UTF-8").scrub("")
1682
+ bg = nil; fg = nil
1683
+ # Check body { } rule
1684
+ if css.match(/body\s*\{([^}]+)\}/m)
1685
+ block = $1
1686
+ bg_match = block[/background(?:-color)?\s*:\s*([^;]+)/, 1]
1687
+ fg_match = block[/(?<!background-)color\s*:\s*([^;]+)/, 1]
1688
+ r = Renderer.new(80)
1689
+ bg = r.send(:parse_css_color, bg_match.strip) if bg_match
1690
+ fg = r.send(:parse_css_color, fg_match.strip) if fg_match
1691
+ end
1692
+ return { bg: bg, fg: fg } if bg || fg
1693
+ rescue
1694
+ next
1695
+ end
1696
+ end
1697
+ { bg: nil, fg: nil }
1698
+ end
1699
+
1497
1700
  # Preferences {{{2
1498
1701
  PREF_SETTINGS = [
1702
+ ["Match site colors", "match_site_colors", [true, false]],
1499
1703
  ["Image mode", "image_mode", %w[auto termpix ascii off]],
1500
1704
  ["Show images", "show_images", [true, false]],
1501
1705
  ["Homepage", "homepage", :text],
@@ -1518,16 +1722,19 @@ module Brrowser
1518
1722
  ]
1519
1723
 
1520
1724
  def show_preferences
1521
- pw = 44
1522
- ph = PREF_SETTINGS.length + 4
1725
+ pw = 56
1726
+ ph = [PREF_SETTINGS.length + 4, @h - 4].min
1523
1727
  px = (@w - pw) / 2
1524
1728
  py = (@h - ph) / 2
1525
1729
  popup = Pane.new(px, py, pw, ph, 252, 234)
1526
1730
  popup.border = true
1731
+ popup.scroll = true
1527
1732
  sel = 0
1528
1733
 
1529
1734
  build = lambda do
1530
- lines = [" brrowser Preferences".b, ""]
1735
+ lines = [" brrowser Preferences".b]
1736
+ lines << " h/l:change H/L:+/-10 Enter:edit ESC:close".fg(245)
1737
+ lines << ""
1531
1738
  PREF_SETTINGS.each_with_index do |(label, key, type), i|
1532
1739
  val = @conf[key]
1533
1740
  disp = case type
@@ -1537,13 +1744,19 @@ module Brrowser
1537
1744
  val.to_s
1538
1745
  end
1539
1746
  prefix = i == sel ? " \u25b6 ".fg(220) : " "
1540
- line = "#{prefix}#{label.ljust(16)} \u25c4 #{disp.to_s.ljust(12)} \u25b6"
1747
+ line = "#{prefix}#{label.ljust(18)} \u25c4 #{disp.to_s.ljust(14)} \u25b6"
1541
1748
  line = line.r if i == sel
1542
1749
  lines << line
1543
1750
  end
1544
- lines << ""
1545
- lines << " h/l:change Enter:edit q:save & close".fg(245)
1546
1751
  popup.text = lines.join("\n")
1752
+ # Keep selected item visible in scrollable area
1753
+ visible = ph - 3
1754
+ line_pos = sel + 3 # offset for header + legend + blank
1755
+ if line_pos >= popup.ix + visible
1756
+ popup.ix = line_pos - visible + 1
1757
+ elsif line_pos < popup.ix
1758
+ popup.ix = [line_pos, 0].max
1759
+ end
1547
1760
  popup.refresh
1548
1761
  end
1549
1762
 
@@ -1555,6 +1768,7 @@ module Brrowser
1555
1768
  when "ESC"
1556
1769
  save_config
1557
1770
  apply_colors
1771
+ apply_site_colors
1558
1772
  break
1559
1773
  when "j", "DOWN"
1560
1774
  sel = (sel + 1) % PREF_SETTINGS.length
@@ -1627,6 +1841,7 @@ module Brrowser
1627
1841
  "t" => :tabopen_url,
1628
1842
  "H" => :go_back, "L" => :go_forward,
1629
1843
  "BACK" => :go_back,
1844
+ "DEL" => :go_forward,
1630
1845
  "r" => :reload,
1631
1846
  "J" => :next_tab, "K" => :prev_tab,
1632
1847
  "/" => :search_page,
@@ -1673,7 +1888,7 @@ module Brrowser
1673
1888
  end
1674
1889
 
1675
1890
  # Auto-show first visible image when downloads complete
1676
- if @termpix.supported? && !@showing_image
1891
+ if image_mode != :off && !@showing_image
1677
1892
  new_count = @img_cache.size
1678
1893
  if new_count > @last_img_count
1679
1894
  @last_img_count = new_count
@@ -1720,12 +1935,19 @@ module Brrowser
1720
1935
  end
1721
1936
 
1722
1937
  def toggle_images
1723
- if @showing_image
1724
- clear_images
1938
+ @conf["show_images"] = !@conf["show_images"]
1939
+ @image_mode_cache_conf = nil # Invalidate cache
1940
+ if !@conf["show_images"]
1941
+ # Force clear all images regardless of state
1942
+ @termpix.clear(x: @pMain.x, y: @pMain.y, width: @pMain.w, height: @pMain.h, term_width: @w, term_height: @h) if @termpix.supported?
1943
+ @pMain.full_refresh
1944
+ @showing_image = false
1725
1945
  @status_msg = "Images hidden"
1726
1946
  else
1947
+ # Re-trigger downloads if needed, then show
1948
+ start_image_downloads
1727
1949
  show_visible_image
1728
- @status_msg = @showing_image ? "Images shown" : "No images in view"
1950
+ @status_msg = @showing_image ? "Images shown" : "Downloading images..."
1729
1951
  end
1730
1952
  refresh_cmd
1731
1953
  end
@@ -10,11 +10,26 @@ module Brrowser
10
10
  USER_AGENT = "brrowser/0.1 (terminal browser)"
11
11
  COOKIE_FILE = File.join(Dir.home, ".brrowser", "cookies.yml")
12
12
 
13
+ MAX_CACHE = 20
14
+
13
15
  def initialize
14
16
  @cookies = load_cookies
17
+ @page_cache = {}
18
+ @page_cache_order = []
15
19
  end
16
20
 
17
21
  def fetch(url, method: :get, params: nil)
22
+ # Return cached response for GET requests
23
+ if method == :get && !params && @page_cache[url]
24
+ return @page_cache[url]
25
+ end
26
+ # Handle local files
27
+ if url.match?(%r{^file://})
28
+ path = url.sub(%r{^file://}, "")
29
+ body = File.exist?(path) ? File.read(path) : "File not found: #{path}"
30
+ ct = path.match?(/\.html?$/i) ? "text/html" : "text/plain"
31
+ return { body: body, url: url, content_type: ct, status: 200 }
32
+ end
18
33
  url = "https://#{url}" unless url.match?(%r{^https?://})
19
34
  uri = URI.parse(url)
20
35
  redirects = 0
@@ -56,12 +71,14 @@ module Brrowser
56
71
  ct = response["content-type"] || ""
57
72
  body = response.body
58
73
  body = body.force_encoding("UTF-8") if ct.match?(/text|html|json|xml/)
59
- return {
74
+ result = {
60
75
  body: body,
61
76
  url: uri.to_s,
62
77
  content_type: ct,
63
78
  status: response.code.to_i
64
79
  }
80
+ cache_response(url, result) if method == :get
81
+ return result
65
82
  else
66
83
  return {
67
84
  body: "Error #{response.code}: #{response.message}",
@@ -82,6 +99,29 @@ module Brrowser
82
99
 
83
100
  private
84
101
 
102
+ public
103
+
104
+ def invalidate_cache(url = nil)
105
+ if url
106
+ @page_cache.delete(url)
107
+ @page_cache_order.delete(url)
108
+ else
109
+ @page_cache.clear
110
+ @page_cache_order.clear
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def cache_response(url, result)
117
+ @page_cache[url] = result
118
+ @page_cache_order << url unless @page_cache_order.include?(url)
119
+ while @page_cache_order.length > MAX_CACHE
120
+ evict = @page_cache_order.shift
121
+ @page_cache.delete(evict)
122
+ end
123
+ end
124
+
85
125
  def store_cookies(uri, response)
86
126
  Array(response.get_fields("set-cookie")).each do |raw|
87
127
  name_val = raw.split(";").first.strip
@@ -2,6 +2,7 @@ require 'nokogiri'
2
2
 
3
3
  module Brrowser
4
4
  class Renderer
5
+ ANSI_RE = /\e\[[0-9;]*m/.freeze
5
6
  SKIP_ELEMENTS = %w[script style noscript svg head].freeze
6
7
  BLOCK_ELEMENTS = %w[
7
8
  p div section article aside main header footer nav
@@ -45,7 +46,8 @@ module Brrowser
45
46
  walk(body)
46
47
  flush_line
47
48
 
48
- { text: @output.join("\n"), links: @links, images: @images, forms: @forms, title: title }
49
+ site_colors = extract_site_colors(doc)
50
+ { text: @output.join("\n"), links: @links, images: @images, forms: @forms, title: title, colors: site_colors }
49
51
  end
50
52
 
51
53
  private
@@ -76,7 +78,7 @@ module Brrowser
76
78
  words = text.split(/( )/)
77
79
  words.each do |word|
78
80
  next if word.empty?
79
- visible = word.gsub(/\e\[[0-9;]*m/, "")
81
+ visible = word.gsub(ANSI_RE, "")
80
82
  if @col + visible.length > @width && @col > 0 && word != " "
81
83
  flush_line
82
84
  end
@@ -103,7 +105,7 @@ module Brrowser
103
105
  text = collect_text(node)
104
106
  @line << text.b.fg(220)
105
107
  flush_line
106
- @line << ("═" * [text.gsub(/\e\[[0-9;]*m/, "").length, @width].min).fg(220)
108
+ @line << ("═" * [text.gsub(ANSI_RE, "").length, @width].min).fg(220)
107
109
  flush_line
108
110
  ensure_blank_line
109
111
  when "h2"
@@ -281,7 +283,11 @@ module Brrowser
281
283
  ensure_blank_line
282
284
  end
283
285
  when "table"
284
- render_table(node)
286
+ if layout_table?(node)
287
+ walk(node) # Treat layout tables as normal content
288
+ else
289
+ render_table(node)
290
+ end
285
291
  when "form"
286
292
  ensure_blank_line
287
293
  action = node["action"] || ""
@@ -316,7 +322,7 @@ module Brrowser
316
322
  field = "[#{label}: ________]".fg(252)
317
323
  @line << field
318
324
  @col += label.length + 14
319
- @current_form[:fields] << { type: type, name: name, value: value, placeholder: placeholder } if @current_form
325
+ @current_form[:fields] << { type: type, name: name, value: value, placeholder: placeholder, line: @output.length } if @current_form
320
326
  end
321
327
  when "select"
322
328
  name = node["name"] || "select"
@@ -325,16 +331,26 @@ module Brrowser
325
331
  options = node.css("option").map { |o| { value: o["value"] || o.text, text: o.text.strip } }
326
332
  selected = node.at_css("option[selected]")
327
333
  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
334
+ @current_form[:fields] << { type: "select", name: name, value: val.to_s, options: options, line: @output.length } if @current_form
329
335
  when "textarea"
330
336
  name = node["name"] || "text"
331
337
  @line << "[#{name}: ________]".fg(252)
332
338
  @col += name.length + 14
333
- @current_form[:fields] << { type: "textarea", name: name, value: node.text } if @current_form
339
+ @current_form[:fields] << { type: "textarea", name: name, value: node.text, line: @output.length } if @current_form
334
340
  when "label"
335
341
  walk(node)
336
342
  when "span"
337
343
  walk(node)
344
+ when "td", "th"
345
+ # Reached here from layout table walk
346
+ flush_line if @col > 0
347
+ walk(node)
348
+ flush_line if @col > 0
349
+ when "tr"
350
+ walk(node)
351
+ flush_line if @col > 0
352
+ when "tbody", "thead", "tfoot", "caption"
353
+ walk(node)
338
354
  else
339
355
  walk(node)
340
356
  end
@@ -384,6 +400,22 @@ module Brrowser
384
400
  end
385
401
  end
386
402
 
403
+ def layout_table?(node)
404
+ # Nested tables = layout
405
+ return true if node.css("table").any?
406
+ # Single-column tables = layout
407
+ first_row = node.at_css("tr")
408
+ return true if first_row && first_row.css("td, th").length <= 1
409
+ # Tables containing block elements in cells = layout
410
+ node.css("td, th").first(4).each do |cell|
411
+ return true if cell.at_css("p, div, h1, h2, h3, h4, ul, ol, table, blockquote")
412
+ end
413
+ # Width 100% = layout
414
+ style = node["style"].to_s + " " + (node["width"].to_s)
415
+ return true if style.match?(/100%/)
416
+ false
417
+ end
418
+
387
419
  def render_table(table_node)
388
420
  ensure_blank_line
389
421
  rows = []
@@ -437,9 +469,7 @@ module Brrowser
437
469
  end
438
470
 
439
471
  def render_table_vertical(rows, table_node)
440
- # Render each row as a block with label: value pairs
441
472
  headers = table_node.css("th").any? ? rows.shift : nil
442
-
443
473
  rows.each do |row|
444
474
  row.each_with_index do |cell, ci|
445
475
  next if cell.strip.empty?
@@ -449,5 +479,117 @@ module Brrowser
449
479
  @output << (" " * @indent) + ("\u2500" * 20).fg(240)
450
480
  end
451
481
  end
482
+
483
+ # Site color extraction {{{
484
+ def extract_site_colors(doc)
485
+ bg = nil; fg = nil
486
+
487
+ # 1. HTML attributes (old-school)
488
+ body = doc.at_css("body")
489
+ if body
490
+ bg = parse_css_color(body["bgcolor"]) if body["bgcolor"]
491
+ fg = parse_css_color(body["text"]) if body["text"]
492
+ end
493
+
494
+ # 2. Inline styles on body/html
495
+ [body, doc.at_css("html")].compact.each do |node|
496
+ style = node["style"].to_s
497
+ next if style.empty?
498
+ bg ||= extract_css_color(style, /background(?:-color)?\s*:\s*([^;]+)/)
499
+ fg ||= extract_css_color(style, /(?<!background-)color\s*:\s*([^;]+)/)
500
+ end
501
+
502
+ # 3. Embedded <style> blocks - check body, html, :root rules
503
+ unless bg && fg
504
+ doc.css("style").each do |style_node|
505
+ css = style_node.text
506
+ %w[body html :root .page .site .wrapper #page #wrapper #content].each do |sel|
507
+ pattern = /#{Regexp.escape(sel)}\s*\{([^}]+)\}/m
508
+ if css.match(pattern)
509
+ block = $1
510
+ bg ||= extract_css_color(block, /background(?:-color)?\s*:\s*([^;]+)/)
511
+ fg ||= extract_css_color(block, /(?<!background-)color\s*:\s*([^;]+)/)
512
+ end
513
+ end
514
+ # CSS variables: --bg-color, --background, --text-color, etc.
515
+ css.scan(/--(bg|background|main-bg|site-bg|page-bg)[^:]*:\s*([^;}\n]+)/) do |_, val|
516
+ bg ||= parse_css_color(val.strip)
517
+ end
518
+ css.scan(/--(fg|text|color|main-color|text-color|font-color)[^:]*:\s*([^;}\n]+)/) do |_, val|
519
+ fg ||= parse_css_color(val.strip)
520
+ end
521
+ end
522
+ end
523
+
524
+ # 4. Meta theme-color
525
+ unless bg
526
+ meta = doc.at_css('meta[name="theme-color"]')
527
+ bg = parse_css_color(meta["content"]) if meta && meta["content"]
528
+ end
529
+
530
+ { bg: bg, fg: fg }
531
+ end
532
+
533
+ def extract_css_color(css_text, regex)
534
+ return nil unless css_text.match?(regex)
535
+ val = css_text[regex, 1]&.strip
536
+ parse_css_color(val)
537
+ end
538
+
539
+ def parse_css_color(val)
540
+ return nil unless val
541
+ val = val.strip.downcase
542
+
543
+ # Hex colors
544
+ if val.match?(/^#[0-9a-f]{6}$/)
545
+ return hex_to_256(val)
546
+ elsif val.match?(/^#[0-9a-f]{3}$/)
547
+ expanded = "##{val[1]*2}#{val[2]*2}#{val[3]*2}"
548
+ return hex_to_256(expanded)
549
+ end
550
+
551
+ # rgb(r, g, b)
552
+ if val.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
553
+ r, g, b = $1.to_i, $2.to_i, $3.to_i
554
+ return rgb_to_256(r, g, b)
555
+ end
556
+
557
+ # Named colors (common ones)
558
+ named = {
559
+ "white" => 15, "black" => 0, "red" => 1, "green" => 2,
560
+ "blue" => 4, "yellow" => 3, "cyan" => 6, "magenta" => 5,
561
+ "gray" => 245, "grey" => 245, "silver" => 250,
562
+ "darkgray" => 238, "darkgrey" => 238,
563
+ "lightgray" => 252, "lightgrey" => 252,
564
+ "navy" => 17, "teal" => 30, "maroon" => 1,
565
+ "olive" => 3, "purple" => 5, "aqua" => 14,
566
+ "orange" => 208, "pink" => 218,
567
+ "whitesmoke" => 255, "ghostwhite" => 255,
568
+ "aliceblue" => 153, "ivory" => 255,
569
+ }
570
+ named[val.gsub(/\s/, "")]
571
+ end
572
+
573
+ def hex_to_256(hex)
574
+ r = hex[1..2].to_i(16)
575
+ g = hex[3..4].to_i(16)
576
+ b = hex[5..6].to_i(16)
577
+ rgb_to_256(r, g, b)
578
+ end
579
+
580
+ def rgb_to_256(r, g, b)
581
+ # Check grayscale ramp (232-255) first
582
+ if r == g && g == b
583
+ return 16 if r < 8
584
+ return 231 if r > 248
585
+ return (((r - 8).to_f / 247 * 24).round + 232)
586
+ end
587
+ # Map to 6x6x6 color cube (16-231)
588
+ ri = ((r.to_f / 255) * 5).round
589
+ gi = ((g.to_f / 255) * 5).round
590
+ bi = ((b.to_f / 255) * 5).round
591
+ 16 + (36 * ri) + (6 * gi) + bi
592
+ end
593
+ # }}}
452
594
  end
453
595
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brrowser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-27 00:00:00.000000000 Z
11
+ date: 2026-03-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcurses
@@ -62,6 +62,7 @@ extensions: []
62
62
  extra_rdoc_files: []
63
63
  files:
64
64
  - LICENSE
65
+ - README.html
65
66
  - README.md
66
67
  - bin/brrowser
67
68
  - img/brrowser.svg