brrowser 0.1.0 → 0.1.3

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: 305e3bb502291756cc66668c1788e23bd78aaae2eb398d7a3de19162ce6bb142
4
- data.tar.gz: 90c4f79eddcca96d1db71099c809cce2fd789d5d2aa94924553ba9a2ae7f3376
3
+ metadata.gz: 031bfa105348853d72db747c32797041f5661fe4ca66547f25a3dacb9a4b211a
4
+ data.tar.gz: f89cca0ebc7e8c7e48537ea62865a869df55d0d6213d6b6d574f2e60c5c8dd7c
5
5
  SHA512:
6
- metadata.gz: 5ac1afb5836953f07dfc2605aa9316291367364621bf00e8fa00a0832b9ebed17ed24ad3e6e3ccfd19b2463b36fdaf20197a19b4341675fe344cba4a49cce8e6
7
- data.tar.gz: 8c9423a9f9b8c32206072273ef2d37f05aaae74e0ed3c9bbdb069b9627d7346b7d3a201db32ee515c802077d611cc7ee5282d6793cf886203abdbbc976ec254b
6
+ metadata.gz: 45309f7af0f927a1fcfd20614826875d3fa7cbb78f8103f43f0ca2c5092a8df5bea05e654d2645fc541e40f5047a259ec7d2416e623727b42eaebabee56e08bd
7
+ data.tar.gz: ea797828fd0810a4086f884e8c3f6cc1c734ec63030e0fcc7fc7b329c71e899c9c7c469f5c0baa6307f3ddabf2e994c44f2da94a09dfc06905c6807a9058e8ff
data/README.md CHANGED
@@ -22,7 +22,7 @@ A terminal web browser combining w3m-style rendering with qutebrowser-style keyb
22
22
  - Ad blocking via StevenBlack hosts list
23
23
 
24
24
  **Navigation:**
25
- - `j`/`k` or arrows to scroll, `gg`/`G` for top/bottom
25
+ - `j`/`k` or arrows to scroll, `<`/`>` to scroll left/right, `gg`/`G` for top/bottom
26
26
  - `TAB`/`S-TAB` to cycle through links and form fields (highlighted with reverse video)
27
27
  - `Enter` to follow focused link or edit focused field
28
28
  - `o` to open URL, `t` to open in new tab, `O` to edit current URL
@@ -60,12 +60,13 @@ A terminal web browser combining w3m-style rendering with qutebrowser-style keyb
60
60
  - `Ctrl-l` to force redraw
61
61
 
62
62
  **Other:**
63
- - `:download URL` to save files to ~/Downloads
63
+ - `:download URL` to save files (configurable download folder)
64
64
  - Binary files prompt: open with `xdg-open`, download, or cancel
65
- - `P` for preferences popup (image mode, colors, homepage, search engine)
65
+ - `P` for preferences (image mode, colors, homepage, search engine, download folder)
66
66
  - `?` for built-in help page
67
+ - `:about` opens the project GitHub page
67
68
  - Cookie persistence across sessions
68
- - Configurable colors for all UI elements
69
+ - Configurable homepage (defaults to project page)
69
70
 
70
71
  ## Installation
71
72
 
@@ -98,6 +99,7 @@ brrowser isene.org # Auto-adds https://
98
99
  | Key | Action |
99
100
  |-----|--------|
100
101
  | `j` / `k` / arrows | Scroll up/down |
102
+ | `<` / `>` | Scroll left/right |
101
103
  | `Left` / `Right` | Previous/next tab |
102
104
  | `gg` / `G` | Top / bottom of page |
103
105
  | `Ctrl-d` / `Ctrl-u` | Half page down/up |
@@ -114,8 +116,8 @@ brrowser isene.org # Auto-adds https://
114
116
  | `/` | Search page |
115
117
  | `n` / `N` | Next/previous search match |
116
118
  | `b` / `B` | Bookmark / show bookmarks |
117
- | `m` + `0-9` | Set quickmark |
118
- | `'` + `0-9` | Go to quickmark |
119
+ | `m` + key | Set quickmark (0-9, a-z) |
120
+ | `'` + key | Go to quickmark (0-9, a-z) |
119
121
  | `f` | Fill and submit form |
120
122
  | `y` / `Y` | Copy URL / copy focused element |
121
123
  | `e` | Edit page source in $EDITOR |
data/bin/brrowser CHANGED
@@ -25,16 +25,17 @@ end
25
25
 
26
26
  # brrowser {{{1
27
27
  module Brrowser
28
- VERSION = "0.1.0"
29
- HOMEPAGE = "about:blank"
28
+ VERSION = "0.1.3"
29
+ HOMEPAGE = "https://github.com/isene/brrowser"
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" => "about:blank",
36
+ "homepage" => "https://github.com/isene/brrowser",
37
37
  "search_engine" => "g",
38
+ "download_dir" => "~/Downloads",
38
39
  "info_fg" => 252,
39
40
  "info_bg" => 236,
40
41
  "tab_fg" => 252,
@@ -49,6 +50,7 @@ module Brrowser
49
50
  "heading_h1" => 220,
50
51
  "heading_h2" => 214,
51
52
  "heading_h3" => 208,
53
+ "match_site_colors" => false,
52
54
  }
53
55
 
54
56
  HELP_HTML = <<~HTML
@@ -59,8 +61,8 @@ module Brrowser
59
61
  <tr><th>Key</th><th>Action</th></tr>
60
62
  <tr><td>j / Down</td><td>Scroll down</td></tr>
61
63
  <tr><td>k / Up</td><td>Scroll up</td></tr>
62
- <tr><td>&lt; / h</td><td>Scroll left</td></tr>
63
- <tr><td>&gt; / l</td><td>Scroll right</td></tr>
64
+ <tr><td>&lt;</td><td>Scroll left</td></tr>
65
+ <tr><td>&gt;</td><td>Scroll right</td></tr>
64
66
  <tr><td>Left</td><td>Previous tab</td></tr>
65
67
  <tr><td>Right</td><td>Next tab</td></tr>
66
68
  <tr><td>gg</td><td>Go to top</td></tr>
@@ -123,8 +125,8 @@ module Brrowser
123
125
  <tr><th>Key</th><th>Action</th></tr>
124
126
  <tr><td>b</td><td>Bookmark current page</td></tr>
125
127
  <tr><td>B</td><td>Show bookmarks</td></tr>
126
- <tr><td>m + 0-9</td><td>Set quickmark</td></tr>
127
- <tr><td>' + 0-9</td><td>Go to quickmark</td></tr>
128
+ <tr><td>m + key</td><td>Set quickmark (0-9, a-z)</td></tr>
129
+ <tr><td>' + key</td><td>Go to quickmark (0-9, a-z)</td></tr>
128
130
  <tr><td>i</td><td>Toggle images</td></tr>
129
131
  <tr><td>p</td><td>Show stored password for site</td></tr>
130
132
  <tr><td>I</td><td>AI summary of page</td></tr>
@@ -162,7 +164,7 @@ module Brrowser
162
164
  @img_dir = File.join(Dir.tmpdir, "brrowser_images_#{Process.pid}")
163
165
  Dir.mkdir(@img_dir) unless Dir.exist?(@img_dir)
164
166
 
165
- main_loop(url || HOMEPAGE)
167
+ main_loop(url || @conf["homepage"] || HOMEPAGE)
166
168
  end
167
169
 
168
170
  # Pane layout {{{2
@@ -245,6 +247,15 @@ module Brrowser
245
247
  # Navigation {{{2
246
248
  def navigate(url)
247
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
248
259
  @status_msg = "Loading #{url}..."
249
260
  refresh_cmd
250
261
 
@@ -265,6 +276,7 @@ module Brrowser
265
276
  @focus_index = -1
266
277
  @focused_element = nil
267
278
  @highlighted_line = nil
279
+ @h_scroll = 0
268
280
 
269
281
  # Handle image URLs: display the image instead of raw binary
270
282
  if ct.match?(/image\//) || result[:url].to_s.match?(/\.(png|jpe?g|gif|bmp|webp|svg|ico)(\?|$)/i)
@@ -305,6 +317,13 @@ module Brrowser
305
317
  current_tab.links = page[:links]
306
318
  current_tab.images = page[:images]
307
319
  current_tab.forms = page[:forms]
320
+ colors = page[:colors]
321
+ # If no colors found in HTML, try first external stylesheet
322
+ if @conf["match_site_colors"] && !colors[:bg] && !colors[:fg]
323
+ colors = fetch_external_css_colors(result[:body], result[:url])
324
+ end
325
+ current_tab.instance_variable_set(:@site_colors, colors)
326
+ apply_site_colors(colors)
308
327
  check_autofill
309
328
  else
310
329
  current_tab.title = result[:url]
@@ -312,6 +331,8 @@ module Brrowser
312
331
  current_tab.links = []
313
332
  current_tab.images = []
314
333
  current_tab.forms = []
334
+ @pMain.fg = @conf["content_fg"]
335
+ @pMain.bg = @conf["content_bg"]
315
336
  end
316
337
  current_tab.ix = 0
317
338
  @pMain.ix = 0
@@ -346,7 +367,7 @@ module Brrowser
346
367
  end
347
368
 
348
369
  def re_render
349
- return unless current_tab.url && current_tab.url != HOMEPAGE
370
+ return unless current_tab.url && !current_tab.url.start_with?("about:")
350
371
  result = @fetcher.fetch(current_tab.url)
351
372
  render_page(result)
352
373
  end
@@ -392,7 +413,7 @@ module Brrowser
392
413
  end
393
414
 
394
415
  def resolve_search(input)
395
- return input if input.match?(%r{^https?://}) || input.match?(%r{^file://})
416
+ return input if input.match?(%r{^https?://}) || input.match?(%r{^file://}) || input.match?(%r{^about:})
396
417
  return input if input.include?(".") && !input.include?(" ")
397
418
 
398
419
  parts = input.split(" ", 2)
@@ -441,7 +462,8 @@ module Brrowser
441
462
  end
442
463
 
443
464
  def refresh_main
444
- @pMain.text = current_tab.content.to_s
465
+ @original_content = current_tab.content.to_s
466
+ @pMain.text = @original_content
445
467
  @pMain.refresh
446
468
  end
447
469
 
@@ -457,6 +479,27 @@ module Brrowser
457
479
  end
458
480
 
459
481
  # Scrolling {{{2
482
+ def scroll_left
483
+ @h_scroll = [(@h_scroll || 0) - 10, 0].max
484
+ apply_h_scroll
485
+ end
486
+
487
+ def scroll_right
488
+ @h_scroll = (@h_scroll || 0) + 10
489
+ apply_h_scroll
490
+ end
491
+
492
+ def apply_h_scroll
493
+ return unless @h_scroll && @h_scroll > 0 && @original_content
494
+ lines = @original_content.split("\n")
495
+ shifted = lines.map do |line|
496
+ pure = line.gsub(/\e\[[0-9;]*m/, "")
497
+ pure.length > @h_scroll ? line[@h_scroll..] || "" : ""
498
+ end
499
+ @pMain.text = shifted.join("\n")
500
+ @pMain.full_refresh
501
+ end
502
+
460
503
  def scroll_down
461
504
  had_images = @showing_image
462
505
  clear_images if @showing_image
@@ -1170,7 +1213,7 @@ module Brrowser
1170
1213
  if result[:status] == 200
1171
1214
  filename = File.basename(URI.parse(result[:url]).path)
1172
1215
  filename = "download" if filename.empty?
1173
- dir = File.expand_path("~/Downloads")
1216
+ dir = File.expand_path(@conf["download_dir"] || "~/Downloads")
1174
1217
  Dir.mkdir(dir) unless Dir.exist?(dir)
1175
1218
  path = File.join(dir, filename)
1176
1219
  # Avoid overwriting
@@ -1207,14 +1250,14 @@ module Brrowser
1207
1250
  end
1208
1251
 
1209
1252
  def set_quickmark
1210
- @status_msg = "Set quickmark (0-9):"
1253
+ @status_msg = "Set quickmark (0-9, a-z):"
1211
1254
  refresh_cmd
1212
1255
  chr = getchr
1213
- if chr && chr.match?(/^[0-9]$/)
1256
+ if chr && chr.length == 1 && chr.match?(/^[0-9a-z]$/)
1214
1257
  load_quickmarks
1215
1258
  @quickmarks[chr] = { "url" => current_tab.url, "title" => current_tab.title.to_s }
1216
1259
  save_quickmarks
1217
- @status_msg = "Quickmark #{chr} set: #{current_tab.title}"
1260
+ @status_msg = "Quickmark '#{chr}' set: #{current_tab.title}"
1218
1261
  else
1219
1262
  @status_msg = "Cancelled"
1220
1263
  end
@@ -1222,15 +1265,15 @@ module Brrowser
1222
1265
  end
1223
1266
 
1224
1267
  def goto_quickmark
1225
- @status_msg = "Go to quickmark (0-9):"
1268
+ @status_msg = "Go to quickmark (0-9, a-z):"
1226
1269
  refresh_cmd
1227
1270
  chr = getchr
1228
- if chr && chr.match?(/^[0-9]$/)
1271
+ if chr && chr.length == 1 && chr.match?(/^[0-9a-z]$/)
1229
1272
  load_quickmarks
1230
1273
  if @quickmarks[chr]
1231
1274
  navigate(@quickmarks[chr]["url"])
1232
1275
  else
1233
- @status_msg = "No quickmark #{chr}"
1276
+ @status_msg = "No quickmark '#{chr}'"
1234
1277
  refresh_cmd
1235
1278
  end
1236
1279
  else
@@ -1470,12 +1513,100 @@ module Brrowser
1470
1513
  refresh_all
1471
1514
  end
1472
1515
 
1516
+ def apply_site_colors(colors = nil)
1517
+ colors ||= current_tab.instance_variable_get(:@site_colors) rescue nil
1518
+ if colors && @conf["match_site_colors"]
1519
+ bg = colors[:bg] || @conf["content_bg"]
1520
+ fg = colors[:fg] || @conf["content_fg"]
1521
+ # Ensure high contrast: if bg and fg are too close, pick white or black
1522
+ fg = high_contrast_fg(bg, fg)
1523
+ @pMain.bg = bg
1524
+ @pMain.fg = fg
1525
+ else
1526
+ @pMain.bg = @conf["content_bg"]
1527
+ @pMain.fg = @conf["content_fg"]
1528
+ end
1529
+ end
1530
+
1531
+ # Map xterm-256 color to approximate luminance (0.0 dark, 1.0 bright)
1532
+ def color_luminance(c)
1533
+ r, g, b = xterm256_to_rgb(c)
1534
+ (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
1535
+ end
1536
+
1537
+ def xterm256_to_rgb(c)
1538
+ if c < 16
1539
+ # Standard colors: approximate
1540
+ basic = [
1541
+ [0,0,0],[128,0,0],[0,128,0],[128,128,0],[0,0,128],[128,0,128],[0,128,128],[192,192,192],
1542
+ [128,128,128],[255,0,0],[0,255,0],[255,255,0],[0,0,255],[255,0,255],[0,255,255],[255,255,255]
1543
+ ]
1544
+ basic[c]
1545
+ elsif c < 232
1546
+ # 6x6x6 color cube
1547
+ c -= 16
1548
+ b = (c % 6) * 51
1549
+ g = ((c / 6) % 6) * 51
1550
+ r = (c / 36) * 51
1551
+ [r, g, b]
1552
+ else
1553
+ # Grayscale ramp
1554
+ v = (c - 232) * 10 + 8
1555
+ [v, v, v]
1556
+ end
1557
+ end
1558
+
1559
+ def high_contrast_fg(bg, fg)
1560
+ bg_lum = color_luminance(bg)
1561
+ fg_lum = color_luminance(fg)
1562
+ contrast = (bg_lum - fg_lum).abs
1563
+ if contrast < 0.3
1564
+ # Not enough contrast: use white for dark bg, black for light bg
1565
+ bg_lum < 0.5 ? 255 : 0
1566
+ else
1567
+ fg
1568
+ end
1569
+ end
1570
+
1571
+ def fetch_external_css_colors(html, base_url)
1572
+ doc = Nokogiri::HTML(html)
1573
+ # Find first local stylesheet
1574
+ doc.css("link[rel=stylesheet]").each do |link|
1575
+ href = link["href"].to_s
1576
+ next if href.empty?
1577
+ next if href.match?(/fonts\.googleapis|font-awesome|calendly|bootstrap/i)
1578
+ href = "https:#{href}" if href.start_with?("//")
1579
+ href = URI.join(base_url, href).to_s unless href.match?(%r{^https?://})
1580
+ begin
1581
+ css_result = @fetcher.fetch(href)
1582
+ next unless css_result[:status] == 200
1583
+ css = css_result[:body].to_s.force_encoding("UTF-8").scrub("")
1584
+ bg = nil; fg = nil
1585
+ # Check body { } rule
1586
+ if css.match(/body\s*\{([^}]+)\}/m)
1587
+ block = $1
1588
+ bg_match = block[/background(?:-color)?\s*:\s*([^;]+)/, 1]
1589
+ fg_match = block[/(?<!background-)color\s*:\s*([^;]+)/, 1]
1590
+ r = Renderer.new(80)
1591
+ bg = r.send(:parse_css_color, bg_match.strip) if bg_match
1592
+ fg = r.send(:parse_css_color, fg_match.strip) if fg_match
1593
+ end
1594
+ return { bg: bg, fg: fg } if bg || fg
1595
+ rescue
1596
+ next
1597
+ end
1598
+ end
1599
+ { bg: nil, fg: nil }
1600
+ end
1601
+
1473
1602
  # Preferences {{{2
1474
1603
  PREF_SETTINGS = [
1604
+ ["Match site colors", "match_site_colors", [true, false]],
1475
1605
  ["Image mode", "image_mode", %w[auto termpix ascii off]],
1476
1606
  ["Show images", "show_images", [true, false]],
1477
1607
  ["Homepage", "homepage", :text],
1478
1608
  ["Search engine", "search_engine", %w[g ddg w]],
1609
+ ["Download folder", "download_dir", :text],
1479
1610
  ["Info bar fg", "info_fg", :color],
1480
1611
  ["Info bar bg", "info_bg", :color],
1481
1612
  ["Tab bar fg", "tab_fg", :color],
@@ -1493,12 +1624,13 @@ module Brrowser
1493
1624
  ]
1494
1625
 
1495
1626
  def show_preferences
1496
- pw = 44
1497
- ph = PREF_SETTINGS.length + 4
1627
+ pw = 56
1628
+ ph = [PREF_SETTINGS.length + 4, @h - 4].min
1498
1629
  px = (@w - pw) / 2
1499
1630
  py = (@h - ph) / 2
1500
1631
  popup = Pane.new(px, py, pw, ph, 252, 234)
1501
1632
  popup.border = true
1633
+ popup.scroll = true
1502
1634
  sel = 0
1503
1635
 
1504
1636
  build = lambda do
@@ -1519,6 +1651,11 @@ module Brrowser
1519
1651
  lines << ""
1520
1652
  lines << " h/l:change Enter:edit q:save & close".fg(245)
1521
1653
  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
1522
1659
  popup.refresh
1523
1660
  end
1524
1661
 
@@ -1530,6 +1667,7 @@ module Brrowser
1530
1667
  when "ESC"
1531
1668
  save_config
1532
1669
  apply_colors
1670
+ apply_site_colors
1533
1671
  break
1534
1672
  when "j", "DOWN"
1535
1673
  sel = (sel + 1) % PREF_SETTINGS.length
@@ -1595,6 +1733,7 @@ module Brrowser
1595
1733
  "j" => :scroll_down, "k" => :scroll_up,
1596
1734
  "DOWN" => :scroll_down, "UP" => :scroll_up,
1597
1735
  "LEFT" => :prev_tab, "RIGHT" => :next_tab,
1736
+ "<" => :scroll_left, ">" => :scroll_right,
1598
1737
  "G" => :go_bottom,
1599
1738
  "d" => :close_tab, "u" => :undo_close_tab,
1600
1739
  "o" => :open_url,
@@ -15,6 +15,13 @@ module Brrowser
15
15
  end
16
16
 
17
17
  def fetch(url, method: :get, params: nil)
18
+ # Handle local files
19
+ if url.match?(%r{^file://})
20
+ path = url.sub(%r{^file://}, "")
21
+ body = File.exist?(path) ? File.read(path) : "File not found: #{path}"
22
+ ct = path.match?(/\.html?$/i) ? "text/html" : "text/plain"
23
+ return { body: body, url: url, content_type: ct, status: 200 }
24
+ end
18
25
  url = "https://#{url}" unless url.match?(%r{^https?://})
19
26
  uri = URI.parse(url)
20
27
  redirects = 0
@@ -45,7 +45,8 @@ module Brrowser
45
45
  walk(body)
46
46
  flush_line
47
47
 
48
- { text: @output.join("\n"), links: @links, images: @images, forms: @forms, title: title }
48
+ site_colors = extract_site_colors(doc)
49
+ { text: @output.join("\n"), links: @links, images: @images, forms: @forms, title: title, colors: site_colors }
49
50
  end
50
51
 
51
52
  private
@@ -281,7 +282,11 @@ module Brrowser
281
282
  ensure_blank_line
282
283
  end
283
284
  when "table"
284
- render_table(node)
285
+ if layout_table?(node)
286
+ walk(node) # Treat layout tables as normal content
287
+ else
288
+ render_table(node)
289
+ end
285
290
  when "form"
286
291
  ensure_blank_line
287
292
  action = node["action"] || ""
@@ -335,6 +340,16 @@ module Brrowser
335
340
  walk(node)
336
341
  when "span"
337
342
  walk(node)
343
+ when "td", "th"
344
+ # Reached here from layout table walk
345
+ flush_line if @col > 0
346
+ walk(node)
347
+ flush_line if @col > 0
348
+ when "tr"
349
+ walk(node)
350
+ flush_line if @col > 0
351
+ when "tbody", "thead", "tfoot", "caption"
352
+ walk(node)
338
353
  else
339
354
  walk(node)
340
355
  end
@@ -384,6 +399,22 @@ module Brrowser
384
399
  end
385
400
  end
386
401
 
402
+ def layout_table?(node)
403
+ # Nested tables = layout
404
+ return true if node.css("table").any?
405
+ # Single-column tables = layout
406
+ first_row = node.at_css("tr")
407
+ return true if first_row && first_row.css("td, th").length <= 1
408
+ # Tables containing block elements in cells = layout
409
+ node.css("td, th").first(4).each do |cell|
410
+ return true if cell.at_css("p, div, h1, h2, h3, h4, ul, ol, table, blockquote")
411
+ end
412
+ # Width 100% = layout
413
+ style = node["style"].to_s + " " + (node["width"].to_s)
414
+ return true if style.match?(/100%/)
415
+ false
416
+ end
417
+
387
418
  def render_table(table_node)
388
419
  ensure_blank_line
389
420
  rows = []
@@ -437,9 +468,7 @@ module Brrowser
437
468
  end
438
469
 
439
470
  def render_table_vertical(rows, table_node)
440
- # Render each row as a block with label: value pairs
441
471
  headers = table_node.css("th").any? ? rows.shift : nil
442
-
443
472
  rows.each do |row|
444
473
  row.each_with_index do |cell, ci|
445
474
  next if cell.strip.empty?
@@ -449,5 +478,117 @@ module Brrowser
449
478
  @output << (" " * @indent) + ("\u2500" * 20).fg(240)
450
479
  end
451
480
  end
481
+
482
+ # Site color extraction {{{
483
+ def extract_site_colors(doc)
484
+ bg = nil; fg = nil
485
+
486
+ # 1. HTML attributes (old-school)
487
+ body = doc.at_css("body")
488
+ if body
489
+ bg = parse_css_color(body["bgcolor"]) if body["bgcolor"]
490
+ fg = parse_css_color(body["text"]) if body["text"]
491
+ end
492
+
493
+ # 2. Inline styles on body/html
494
+ [body, doc.at_css("html")].compact.each do |node|
495
+ style = node["style"].to_s
496
+ next if style.empty?
497
+ bg ||= extract_css_color(style, /background(?:-color)?\s*:\s*([^;]+)/)
498
+ fg ||= extract_css_color(style, /(?<!background-)color\s*:\s*([^;]+)/)
499
+ end
500
+
501
+ # 3. Embedded <style> blocks - check body, html, :root rules
502
+ unless bg && fg
503
+ doc.css("style").each do |style_node|
504
+ css = style_node.text
505
+ %w[body html :root .page .site .wrapper #page #wrapper #content].each do |sel|
506
+ pattern = /#{Regexp.escape(sel)}\s*\{([^}]+)\}/m
507
+ if css.match(pattern)
508
+ block = $1
509
+ bg ||= extract_css_color(block, /background(?:-color)?\s*:\s*([^;]+)/)
510
+ fg ||= extract_css_color(block, /(?<!background-)color\s*:\s*([^;]+)/)
511
+ end
512
+ end
513
+ # CSS variables: --bg-color, --background, --text-color, etc.
514
+ css.scan(/--(bg|background|main-bg|site-bg|page-bg)[^:]*:\s*([^;}\n]+)/) do |_, val|
515
+ bg ||= parse_css_color(val.strip)
516
+ end
517
+ css.scan(/--(fg|text|color|main-color|text-color|font-color)[^:]*:\s*([^;}\n]+)/) do |_, val|
518
+ fg ||= parse_css_color(val.strip)
519
+ end
520
+ end
521
+ end
522
+
523
+ # 4. Meta theme-color
524
+ unless bg
525
+ meta = doc.at_css('meta[name="theme-color"]')
526
+ bg = parse_css_color(meta["content"]) if meta && meta["content"]
527
+ end
528
+
529
+ { bg: bg, fg: fg }
530
+ end
531
+
532
+ def extract_css_color(css_text, regex)
533
+ return nil unless css_text.match?(regex)
534
+ val = css_text[regex, 1]&.strip
535
+ parse_css_color(val)
536
+ end
537
+
538
+ def parse_css_color(val)
539
+ return nil unless val
540
+ val = val.strip.downcase
541
+
542
+ # Hex colors
543
+ if val.match?(/^#[0-9a-f]{6}$/)
544
+ return hex_to_256(val)
545
+ elsif val.match?(/^#[0-9a-f]{3}$/)
546
+ expanded = "##{val[1]*2}#{val[2]*2}#{val[3]*2}"
547
+ return hex_to_256(expanded)
548
+ end
549
+
550
+ # rgb(r, g, b)
551
+ if val.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
552
+ r, g, b = $1.to_i, $2.to_i, $3.to_i
553
+ return rgb_to_256(r, g, b)
554
+ end
555
+
556
+ # Named colors (common ones)
557
+ named = {
558
+ "white" => 15, "black" => 0, "red" => 1, "green" => 2,
559
+ "blue" => 4, "yellow" => 3, "cyan" => 6, "magenta" => 5,
560
+ "gray" => 245, "grey" => 245, "silver" => 250,
561
+ "darkgray" => 238, "darkgrey" => 238,
562
+ "lightgray" => 252, "lightgrey" => 252,
563
+ "navy" => 17, "teal" => 30, "maroon" => 1,
564
+ "olive" => 3, "purple" => 5, "aqua" => 14,
565
+ "orange" => 208, "pink" => 218,
566
+ "whitesmoke" => 255, "ghostwhite" => 255,
567
+ "aliceblue" => 153, "ivory" => 255,
568
+ }
569
+ named[val.gsub(/\s/, "")]
570
+ end
571
+
572
+ def hex_to_256(hex)
573
+ r = hex[1..2].to_i(16)
574
+ g = hex[3..4].to_i(16)
575
+ b = hex[5..6].to_i(16)
576
+ rgb_to_256(r, g, b)
577
+ end
578
+
579
+ def rgb_to_256(r, g, b)
580
+ # Check grayscale ramp (232-255) first
581
+ if r == g && g == b
582
+ return 16 if r < 8
583
+ return 231 if r > 248
584
+ return (((r - 8).to_f / 247 * 24).round + 232)
585
+ end
586
+ # Map to 6x6x6 color cube (16-231)
587
+ ri = ((r.to_f / 255) * 5).round
588
+ gi = ((g.to_f / 255) * 5).round
589
+ bi = ((b.to_f / 255) * 5).round
590
+ 16 + (36 * ri) + (6 * gi) + bi
591
+ end
592
+ # }}}
452
593
  end
453
594
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brrowser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
@@ -42,14 +42,14 @@ dependencies:
42
42
  name: termpix
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - ">="
46
46
  - !ruby/object:Gem::Version
47
47
  version: '0.3'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0.3'
55
55
  description: A terminal web browser combining w3m-style rendering with qutebrowser-style