brrowser 0.1.3 → 0.1.5

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: 031bfa105348853d72db747c32797041f5661fe4ca66547f25a3dacb9a4b211a
4
- data.tar.gz: f89cca0ebc7e8c7e48537ea62865a869df55d0d6213d6b6d574f2e60c5c8dd7c
3
+ metadata.gz: 1502511b56f9175cb771635ca305bad614c6230d342c85b44205f4de29a79cc3
4
+ data.tar.gz: ec1f738931d99d5783b9a0e45f4085b785a3a70c3c6efb11cfd63da3f7b06d5b
5
5
  SHA512:
6
- metadata.gz: 45309f7af0f927a1fcfd20614826875d3fa7cbb78f8103f43f0ca2c5092a8df5bea05e654d2645fc541e40f5047a259ec7d2416e623727b42eaebabee56e08bd
7
- data.tar.gz: ea797828fd0810a4086f884e8c3f6cc1c734ec63030e0fcc7fc7b329c71e899c9c7c469f5c0baa6307f3ddabf2e994c44f2da94a09dfc06905c6807a9058e8ff
6
+ metadata.gz: 737df9f08305e342142bcefa5e6a80a30dc060998fc91f3f71fbfd623167e17ec3b8f0021d9c24189fd27d7c088fb998d89b474f81b9e6ee0c2a7649b5290466
7
+ data.tar.gz: 24ecf86ab13aaadabd05eacecbfeeeda13dedafd05c77eacf11b5406428918b36d65065e6ecfd3f179c23bb0f134157cbc1f8d060754f03326987fb19821409a
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.3"
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,
@@ -87,7 +87,7 @@ module Brrowser
87
87
  <tr><td>f</td><td>Fill and submit form</td></tr>
88
88
  <tr><td>Ctrl-g</td><td>Edit focused field in $EDITOR</td></tr>
89
89
  <tr><td>H / Backspace</td><td>Go back</td></tr>
90
- <tr><td>L</td><td>Go forward</td></tr>
90
+ <tr><td>L / Delete</td><td>Go forward</td></tr>
91
91
  <tr><td>r</td><td>Reload page</td></tr>
92
92
  </table>
93
93
  <h2>Tabs</h2>
@@ -256,6 +256,10 @@ module Brrowser
256
256
  refresh_all
257
257
  return
258
258
  end
259
+ if url == "about:home"
260
+ show_homepage
261
+ return
262
+ end
259
263
  @status_msg = "Loading #{url}..."
260
264
  refresh_cmd
261
265
 
@@ -400,6 +404,7 @@ module Brrowser
400
404
 
401
405
  def reload
402
406
  return unless current_tab.url
407
+ @fetcher.invalidate_cache(current_tab.url)
403
408
  navigate_no_history(current_tab.url)
404
409
  end
405
410
 
@@ -452,7 +457,7 @@ module Brrowser
452
457
  label = tab.title.to_s.empty? ? "New Tab" : tab.title.to_s
453
458
  label = label[0..20] + "..." if label.length > 23
454
459
  if i == @current_tab
455
- " #{label} ".fg(220).b
460
+ " #{label} ".fg(220).bd
456
461
  else
457
462
  " #{label} ".fg(245)
458
463
  end
@@ -493,7 +498,7 @@ module Brrowser
493
498
  return unless @h_scroll && @h_scroll > 0 && @original_content
494
499
  lines = @original_content.split("\n")
495
500
  shifted = lines.map do |line|
496
- pure = line.gsub(/\e\[[0-9;]*m/, "")
501
+ pure = line.gsub(Rcurses::ANSI_RE, "")
497
502
  pure.length > @h_scroll ? line[@h_scroll..] || "" : ""
498
503
  end
499
504
  @pMain.text = shifted.join("\n")
@@ -501,33 +506,41 @@ module Brrowser
501
506
  end
502
507
 
503
508
  def scroll_down
504
- had_images = @showing_image
505
- clear_images if @showing_image
506
- @pMain.linedown
507
- current_tab.ix = @pMain.ix
508
- 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
509
518
  end
510
519
 
511
520
  def scroll_up
512
- had_images = @showing_image
513
- clear_images if @showing_image
514
- @pMain.lineup
515
- current_tab.ix = @pMain.ix
516
- 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
517
530
  end
518
531
 
519
532
  def page_down
520
533
  clear_images if @showing_image
521
534
  @pMain.pagedown
522
535
  current_tab.ix = @pMain.ix
523
- show_visible_image
536
+ show_visible_image if image_mode != :off
524
537
  end
525
538
 
526
539
  def page_up
527
540
  clear_images if @showing_image
528
541
  @pMain.pageup
529
542
  current_tab.ix = @pMain.ix
530
- show_visible_image
543
+ show_visible_image if image_mode != :off
531
544
  end
532
545
 
533
546
  def half_page_down
@@ -535,7 +548,7 @@ module Brrowser
535
548
  half = (@h - 3) / 2
536
549
  half.times { @pMain.linedown }
537
550
  current_tab.ix = @pMain.ix
538
- show_visible_image
551
+ show_visible_image if image_mode != :off
539
552
  end
540
553
 
541
554
  def half_page_up
@@ -543,7 +556,7 @@ module Brrowser
543
556
  half = (@h - 3) / 2
544
557
  half.times { @pMain.lineup }
545
558
  current_tab.ix = @pMain.ix
546
- show_visible_image
559
+ show_visible_image if image_mode != :off
547
560
  end
548
561
 
549
562
  def go_top
@@ -551,23 +564,23 @@ module Brrowser
551
564
  @pMain.ix = 0
552
565
  current_tab.ix = 0
553
566
  refresh_main
554
- show_visible_image
567
+ show_visible_image if image_mode != :off
555
568
  end
556
569
 
557
570
  def go_bottom
558
571
  clear_images if @showing_image
559
572
  @pMain.bottom
560
573
  current_tab.ix = @pMain.ix
561
- show_visible_image
574
+ show_visible_image if image_mode != :off
562
575
  end
563
576
 
564
577
  def redraw_screen
565
- clear_images
578
+ clear_images if @showing_image
566
579
  @pInfo.full_refresh
567
580
  @pTab.full_refresh
568
581
  @pMain.full_refresh
569
582
  @pCmd.full_refresh
570
- show_visible_image
583
+ show_visible_image if image_mode != :off
571
584
  end
572
585
 
573
586
  # Images {{{2
@@ -596,6 +609,22 @@ module Brrowser
596
609
  response = @fetcher.fetch(src)
597
610
  next if response[:status] != 200
598
611
  ct = response[:content_type].to_s
612
+ # Handle oEmbed JSON responses (e.g. YouTube playlists)
613
+ if ct.match?(/json/) && src.include?("oembed")
614
+ begin
615
+ data = JSON.parse(response[:body])
616
+ thumb = data["thumbnail_url"]
617
+ next unless thumb
618
+ response = @fetcher.fetch(thumb)
619
+ next if response[:status] != 200
620
+ ct = response[:content_type].to_s
621
+ ext = File.extname(URI.parse(thumb).path)[0..5]
622
+ ext = ".jpg" if ext.empty?
623
+ local = File.join(@img_dir, "#{Digest::MD5.hexdigest(src)}#{ext}")
624
+ rescue
625
+ next
626
+ end
627
+ end
599
628
  next unless ct.match?(/image/) || src.match?(/\.(png|jpe?g|gif|bmp|webp|svg|ico)/i)
600
629
  File.binwrite(local, response[:body])
601
630
  if src.match?(/\.svg/i) || ct.include?("svg")
@@ -613,14 +642,18 @@ module Brrowser
613
642
  end
614
643
 
615
644
  def image_mode
645
+ return @image_mode_cache if @image_mode_cache_conf == [@conf["image_mode"], @conf["show_images"]]
646
+ @image_mode_cache_conf = [@conf["image_mode"], @conf["show_images"]]
616
647
  mode = @conf["image_mode"]
617
- return :off unless @conf["show_images"]
618
- case mode
619
- when "termpix" then @termpix.supported? ? :termpix : :off
620
- when "ascii" then :ascii
621
- when "off" then :off
622
- else # auto
623
- @termpix.supported? ? :termpix : (chafa_available? ? :ascii : :off)
648
+ @image_mode_cache = if !@conf["show_images"]
649
+ :off
650
+ else
651
+ case mode
652
+ when "termpix" then @termpix.supported? ? :termpix : :off
653
+ when "ascii" then :ascii
654
+ when "off" then :off
655
+ else @termpix.supported? ? :termpix : (chafa_available? ? :ascii : :off)
656
+ end
624
657
  end
625
658
  end
626
659
 
@@ -718,7 +751,7 @@ module Brrowser
718
751
  return unless @search_term
719
752
  lines = current_tab.content.to_s.split("\n")
720
753
  lines.each_with_index do |line, i|
721
- clean = line.gsub(/\e\[[0-9;]*m/, "")
754
+ clean = line.gsub(Rcurses::ANSI_RE, "")
722
755
  if clean.downcase.include?(@search_term.downcase)
723
756
  @search_matches << i
724
757
  end
@@ -799,9 +832,25 @@ module Brrowser
799
832
  lines[@highlighted_line] = lines[@highlighted_line]
800
833
  .gsub("\e[7m", "").gsub("\e[27m", "")
801
834
  end
802
- # Highlight current element line
835
+ # Highlight the specific element (not the whole line)
803
836
  if elem[:line] < lines.length
804
- lines[elem[:line]] = "\e[7m#{lines[elem[:line]]}\e[27m"
837
+ line = lines[elem[:line]]
838
+ highlighted = false
839
+ if elem[:type] == :link
840
+ link_text = Regexp.escape(elem[:data][:text])
841
+ idx = Regexp.escape("[#{elem[:data][:index]}]")
842
+ # Match the ANSI-wrapped link text + label as rendered:
843
+ # text.fg(81).ul => \e[4m\e[38;5;81m...text...\e[39m\e[24m
844
+ # "[N]".fg(39) => \e[38;5;39m[N]\e[39m
845
+ re = /(\e\[4m\e\[38;5;81m#{link_text}\e\[39m\e\[24m\e\[38;5;39m#{idx}\e\[39m)/
846
+ if line.match?(re)
847
+ lines[elem[:line]] = line.sub(re, "\e[7m\\1\e[27m")
848
+ highlighted = true
849
+ end
850
+ end
851
+ unless highlighted
852
+ lines[elem[:line]] = "\e[7m#{line}\e[27m"
853
+ end
805
854
  @highlighted_line = elem[:line]
806
855
  end
807
856
  current_tab.content = lines.join("\n")
@@ -829,9 +878,14 @@ module Brrowser
829
878
  navigate(@focused_element[:data][:href])
830
879
  return
831
880
  end
832
- # If focused on a form field, edit it
881
+ # If focused on a form field: edit if empty, submit if filled
833
882
  if @focused_element && @focused_element[:type] == :field
834
- edit_focused_field
883
+ field = @focused_element[:data]
884
+ if field[:value].to_s.strip.empty?
885
+ edit_focused_field
886
+ else
887
+ submit_focused_form
888
+ end
835
889
  return
836
890
  end
837
891
  return if current_tab.links.empty?
@@ -860,11 +914,56 @@ module Brrowser
860
914
  val = @pInput.ask("#{field[:placeholder] || field[:name]}: ", field[:value].to_s)
861
915
  if val && !val.strip.empty?
862
916
  field[:value] = val.strip
863
- @status_msg = "Set #{field[:name]} = #{val.strip}"
917
+ # Update the rendered content to show the value
918
+ if field[:line]
919
+ lines = current_tab.content.split("\n")
920
+ if field[:line] < lines.length
921
+ old_line = lines[field[:line]].gsub("\e[7m", "").gsub("\e[27m", "")
922
+ # Replace the ________ placeholder with the value
923
+ old_line = old_line.gsub(/________\]/, "#{val.strip}]")
924
+ lines[field[:line]] = "\e[7m#{old_line}\e[27m"
925
+ current_tab.content = lines.join("\n")
926
+ end
927
+ end
928
+ @status_msg = "#{field[:name]} = #{val.strip} (Enter to submit)"
929
+ refresh_main
864
930
  end
865
931
  refresh_cmd
866
932
  end
867
933
 
934
+ def submit_focused_form
935
+ return unless @focused_element && @focused_element[:type] == :field
936
+ form = @focused_element[:form]
937
+ return unless form
938
+
939
+ params = {}
940
+ form[:fields].each do |f|
941
+ next if f[:name].to_s.empty?
942
+ next if f[:type] == "submit"
943
+ params[f[:name]] = f[:value].to_s
944
+ end
945
+
946
+ action = form[:action]
947
+ action = current_tab.url if action.nil? || action.empty?
948
+ method = form[:method] == "post" ? :post : :get
949
+
950
+ @status_msg = "Submitting..."
951
+ refresh_cmd
952
+
953
+ if method == :post
954
+ result = @fetcher.fetch(action, method: :post, params: params)
955
+ else
956
+ query = URI.encode_www_form(params)
957
+ url = action.include?("?") ? "#{action}&#{query}" : "#{action}?#{query}"
958
+ result = @fetcher.fetch(url)
959
+ end
960
+
961
+ current_tab.navigate(action)
962
+ current_tab.url = result[:url]
963
+ render_page(result)
964
+ refresh_all
965
+ end
966
+
868
967
  def edit_field_in_editor
869
968
  return unless @focused_element && @focused_element[:type] == :field
870
969
  field = @focused_element[:data]
@@ -1016,6 +1115,37 @@ module Brrowser
1016
1115
  end
1017
1116
 
1018
1117
  # Help {{{2
1118
+ def show_homepage
1119
+ html_file = nil
1120
+ [File.join(__dir__, "..", "README.html"),
1121
+ File.join(__dir__, "..", "..", "README.html")].each do |path|
1122
+ if File.exist?(path)
1123
+ html_file = path
1124
+ break
1125
+ end
1126
+ end
1127
+
1128
+ unless html_file
1129
+ navigate("https://github.com/isene/brrowser")
1130
+ return
1131
+ end
1132
+
1133
+ html = File.read(html_file)
1134
+ current_tab.navigate("about:home") unless current_tab.url == "about:home"
1135
+ current_tab.url = "about:home"
1136
+ renderer = Renderer.new(@w - 2)
1137
+ page = renderer.render(html)
1138
+ current_tab.title = "brrowser"
1139
+ current_tab.content = page[:text]
1140
+ current_tab.links = page[:links]
1141
+ current_tab.images = []
1142
+ current_tab.forms = []
1143
+ current_tab.ix = 0
1144
+ @pMain.ix = 0
1145
+ @status_msg = ""
1146
+ refresh_all
1147
+ end
1148
+
1019
1149
  def show_help
1020
1150
  tab = Tab.new
1021
1151
  @tabs.insert(@current_tab + 1, tab)
@@ -1043,7 +1173,7 @@ module Brrowser
1043
1173
  api_key = File.read(key_file).strip
1044
1174
 
1045
1175
  # Get page text (strip ANSI)
1046
- text = current_tab.content.to_s.gsub(/\e\[[0-9;]*m/, "")
1176
+ text = current_tab.content.to_s.gsub(Rcurses::ANSI_RE, "")
1047
1177
  if text.strip.empty?
1048
1178
  @status_msg = "No content to summarize"
1049
1179
  refresh_cmd
@@ -1634,7 +1764,9 @@ module Brrowser
1634
1764
  sel = 0
1635
1765
 
1636
1766
  build = lambda do
1637
- lines = [" brrowser Preferences".b, ""]
1767
+ lines = [" brrowser Preferences".bd]
1768
+ lines << " h/l:change H/L:+/-10 Enter:edit ESC:close".fg(245)
1769
+ lines << ""
1638
1770
  PREF_SETTINGS.each_with_index do |(label, key, type), i|
1639
1771
  val = @conf[key]
1640
1772
  disp = case type
@@ -1644,18 +1776,19 @@ module Brrowser
1644
1776
  val.to_s
1645
1777
  end
1646
1778
  prefix = i == sel ? " \u25b6 ".fg(220) : " "
1647
- line = "#{prefix}#{label.ljust(16)} \u25c4 #{disp.to_s.ljust(12)} \u25b6"
1648
- line = line.r if i == sel
1779
+ line = "#{prefix}#{label.ljust(18)} \u25c4 #{disp.to_s.ljust(14)} \u25b6"
1780
+ line = line.rv if i == sel
1649
1781
  lines << line
1650
1782
  end
1651
- lines << ""
1652
- lines << " h/l:change Enter:edit q:save & close".fg(245)
1653
1783
  popup.text = lines.join("\n")
1654
- # Keep selected item visible
1655
- visible = ph - 3 # account for border
1656
- line_pos = sel + 2 # offset for header lines
1657
- popup.ix = [line_pos - visible + 1, 0].max if line_pos >= popup.ix + visible
1658
- popup.ix = line_pos if line_pos < popup.ix
1784
+ # Keep selected item visible in scrollable area
1785
+ visible = ph - 3
1786
+ line_pos = sel + 3 # offset for header + legend + blank
1787
+ if line_pos >= popup.ix + visible
1788
+ popup.ix = line_pos - visible + 1
1789
+ elsif line_pos < popup.ix
1790
+ popup.ix = [line_pos, 0].max
1791
+ end
1659
1792
  popup.refresh
1660
1793
  end
1661
1794
 
@@ -1740,6 +1873,7 @@ module Brrowser
1740
1873
  "t" => :tabopen_url,
1741
1874
  "H" => :go_back, "L" => :go_forward,
1742
1875
  "BACK" => :go_back,
1876
+ "DEL" => :go_forward,
1743
1877
  "r" => :reload,
1744
1878
  "J" => :next_tab, "K" => :prev_tab,
1745
1879
  "/" => :search_page,
@@ -1786,7 +1920,7 @@ module Brrowser
1786
1920
  end
1787
1921
 
1788
1922
  # Auto-show first visible image when downloads complete
1789
- if @termpix.supported? && !@showing_image
1923
+ if image_mode != :off && !@showing_image
1790
1924
  new_count = @img_cache.size
1791
1925
  if new_count > @last_img_count
1792
1926
  @last_img_count = new_count
@@ -1833,12 +1967,19 @@ module Brrowser
1833
1967
  end
1834
1968
 
1835
1969
  def toggle_images
1836
- if @showing_image
1837
- clear_images
1970
+ @conf["show_images"] = !@conf["show_images"]
1971
+ @image_mode_cache_conf = nil # Invalidate cache
1972
+ if !@conf["show_images"]
1973
+ # Force clear all images regardless of state
1974
+ @termpix.clear(x: @pMain.x, y: @pMain.y, width: @pMain.w, height: @pMain.h, term_width: @w, term_height: @h) if @termpix.supported?
1975
+ @pMain.full_refresh
1976
+ @showing_image = false
1838
1977
  @status_msg = "Images hidden"
1839
1978
  else
1979
+ # Re-trigger downloads if needed, then show
1980
+ start_image_downloads
1840
1981
  show_visible_image
1841
- @status_msg = @showing_image ? "Images shown" : "No images in view"
1982
+ @status_msg = @showing_image ? "Images shown" : "Downloading images..."
1842
1983
  end
1843
1984
  refresh_cmd
1844
1985
  end
@@ -10,11 +10,19 @@ 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
18
26
  # Handle local files
19
27
  if url.match?(%r{^file://})
20
28
  path = url.sub(%r{^file://}, "")
@@ -63,12 +71,14 @@ module Brrowser
63
71
  ct = response["content-type"] || ""
64
72
  body = response.body
65
73
  body = body.force_encoding("UTF-8") if ct.match?(/text|html|json|xml/)
66
- return {
74
+ result = {
67
75
  body: body,
68
76
  url: uri.to_s,
69
77
  content_type: ct,
70
78
  status: response.code.to_i
71
79
  }
80
+ cache_response(url, result) if method == :get
81
+ return result
72
82
  else
73
83
  return {
74
84
  body: "Error #{response.code}: #{response.message}",
@@ -89,6 +99,29 @@ module Brrowser
89
99
 
90
100
  private
91
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
+
92
125
  def store_cookies(uri, response)
93
126
  Array(response.get_fields("set-cookie")).each do |raw|
94
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
@@ -77,7 +78,7 @@ module Brrowser
77
78
  words = text.split(/( )/)
78
79
  words.each do |word|
79
80
  next if word.empty?
80
- visible = word.gsub(/\e\[[0-9;]*m/, "")
81
+ visible = word.gsub(ANSI_RE, "")
81
82
  if @col + visible.length > @width && @col > 0 && word != " "
82
83
  flush_line
83
84
  end
@@ -102,24 +103,24 @@ module Brrowser
102
103
  when "h1"
103
104
  ensure_blank_line
104
105
  text = collect_text(node)
105
- @line << text.b.fg(220)
106
+ @line << text.bd.fg(220)
106
107
  flush_line
107
- @line << ("═" * [text.gsub(/\e\[[0-9;]*m/, "").length, @width].min).fg(220)
108
+ @line << ("═" * [text.gsub(ANSI_RE, "").length, @width].min).fg(220)
108
109
  flush_line
109
110
  ensure_blank_line
110
111
  when "h2"
111
112
  ensure_blank_line
112
- inline_walk(node, :b, 214)
113
+ inline_walk(node, :bd, 214)
113
114
  flush_line
114
115
  ensure_blank_line
115
116
  when "h3"
116
117
  ensure_blank_line
117
- inline_walk(node, :b, 208)
118
+ inline_walk(node, :bd, 208)
118
119
  flush_line
119
120
  ensure_blank_line
120
121
  when "h4", "h5", "h6"
121
122
  ensure_blank_line
122
- inline_walk(node, :b, 252)
123
+ inline_walk(node, :bd, 252)
123
124
  flush_line
124
125
  ensure_blank_line
125
126
  when "p", "div", "section", "article", "aside", "main",
@@ -186,7 +187,7 @@ module Brrowser
186
187
  ensure_blank_line
187
188
  when "dt"
188
189
  flush_line if @col > 0
189
- inline_walk(node, :b, 252)
190
+ inline_walk(node, :bd, 252)
190
191
  flush_line
191
192
  when "dd"
192
193
  old_indent = @indent
@@ -211,7 +212,7 @@ module Brrowser
211
212
  walk(node)
212
213
  @in_link = nil
213
214
  else
214
- @line << text.fg(81).u
215
+ @line << text.fg(81).ul
215
216
  @col += text.length
216
217
  end
217
218
  label = "[#{link_index}]"
@@ -226,11 +227,11 @@ module Brrowser
226
227
  end
227
228
  end
228
229
  when "strong", "b"
229
- inline_walk(node, :b)
230
+ inline_walk(node, :bd)
230
231
  when "em", "i"
231
- inline_walk(node, :i)
232
+ inline_walk(node, :it)
232
233
  when "u"
233
- inline_walk(node, :u)
234
+ inline_walk(node, :ul)
234
235
  when "s", "strike", "del"
235
236
  text = collect_text(node)
236
237
  @line << text.fg(240)
@@ -252,7 +253,8 @@ module Brrowser
252
253
  src = resolve_url(src) unless src.empty?
253
254
  if src.match?(%r{youtube\.com/embed/|youtube-nocookie\.com/embed/})
254
255
  video_id = src[%r{/embed/([^?&/]+)}, 1]
255
- if video_id
256
+ playlist_id = src[/[?&]list=([^&]+)/, 1]
257
+ if video_id && video_id != "playlist" && video_id != "videoseries"
256
258
  ensure_blank_line
257
259
  # Add YouTube thumbnail as image
258
260
  thumb_url = "https://img.youtube.com/vi/#{video_id}/hqdefault.jpg"
@@ -266,10 +268,28 @@ module Brrowser
266
268
  link_index = @links.length
267
269
  link_line = @output.length
268
270
  @links << { index: link_index, href: video_url, text: "Watch on YouTube", line: link_line }
269
- @line << "\u25b6 Watch on YouTube".fg(196).b + "[#{link_index}]".fg(39)
271
+ @line << "\u25b6 Watch on YouTube".fg(196).bd + "[#{link_index}]".fg(39)
270
272
  @col += 19 + "[#{link_index}]".length
271
273
  flush_line
272
274
  ensure_blank_line
275
+ elsif playlist_id
276
+ ensure_blank_line
277
+ # Playlist embed: use oEmbed to resolve thumbnail
278
+ oembed_url = "https://www.youtube.com/oembed?url=https://www.youtube.com/playlist?list=#{playlist_id}&format=json"
279
+ flush_line if @col > 0
280
+ line_num = @output.length
281
+ @images << { src: oembed_url, alt: "YouTube playlist", line: line_num, height: IMG_RESERVE }
282
+ @output << "[YouTube playlist]".fg(236)
283
+ (IMG_RESERVE - 1).times { @output << "" }
284
+ # Add link to playlist
285
+ playlist_url = "https://www.youtube.com/playlist?list=#{playlist_id}"
286
+ link_index = @links.length
287
+ link_line = @output.length
288
+ @links << { index: link_index, href: playlist_url, text: "YouTube Playlist", line: link_line }
289
+ @line << "\u25b6 YouTube Playlist".fg(196).bd + "[#{link_index}]".fg(39)
290
+ @col += 20 + "[#{link_index}]".length
291
+ flush_line
292
+ ensure_blank_line
273
293
  end
274
294
  elsif !src.empty?
275
295
  ensure_blank_line
@@ -293,7 +313,7 @@ module Brrowser
293
313
  action = resolve_url(action) unless action.empty?
294
314
  method = (node["method"] || "get").downcase
295
315
  @current_form = { action: action, method: method, fields: [], line: @output.length }
296
- @line << "[Form]".fg(208).b
316
+ @line << "[Form]".fg(208).bd
297
317
  flush_line
298
318
  walk(node)
299
319
  # Check if form has password field
@@ -321,7 +341,7 @@ module Brrowser
321
341
  field = "[#{label}: ________]".fg(252)
322
342
  @line << field
323
343
  @col += label.length + 14
324
- @current_form[:fields] << { type: type, name: name, value: value, placeholder: placeholder } if @current_form
344
+ @current_form[:fields] << { type: type, name: name, value: value, placeholder: placeholder, line: @output.length } if @current_form
325
345
  end
326
346
  when "select"
327
347
  name = node["name"] || "select"
@@ -330,12 +350,12 @@ module Brrowser
330
350
  options = node.css("option").map { |o| { value: o["value"] || o.text, text: o.text.strip } }
331
351
  selected = node.at_css("option[selected]")
332
352
  val = selected ? (selected["value"] || selected.text) : options.first&.dig(:value)
333
- @current_form[:fields] << { type: "select", name: name, value: val.to_s, options: options } if @current_form
353
+ @current_form[:fields] << { type: "select", name: name, value: val.to_s, options: options, line: @output.length } if @current_form
334
354
  when "textarea"
335
355
  name = node["name"] || "text"
336
356
  @line << "[#{name}: ________]".fg(252)
337
357
  @col += name.length + 14
338
- @current_form[:fields] << { type: "textarea", name: name, value: node.text } if @current_form
358
+ @current_form[:fields] << { type: "textarea", name: name, value: node.text, line: @output.length } if @current_form
339
359
  when "label"
340
360
  walk(node)
341
361
  when "span"
@@ -455,7 +475,7 @@ module Brrowser
455
475
  cell.length > w ? cell[0...w] : cell.ljust(w)
456
476
  end
457
477
  line = parts.join(" \u2502 ".fg(240))
458
- line = line.b if ri == 0 && table_node.at_css("th")
478
+ line = line.bd if ri == 0 && table_node.at_css("th")
459
479
  @output << (" " * @indent) + line
460
480
 
461
481
  if ri == 0 && table_node.at_css("th")
@@ -473,7 +493,7 @@ module Brrowser
473
493
  row.each_with_index do |cell, ci|
474
494
  next if cell.strip.empty?
475
495
  label = headers && headers[ci] ? headers[ci] : "Col #{ci + 1}"
476
- @output << (" " * @indent) + "#{label}: ".fg(245).b + cell
496
+ @output << (" " * @indent) + "#{label}: ".fg(245).bd + cell
477
497
  end
478
498
  @output << (" " * @indent) + ("\u2500" * 20).fg(240)
479
499
  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.3
4
+ version: 0.1.5
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-04-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcurses
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '6.0'
19
+ version: '7.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '6.0'
26
+ version: '7.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: nokogiri
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -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