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 +4 -4
- data/README.html +95 -0
- data/bin/brrowser +267 -45
- data/lib/brrowser/fetcher.rb +41 -1
- data/lib/brrowser/renderer.rb +151 -9
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3fa096f37a4682bd1edb910013f6a6273acd8f46ee52304eaf140a4963527a5
|
|
4
|
+
data.tar.gz: 0b266827e97d189f85cbdc4bd33318419c03a1fc7e684b5b7eb7d0460f5093ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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>< / ></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,
|
|
@@ -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(
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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(
|
|
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
|
|
849
|
+
# If focused on a form field: edit if empty, submit if filled
|
|
814
850
|
if @focused_element && @focused_element[:type] == :field
|
|
815
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
1724
|
-
|
|
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" : "
|
|
1950
|
+
@status_msg = @showing_image ? "Images shown" : "Downloading images..."
|
|
1729
1951
|
end
|
|
1730
1952
|
refresh_cmd
|
|
1731
1953
|
end
|
data/lib/brrowser/fetcher.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
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
|
|
@@ -45,7 +46,8 @@ module Brrowser
|
|
|
45
46
|
walk(body)
|
|
46
47
|
flush_line
|
|
47
48
|
|
|
48
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|