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 +4 -4
- data/README.html +95 -0
- data/bin/brrowser +193 -52
- data/lib/brrowser/fetcher.rb +34 -1
- data/lib/brrowser/renderer.rb +39 -19
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1502511b56f9175cb771635ca305bad614c6230d342c85b44205f4de29a79cc3
|
|
4
|
+
data.tar.gz: ec1f738931d99d5783b9a0e45f4085b785a3a70c3c6efb11cfd63da3f7b06d5b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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>< / ></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.
|
|
29
|
-
HOMEPAGE = "
|
|
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" => "
|
|
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).
|
|
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(
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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(
|
|
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
|
|
835
|
+
# Highlight the specific element (not the whole line)
|
|
803
836
|
if elem[:line] < lines.length
|
|
804
|
-
|
|
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
|
|
881
|
+
# If focused on a form field: edit if empty, submit if filled
|
|
833
882
|
if @focused_element && @focused_element[:type] == :field
|
|
834
|
-
|
|
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
|
-
|
|
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(
|
|
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".
|
|
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(
|
|
1648
|
-
line = line.
|
|
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
|
|
1656
|
-
line_pos = sel +
|
|
1657
|
-
|
|
1658
|
-
|
|
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
|
|
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
|
-
|
|
1837
|
-
|
|
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" : "
|
|
1982
|
+
@status_msg = @showing_image ? "Images shown" : "Downloading images..."
|
|
1842
1983
|
end
|
|
1843
1984
|
refresh_cmd
|
|
1844
1985
|
end
|
data/lib/brrowser/fetcher.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/brrowser/renderer.rb
CHANGED
|
@@ -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(
|
|
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.
|
|
106
|
+
@line << text.bd.fg(220)
|
|
106
107
|
flush_line
|
|
107
|
-
@line << ("═" * [text.gsub(
|
|
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, :
|
|
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, :
|
|
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, :
|
|
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, :
|
|
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).
|
|
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, :
|
|
230
|
+
inline_walk(node, :bd)
|
|
230
231
|
when "em", "i"
|
|
231
|
-
inline_walk(node, :
|
|
232
|
+
inline_walk(node, :it)
|
|
232
233
|
when "u"
|
|
233
|
-
inline_walk(node, :
|
|
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
|
-
|
|
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).
|
|
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).
|
|
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.
|
|
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).
|
|
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.
|
|
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-
|
|
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: '
|
|
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: '
|
|
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
|