brrowser 0.1.0
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 +7 -0
- data/LICENSE +24 -0
- data/README.md +170 -0
- data/bin/brrowser +1715 -0
- data/img/brrowser.svg +79 -0
- data/img/screenshot.png +0 -0
- data/lib/brrowser/fetcher.rb +111 -0
- data/lib/brrowser/renderer.rb +453 -0
- data/lib/brrowser/tab.rb +48 -0
- metadata +96 -0
data/bin/brrowser
ADDED
|
@@ -0,0 +1,1715 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: utf-8
|
|
3
|
+
# brrowser - Terminal web browser with vim-style keybindings
|
|
4
|
+
# License: Unlicense
|
|
5
|
+
|
|
6
|
+
require 'rcurses'
|
|
7
|
+
require 'io/wait'
|
|
8
|
+
require 'termpix'
|
|
9
|
+
require 'tmpdir'
|
|
10
|
+
require 'digest'
|
|
11
|
+
require 'fileutils'
|
|
12
|
+
require 'yaml'
|
|
13
|
+
require 'shellwords'
|
|
14
|
+
require 'set'
|
|
15
|
+
require 'json'
|
|
16
|
+
|
|
17
|
+
require_relative '../lib/brrowser/tab'
|
|
18
|
+
require_relative '../lib/brrowser/fetcher'
|
|
19
|
+
require_relative '../lib/brrowser/renderer'
|
|
20
|
+
|
|
21
|
+
class Object
|
|
22
|
+
include Rcurses
|
|
23
|
+
include Rcurses::Input
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# brrowser {{{1
|
|
27
|
+
module Brrowser
|
|
28
|
+
VERSION = "0.1.0"
|
|
29
|
+
HOMEPAGE = "about:blank"
|
|
30
|
+
CONFIG_DIR = File.join(Dir.home, ".brrowser")
|
|
31
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
|
32
|
+
|
|
33
|
+
DEFAULTS = {
|
|
34
|
+
"show_images" => true,
|
|
35
|
+
"image_mode" => "auto", # auto, termpix, ascii, off
|
|
36
|
+
"homepage" => "about:blank",
|
|
37
|
+
"search_engine" => "g",
|
|
38
|
+
"info_fg" => 252,
|
|
39
|
+
"info_bg" => 236,
|
|
40
|
+
"tab_fg" => 252,
|
|
41
|
+
"tab_bg" => 234,
|
|
42
|
+
"tab_active" => 220,
|
|
43
|
+
"content_fg" => 255,
|
|
44
|
+
"content_bg" => 0,
|
|
45
|
+
"cmd_fg" => 252,
|
|
46
|
+
"cmd_bg" => 234,
|
|
47
|
+
"link_color" => 81,
|
|
48
|
+
"link_num" => 39,
|
|
49
|
+
"heading_h1" => 220,
|
|
50
|
+
"heading_h2" => 214,
|
|
51
|
+
"heading_h3" => 208,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
HELP_HTML = <<~HTML
|
|
55
|
+
<html><body>
|
|
56
|
+
<h1>brrowser help</h1>
|
|
57
|
+
<h2>Navigation</h2>
|
|
58
|
+
<table>
|
|
59
|
+
<tr><th>Key</th><th>Action</th></tr>
|
|
60
|
+
<tr><td>j / Down</td><td>Scroll down</td></tr>
|
|
61
|
+
<tr><td>k / Up</td><td>Scroll up</td></tr>
|
|
62
|
+
<tr><td>< / h</td><td>Scroll left</td></tr>
|
|
63
|
+
<tr><td>> / l</td><td>Scroll right</td></tr>
|
|
64
|
+
<tr><td>Left</td><td>Previous tab</td></tr>
|
|
65
|
+
<tr><td>Right</td><td>Next tab</td></tr>
|
|
66
|
+
<tr><td>gg</td><td>Go to top</td></tr>
|
|
67
|
+
<tr><td>G</td><td>Go to bottom</td></tr>
|
|
68
|
+
<tr><td>Ctrl-d</td><td>Half page down</td></tr>
|
|
69
|
+
<tr><td>Ctrl-u</td><td>Half page up</td></tr>
|
|
70
|
+
<tr><td>PgDn / Space</td><td>Page down</td></tr>
|
|
71
|
+
<tr><td>PgUp</td><td>Page up</td></tr>
|
|
72
|
+
</table>
|
|
73
|
+
<h2>Browsing</h2>
|
|
74
|
+
<table>
|
|
75
|
+
<tr><th>Key</th><th>Action</th></tr>
|
|
76
|
+
<tr><td>o</td><td>Open URL</td></tr>
|
|
77
|
+
<tr><td>O</td><td>Open URL (pre-filled with current)</td></tr>
|
|
78
|
+
<tr><td>t</td><td>Open URL in new tab</td></tr>
|
|
79
|
+
<tr><td>Tab</td><td>Jump to next link/field</td></tr>
|
|
80
|
+
<tr><td>Shift-Tab</td><td>Jump to previous link/field</td></tr>
|
|
81
|
+
<tr><td>Enter</td><td>Follow link or edit field</td></tr>
|
|
82
|
+
<tr><td>y</td><td>Copy page URL to clipboard</td></tr>
|
|
83
|
+
<tr><td>Y</td><td>Copy focused link URL or field value</td></tr>
|
|
84
|
+
<tr><td>e</td><td>Edit page source in $EDITOR</td></tr>
|
|
85
|
+
<tr><td>f</td><td>Fill and submit form</td></tr>
|
|
86
|
+
<tr><td>Ctrl-g</td><td>Edit focused field in $EDITOR</td></tr>
|
|
87
|
+
<tr><td>H / Backspace</td><td>Go back</td></tr>
|
|
88
|
+
<tr><td>L</td><td>Go forward</td></tr>
|
|
89
|
+
<tr><td>r</td><td>Reload page</td></tr>
|
|
90
|
+
</table>
|
|
91
|
+
<h2>Tabs</h2>
|
|
92
|
+
<table>
|
|
93
|
+
<tr><th>Key</th><th>Action</th></tr>
|
|
94
|
+
<tr><td>J / Right</td><td>Next tab</td></tr>
|
|
95
|
+
<tr><td>K / Left</td><td>Previous tab</td></tr>
|
|
96
|
+
<tr><td>d</td><td>Close tab</td></tr>
|
|
97
|
+
<tr><td>u</td><td>Undo close tab</td></tr>
|
|
98
|
+
</table>
|
|
99
|
+
<h2>Search</h2>
|
|
100
|
+
<table>
|
|
101
|
+
<tr><th>Key</th><th>Action</th></tr>
|
|
102
|
+
<tr><td>/</td><td>Search page</td></tr>
|
|
103
|
+
<tr><td>n</td><td>Next search match</td></tr>
|
|
104
|
+
<tr><td>N</td><td>Previous search match</td></tr>
|
|
105
|
+
</table>
|
|
106
|
+
<h2>Commands (: mode)</h2>
|
|
107
|
+
<table>
|
|
108
|
+
<tr><th>Command</th><th>Action</th></tr>
|
|
109
|
+
<tr><td>:open URL</td><td>Navigate to URL</td></tr>
|
|
110
|
+
<tr><td>:tabopen URL</td><td>Open URL in new tab</td></tr>
|
|
111
|
+
<tr><td>:close / :q</td><td>Close tab</td></tr>
|
|
112
|
+
<tr><td>:quit / :qa</td><td>Quit brrowser</td></tr>
|
|
113
|
+
<tr><td>:back</td><td>Go back</td></tr>
|
|
114
|
+
<tr><td>:forward</td><td>Go forward</td></tr>
|
|
115
|
+
<tr><td>:bookmark / :bm</td><td>Bookmark (or open by name)</td></tr>
|
|
116
|
+
<tr><td>:bookmarks / :bms</td><td>List bookmarks</td></tr>
|
|
117
|
+
<tr><td>:download / :dl</td><td>Download file</td></tr>
|
|
118
|
+
<tr><td>:adblock</td><td>Update ad blocklist</td></tr>
|
|
119
|
+
<tr><td>:password / :pw</td><td>Save password for site</td></tr>
|
|
120
|
+
</table>
|
|
121
|
+
<h2>Other</h2>
|
|
122
|
+
<table>
|
|
123
|
+
<tr><th>Key</th><th>Action</th></tr>
|
|
124
|
+
<tr><td>b</td><td>Bookmark current page</td></tr>
|
|
125
|
+
<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>i</td><td>Toggle images</td></tr>
|
|
129
|
+
<tr><td>p</td><td>Show stored password for site</td></tr>
|
|
130
|
+
<tr><td>I</td><td>AI summary of page</td></tr>
|
|
131
|
+
<tr><td>P</td><td>Preferences</td></tr>
|
|
132
|
+
<tr><td>?</td><td>This help page</td></tr>
|
|
133
|
+
<tr><td>q</td><td>Quit</td></tr>
|
|
134
|
+
</table>
|
|
135
|
+
</body></html>
|
|
136
|
+
HTML
|
|
137
|
+
|
|
138
|
+
SEARCH_ENGINES = {
|
|
139
|
+
"g" => "https://www.google.com/search?q=",
|
|
140
|
+
"ddg" => "https://duckduckgo.com/?q=",
|
|
141
|
+
"w" => "https://en.wikipedia.org/wiki/Special:Search?search=",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class Browser # {{{1
|
|
145
|
+
def initialize(url = nil)
|
|
146
|
+
@fetcher = Fetcher.new
|
|
147
|
+
@tabs = []
|
|
148
|
+
@current_tab = 0
|
|
149
|
+
@closed_tabs = []
|
|
150
|
+
@mode = :normal
|
|
151
|
+
@g_pressed = false
|
|
152
|
+
@search_term = nil
|
|
153
|
+
@search_matches = []
|
|
154
|
+
@search_index = 0
|
|
155
|
+
@status_msg = ""
|
|
156
|
+
@running = true
|
|
157
|
+
load_config
|
|
158
|
+
load_adblock
|
|
159
|
+
@termpix = Termpix::Display.new
|
|
160
|
+
@img_cache = {} # url => local path
|
|
161
|
+
@img_thread = nil
|
|
162
|
+
@img_dir = File.join(Dir.tmpdir, "brrowser_images_#{Process.pid}")
|
|
163
|
+
Dir.mkdir(@img_dir) unless Dir.exist?(@img_dir)
|
|
164
|
+
|
|
165
|
+
main_loop(url || HOMEPAGE)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Pane layout {{{2
|
|
169
|
+
def setup_panes
|
|
170
|
+
@h, @w = IO.console.winsize
|
|
171
|
+
|
|
172
|
+
@pInfo = Pane.new(1, 1, @w, 1, 252, 236)
|
|
173
|
+
@pTab = Pane.new(1, 2, @w, 1, 252, 234)
|
|
174
|
+
@pMain = Pane.new(1, 3, @w, @h - 3, 255, 0)
|
|
175
|
+
@pCmd = Pane.new(1, @h, @w, 1, 252, 234)
|
|
176
|
+
|
|
177
|
+
@pMain.border = false
|
|
178
|
+
@pMain.scroll = true
|
|
179
|
+
|
|
180
|
+
@pInput = Pane.new(2, @h, @w - 1, 1, 255, 234)
|
|
181
|
+
@pInput.record = true
|
|
182
|
+
|
|
183
|
+
@pUrlInput = Pane.new(2, @h, @w - 1, 1, 255, 234)
|
|
184
|
+
@pUrlInput.record = true
|
|
185
|
+
|
|
186
|
+
Signal.trap("WINCH") { @winch = true }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def resize
|
|
190
|
+
@h, @w = IO.console.winsize
|
|
191
|
+
@pInfo.w = @w; @pInfo.h = 1; @pInfo.x = 1; @pInfo.y = 1
|
|
192
|
+
@pTab.w = @w; @pTab.h = 1; @pTab.x = 1; @pTab.y = 2
|
|
193
|
+
@pMain.w = @w; @pMain.h = @h - 3; @pMain.x = 1; @pMain.y = 3
|
|
194
|
+
@pCmd.w = @w; @pCmd.h = 1; @pCmd.x = 1; @pCmd.y = @h
|
|
195
|
+
@pInput.w = @w - 1; @pInput.x = 2; @pInput.y = @h
|
|
196
|
+
|
|
197
|
+
re_render
|
|
198
|
+
refresh_all
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Tab management {{{2
|
|
202
|
+
def current_tab
|
|
203
|
+
@tabs[@current_tab]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def new_tab(url)
|
|
207
|
+
tab = Tab.new
|
|
208
|
+
@tabs.insert(@current_tab + (@tabs.empty? ? 0 : 1), tab)
|
|
209
|
+
@current_tab = @tabs.index(tab)
|
|
210
|
+
navigate(url)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def close_tab
|
|
214
|
+
return if @tabs.length <= 1
|
|
215
|
+
@closed_tabs.push(@tabs[@current_tab])
|
|
216
|
+
@tabs.delete_at(@current_tab)
|
|
217
|
+
@current_tab = [@current_tab, @tabs.length - 1].min
|
|
218
|
+
@pMain.ix = current_tab.ix
|
|
219
|
+
refresh_all
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def undo_close_tab
|
|
223
|
+
return if @closed_tabs.empty?
|
|
224
|
+
tab = @closed_tabs.pop
|
|
225
|
+
@tabs.insert(@current_tab + 1, tab)
|
|
226
|
+
@current_tab += 1
|
|
227
|
+
@pMain.ix = current_tab.ix
|
|
228
|
+
refresh_all
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def next_tab
|
|
232
|
+
clear_images
|
|
233
|
+
@current_tab = (@current_tab + 1) % @tabs.length
|
|
234
|
+
@pMain.ix = current_tab.ix
|
|
235
|
+
refresh_all
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def prev_tab
|
|
239
|
+
clear_images
|
|
240
|
+
@current_tab = (@current_tab - 1) % @tabs.length
|
|
241
|
+
@pMain.ix = current_tab.ix
|
|
242
|
+
refresh_all
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Navigation {{{2
|
|
246
|
+
def navigate(url)
|
|
247
|
+
url = resolve_search(url)
|
|
248
|
+
@status_msg = "Loading #{url}..."
|
|
249
|
+
refresh_cmd
|
|
250
|
+
|
|
251
|
+
current_tab.navigate(url)
|
|
252
|
+
result = @fetcher.fetch(url)
|
|
253
|
+
current_tab.url = result[:url]
|
|
254
|
+
|
|
255
|
+
render_page(result)
|
|
256
|
+
refresh_all
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def render_page(result)
|
|
260
|
+
ct = result[:content_type].to_s
|
|
261
|
+
|
|
262
|
+
# Always clear old state
|
|
263
|
+
clear_images
|
|
264
|
+
@current_link = -1
|
|
265
|
+
@focus_index = -1
|
|
266
|
+
@focused_element = nil
|
|
267
|
+
@highlighted_line = nil
|
|
268
|
+
|
|
269
|
+
# Handle image URLs: display the image instead of raw binary
|
|
270
|
+
if ct.match?(/image\//) || result[:url].to_s.match?(/\.(png|jpe?g|gif|bmp|webp|svg|ico)(\?|$)/i)
|
|
271
|
+
render_image_page(result)
|
|
272
|
+
return
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Handle other binary content: prompt open or download
|
|
276
|
+
if ct.match?(/octet-stream|application\/pdf|application\/zip|audio\/|video\//) ||
|
|
277
|
+
result[:url].to_s.match?(/\.(pdf|zip|tar|gz|mp[34]|wav|avi|mov|mkv)(\?|$)/i)
|
|
278
|
+
filename = File.basename(URI.parse(result[:url]).path) rescue "file"
|
|
279
|
+
# Save to temp first
|
|
280
|
+
tmppath = File.join(@img_dir, filename)
|
|
281
|
+
File.binwrite(tmppath, result[:body])
|
|
282
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
283
|
+
choice = @pInput.ask("#{filename} (#{result[:body]&.length} bytes) - [o]pen / [d]ownload / [c]ancel: ", "o")
|
|
284
|
+
case choice&.strip&.downcase
|
|
285
|
+
when "o"
|
|
286
|
+
system("xdg-open #{Shellwords.escape(tmppath)} &")
|
|
287
|
+
@status_msg = "Opened #{filename}"
|
|
288
|
+
when "d"
|
|
289
|
+
download_file(result[:url])
|
|
290
|
+
else
|
|
291
|
+
@status_msg = "Cancelled"
|
|
292
|
+
end
|
|
293
|
+
refresh_cmd
|
|
294
|
+
go_back
|
|
295
|
+
return
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
body = result[:body].to_s.dup.force_encoding("UTF-8").scrub("")
|
|
299
|
+
result[:body] = body
|
|
300
|
+
if ct.include?("text/html") || body.match?(/<html/i)
|
|
301
|
+
renderer = Renderer.new(@w - 2)
|
|
302
|
+
page = renderer.render(result[:body], result[:url])
|
|
303
|
+
current_tab.title = page[:title]
|
|
304
|
+
current_tab.content = page[:text]
|
|
305
|
+
current_tab.links = page[:links]
|
|
306
|
+
current_tab.images = page[:images]
|
|
307
|
+
current_tab.forms = page[:forms]
|
|
308
|
+
check_autofill
|
|
309
|
+
else
|
|
310
|
+
current_tab.title = result[:url]
|
|
311
|
+
current_tab.content = body
|
|
312
|
+
current_tab.links = []
|
|
313
|
+
current_tab.images = []
|
|
314
|
+
current_tab.forms = []
|
|
315
|
+
end
|
|
316
|
+
current_tab.ix = 0
|
|
317
|
+
@pMain.ix = 0
|
|
318
|
+
@status_msg = ""
|
|
319
|
+
@last_img_count = 0
|
|
320
|
+
start_image_downloads
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def render_image_page(result)
|
|
324
|
+
# Save the image to temp and display it
|
|
325
|
+
filename = File.basename(URI.parse(result[:url]).path) rescue "image"
|
|
326
|
+
ext = File.extname(filename)
|
|
327
|
+
ext = ".png" if ext.empty?
|
|
328
|
+
local = File.join(@img_dir, "#{Digest::MD5.hexdigest(result[:url])}#{ext}")
|
|
329
|
+
File.binwrite(local, result[:body])
|
|
330
|
+
@img_cache[result[:url]] = local
|
|
331
|
+
|
|
332
|
+
# Use most of the screen for the image
|
|
333
|
+
img_h = @pMain.h - 4
|
|
334
|
+
current_tab.title = filename
|
|
335
|
+
lines = ["#{filename} #{result[:body].length} bytes".fg(245)]
|
|
336
|
+
lines += Array.new(img_h, "")
|
|
337
|
+
current_tab.content = lines.join("\n")
|
|
338
|
+
current_tab.links = []
|
|
339
|
+
current_tab.images = [{ src: result[:url], alt: filename, line: 1, height: img_h }]
|
|
340
|
+
current_tab.forms = []
|
|
341
|
+
current_tab.ix = 0
|
|
342
|
+
@pMain.ix = 0
|
|
343
|
+
@status_msg = ""
|
|
344
|
+
@last_img_count = 0
|
|
345
|
+
start_image_downloads
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def re_render
|
|
349
|
+
return unless current_tab.url && current_tab.url != HOMEPAGE
|
|
350
|
+
result = @fetcher.fetch(current_tab.url)
|
|
351
|
+
render_page(result)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def go_back
|
|
355
|
+
url = current_tab.go_back
|
|
356
|
+
if url
|
|
357
|
+
result = @fetcher.fetch(url)
|
|
358
|
+
render_page(result)
|
|
359
|
+
@pMain.ix = current_tab.ix
|
|
360
|
+
refresh_all
|
|
361
|
+
else
|
|
362
|
+
@status_msg = "No back history"
|
|
363
|
+
refresh_cmd
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def go_forward
|
|
368
|
+
url = current_tab.go_forward
|
|
369
|
+
if url
|
|
370
|
+
result = @fetcher.fetch(url)
|
|
371
|
+
render_page(result)
|
|
372
|
+
@pMain.ix = current_tab.ix
|
|
373
|
+
refresh_all
|
|
374
|
+
else
|
|
375
|
+
@status_msg = "No forward history"
|
|
376
|
+
refresh_cmd
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def reload
|
|
381
|
+
return unless current_tab.url
|
|
382
|
+
navigate_no_history(current_tab.url)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def navigate_no_history(url)
|
|
386
|
+
@status_msg = "Loading #{url}..."
|
|
387
|
+
refresh_cmd
|
|
388
|
+
result = @fetcher.fetch(url)
|
|
389
|
+
current_tab.url = result[:url]
|
|
390
|
+
render_page(result)
|
|
391
|
+
refresh_all
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def resolve_search(input)
|
|
395
|
+
return input if input.match?(%r{^https?://}) || input.match?(%r{^file://})
|
|
396
|
+
return input if input.include?(".") && !input.include?(" ")
|
|
397
|
+
|
|
398
|
+
parts = input.split(" ", 2)
|
|
399
|
+
if parts.length == 2 && SEARCH_ENGINES[parts[0]]
|
|
400
|
+
SEARCH_ENGINES[parts[0]] + URI.encode_www_form_component(parts[1])
|
|
401
|
+
elsif parts.length == 1 && !input.include?(" ")
|
|
402
|
+
"https://#{input}"
|
|
403
|
+
else
|
|
404
|
+
SEARCH_ENGINES["g"] + URI.encode_www_form_component(input)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Rendering {{{2
|
|
409
|
+
def refresh_all
|
|
410
|
+
refresh_info
|
|
411
|
+
refresh_tabs
|
|
412
|
+
refresh_main
|
|
413
|
+
refresh_cmd
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def refresh_info
|
|
417
|
+
tab = current_tab
|
|
418
|
+
mode_str = @mode == :normal ? "" : " [#{@mode.upcase}]"
|
|
419
|
+
back_indicator = tab.can_go_back? ? "\u25c0 " : ""
|
|
420
|
+
fwd_indicator = tab.can_go_forward? ? " \u25b6" : ""
|
|
421
|
+
title = tab.title.to_s
|
|
422
|
+
url = tab.url.to_s
|
|
423
|
+
info = " #{back_indicator}#{url}#{fwd_indicator} #{title}#{mode_str}"
|
|
424
|
+
info = info[0...@w] if info.length > @w
|
|
425
|
+
@pInfo.text = info
|
|
426
|
+
@pInfo.refresh
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def refresh_tabs
|
|
430
|
+
parts = @tabs.each_with_index.map do |tab, i|
|
|
431
|
+
label = tab.title.to_s.empty? ? "New Tab" : tab.title.to_s
|
|
432
|
+
label = label[0..20] + "..." if label.length > 23
|
|
433
|
+
if i == @current_tab
|
|
434
|
+
" #{label} ".fg(220).b
|
|
435
|
+
else
|
|
436
|
+
" #{label} ".fg(245)
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
@pTab.text = parts.join("\u2502".fg(240))
|
|
440
|
+
@pTab.refresh
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def refresh_main
|
|
444
|
+
@pMain.text = current_tab.content.to_s
|
|
445
|
+
@pMain.refresh
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def refresh_cmd
|
|
449
|
+
if @status_msg.empty?
|
|
450
|
+
link_count = current_tab.links.length
|
|
451
|
+
info = " #{link_count} links | ? help | : command"
|
|
452
|
+
@pCmd.text = info.fg(245)
|
|
453
|
+
else
|
|
454
|
+
@pCmd.text = " " + @status_msg.fg(214)
|
|
455
|
+
end
|
|
456
|
+
@pCmd.refresh
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Scrolling {{{2
|
|
460
|
+
def scroll_down
|
|
461
|
+
had_images = @showing_image
|
|
462
|
+
clear_images if @showing_image
|
|
463
|
+
@pMain.linedown
|
|
464
|
+
current_tab.ix = @pMain.ix
|
|
465
|
+
show_visible_image if had_images
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def scroll_up
|
|
469
|
+
had_images = @showing_image
|
|
470
|
+
clear_images if @showing_image
|
|
471
|
+
@pMain.lineup
|
|
472
|
+
current_tab.ix = @pMain.ix
|
|
473
|
+
show_visible_image if had_images
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def page_down
|
|
477
|
+
clear_images if @showing_image
|
|
478
|
+
@pMain.pagedown
|
|
479
|
+
current_tab.ix = @pMain.ix
|
|
480
|
+
show_visible_image
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def page_up
|
|
484
|
+
clear_images if @showing_image
|
|
485
|
+
@pMain.pageup
|
|
486
|
+
current_tab.ix = @pMain.ix
|
|
487
|
+
show_visible_image
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def half_page_down
|
|
491
|
+
clear_images if @showing_image
|
|
492
|
+
half = (@h - 3) / 2
|
|
493
|
+
half.times { @pMain.linedown }
|
|
494
|
+
current_tab.ix = @pMain.ix
|
|
495
|
+
show_visible_image
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def half_page_up
|
|
499
|
+
clear_images if @showing_image
|
|
500
|
+
half = (@h - 3) / 2
|
|
501
|
+
half.times { @pMain.lineup }
|
|
502
|
+
current_tab.ix = @pMain.ix
|
|
503
|
+
show_visible_image
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def go_top
|
|
507
|
+
clear_images if @showing_image
|
|
508
|
+
@pMain.ix = 0
|
|
509
|
+
current_tab.ix = 0
|
|
510
|
+
refresh_main
|
|
511
|
+
show_visible_image
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def go_bottom
|
|
515
|
+
clear_images if @showing_image
|
|
516
|
+
@pMain.bottom
|
|
517
|
+
current_tab.ix = @pMain.ix
|
|
518
|
+
show_visible_image
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def redraw_screen
|
|
522
|
+
clear_images
|
|
523
|
+
@pInfo.full_refresh
|
|
524
|
+
@pTab.full_refresh
|
|
525
|
+
@pMain.full_refresh
|
|
526
|
+
@pCmd.full_refresh
|
|
527
|
+
show_visible_image
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Images {{{2
|
|
531
|
+
# Images display in the reserved blank lines from the renderer.
|
|
532
|
+
# Only one image shown at a time to avoid kitty protocol conflicts.
|
|
533
|
+
|
|
534
|
+
def start_image_downloads
|
|
535
|
+
return unless @termpix.supported?
|
|
536
|
+
images = current_tab.images
|
|
537
|
+
return if images.empty?
|
|
538
|
+
|
|
539
|
+
@img_thread = Thread.new do
|
|
540
|
+
images.each do |img|
|
|
541
|
+
break unless @running
|
|
542
|
+
next if @img_cache[img[:src]]
|
|
543
|
+
src = img[:src]
|
|
544
|
+
src = "https:#{src}" if src.start_with?("//")
|
|
545
|
+
next unless src.match?(%r{^https?://})
|
|
546
|
+
next if blocked?(src)
|
|
547
|
+
begin
|
|
548
|
+
uri = URI.parse(src)
|
|
549
|
+
ext = File.extname(uri.path)[0..5]
|
|
550
|
+
ext = ".png" if ext.empty?
|
|
551
|
+
local = File.join(@img_dir, "#{Digest::MD5.hexdigest(src)}#{ext}")
|
|
552
|
+
unless File.exist?(local)
|
|
553
|
+
response = @fetcher.fetch(src)
|
|
554
|
+
next if response[:status] != 200
|
|
555
|
+
ct = response[:content_type].to_s
|
|
556
|
+
next unless ct.match?(/image/) || src.match?(/\.(png|jpe?g|gif|bmp|webp|svg|ico)/i)
|
|
557
|
+
File.binwrite(local, response[:body])
|
|
558
|
+
if src.match?(/\.svg/i) || ct.include?("svg")
|
|
559
|
+
png_path = local.sub(/\.[^.]+$/, ".png")
|
|
560
|
+
system("convert", local, png_path, [:out, :err] => "/dev/null")
|
|
561
|
+
local = png_path if File.exist?(png_path)
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
@img_cache[src] = local
|
|
565
|
+
rescue
|
|
566
|
+
# skip failed downloads
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def image_mode
|
|
573
|
+
mode = @conf["image_mode"]
|
|
574
|
+
return :off unless @conf["show_images"]
|
|
575
|
+
case mode
|
|
576
|
+
when "termpix" then @termpix.supported? ? :termpix : :off
|
|
577
|
+
when "ascii" then :ascii
|
|
578
|
+
when "off" then :off
|
|
579
|
+
else # auto
|
|
580
|
+
@termpix.supported? ? :termpix : (chafa_available? ? :ascii : :off)
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def chafa_available?
|
|
585
|
+
@chafa_available = system("which chafa > /dev/null 2>&1") if @chafa_available.nil?
|
|
586
|
+
@chafa_available
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def show_visible_image
|
|
590
|
+
mode = image_mode
|
|
591
|
+
return if mode == :off
|
|
592
|
+
images = current_tab.images
|
|
593
|
+
return if images.empty?
|
|
594
|
+
|
|
595
|
+
viewport_top = @pMain.ix
|
|
596
|
+
viewport_bottom = viewport_top + @pMain.h - 1
|
|
597
|
+
reserve = Brrowser::Renderer::IMG_RESERVE
|
|
598
|
+
|
|
599
|
+
images.each do |img|
|
|
600
|
+
line = img[:line] || 0
|
|
601
|
+
img_bottom = line + reserve - 1
|
|
602
|
+
next unless img_bottom >= viewport_top && line <= viewport_bottom
|
|
603
|
+
local = @img_cache[img[:src]]
|
|
604
|
+
next unless local && File.exist?(local)
|
|
605
|
+
|
|
606
|
+
top_in_view = [line, viewport_top].max
|
|
607
|
+
bot_in_view = [img_bottom, viewport_bottom].min
|
|
608
|
+
screen_y = @pMain.y + (top_in_view - viewport_top)
|
|
609
|
+
visible_h = bot_in_view - top_in_view + 1
|
|
610
|
+
next if visible_h < 3
|
|
611
|
+
|
|
612
|
+
# Full width for standalone image pages, half for inline
|
|
613
|
+
standalone = current_tab.images.length == 1 && current_tab.links.empty?
|
|
614
|
+
mw = standalone ? @pMain.w - 2 : [@pMain.w / 2, 60].min
|
|
615
|
+
|
|
616
|
+
if mode == :termpix
|
|
617
|
+
@termpix.show(local,
|
|
618
|
+
x: @pMain.x,
|
|
619
|
+
y: screen_y,
|
|
620
|
+
max_width: mw,
|
|
621
|
+
max_height: visible_h)
|
|
622
|
+
elsif mode == :ascii
|
|
623
|
+
show_ascii_image(local, screen_y, visible_h, mw)
|
|
624
|
+
end
|
|
625
|
+
@showing_image = true
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def show_ascii_image(path, screen_y, max_h, cols = nil)
|
|
630
|
+
cols ||= [@pMain.w / 2, 60].min
|
|
631
|
+
art = `chafa -s #{cols}x#{max_h} --format=symbols #{Shellwords.escape(path)} 2>/dev/null`
|
|
632
|
+
return if art.empty?
|
|
633
|
+
lines = art.split("\n")
|
|
634
|
+
lines.each_with_index do |line, i|
|
|
635
|
+
break if i >= max_h
|
|
636
|
+
print "\e[#{screen_y + i};#{@pMain.x}H#{line}"
|
|
637
|
+
end
|
|
638
|
+
$stdout.flush
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def clear_images
|
|
642
|
+
return unless @showing_image
|
|
643
|
+
if @termpix.supported?
|
|
644
|
+
@termpix.clear(
|
|
645
|
+
x: @pMain.x,
|
|
646
|
+
y: @pMain.y,
|
|
647
|
+
width: @pMain.w,
|
|
648
|
+
height: @pMain.h,
|
|
649
|
+
term_width: @w,
|
|
650
|
+
term_height: @h)
|
|
651
|
+
end
|
|
652
|
+
# For ASCII art, a full_refresh redraws text over the art
|
|
653
|
+
@pMain.full_refresh if image_mode == :ascii
|
|
654
|
+
@showing_image = false
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Search {{{2
|
|
658
|
+
def search_page
|
|
659
|
+
@pInput.x = 2
|
|
660
|
+
@pInput.w = @w - 1
|
|
661
|
+
@pInput.y = @h
|
|
662
|
+
term = @pInput.ask("/", "")
|
|
663
|
+
if term.nil? || term.strip.empty?
|
|
664
|
+
refresh_cmd
|
|
665
|
+
return
|
|
666
|
+
end
|
|
667
|
+
@search_term = term.strip
|
|
668
|
+
find_matches
|
|
669
|
+
search_next
|
|
670
|
+
refresh_cmd
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def find_matches
|
|
674
|
+
@search_matches = []
|
|
675
|
+
return unless @search_term
|
|
676
|
+
lines = current_tab.content.to_s.split("\n")
|
|
677
|
+
lines.each_with_index do |line, i|
|
|
678
|
+
clean = line.gsub(/\e\[[0-9;]*m/, "")
|
|
679
|
+
if clean.downcase.include?(@search_term.downcase)
|
|
680
|
+
@search_matches << i
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
@search_index = 0
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def search_next
|
|
687
|
+
return unless @search_matches.any?
|
|
688
|
+
@search_index = (@search_index + 1) % @search_matches.length if @pMain.ix >= @search_matches[@search_index]
|
|
689
|
+
@pMain.ix = @search_matches[@search_index]
|
|
690
|
+
current_tab.ix = @pMain.ix
|
|
691
|
+
@status_msg = "Match #{@search_index + 1}/#{@search_matches.length}"
|
|
692
|
+
refresh_main
|
|
693
|
+
refresh_cmd
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def search_prev
|
|
697
|
+
return unless @search_matches.any?
|
|
698
|
+
@search_index = (@search_index - 1) % @search_matches.length
|
|
699
|
+
@pMain.ix = @search_matches[@search_index]
|
|
700
|
+
current_tab.ix = @pMain.ix
|
|
701
|
+
@status_msg = "Match #{@search_index + 1}/#{@search_matches.length}"
|
|
702
|
+
refresh_main
|
|
703
|
+
refresh_cmd
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Link/field navigation {{{2
|
|
707
|
+
# TAB cycles through links and form fields together, sorted by line.
|
|
708
|
+
@focus_index = -1
|
|
709
|
+
|
|
710
|
+
def focusable_elements
|
|
711
|
+
elements = []
|
|
712
|
+
(current_tab.links || []).each do |l|
|
|
713
|
+
next unless l[:line]
|
|
714
|
+
elements << { type: :link, data: l, line: l[:line] }
|
|
715
|
+
end
|
|
716
|
+
(current_tab.forms || []).each do |form|
|
|
717
|
+
(form[:fields] || []).each do |field|
|
|
718
|
+
next if field[:type] == "hidden" || field[:type] == "submit"
|
|
719
|
+
next unless field[:line] || form[:line]
|
|
720
|
+
elements << { type: :field, data: field, form: form, line: field[:line] || form[:line] }
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
elements.sort_by { |e| e[:line] }
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def next_link
|
|
727
|
+
elems = focusable_elements
|
|
728
|
+
return if elems.empty?
|
|
729
|
+
if @focus_index < 0
|
|
730
|
+
# First TAB: jump to first visible element
|
|
731
|
+
vt = @pMain.ix
|
|
732
|
+
@focus_index = elems.index { |e| e[:line] >= vt } || 0
|
|
733
|
+
else
|
|
734
|
+
@focus_index = (@focus_index + 1) % elems.length
|
|
735
|
+
end
|
|
736
|
+
jump_to_element(elems[@focus_index])
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def prev_link
|
|
740
|
+
elems = focusable_elements
|
|
741
|
+
return if elems.empty?
|
|
742
|
+
if @focus_index < 0
|
|
743
|
+
# First S-TAB: jump to last visible element
|
|
744
|
+
vb = @pMain.ix + @pMain.h - 1
|
|
745
|
+
@focus_index = elems.rindex { |e| e[:line] <= vb } || elems.length - 1
|
|
746
|
+
else
|
|
747
|
+
@focus_index = (@focus_index - 1) % elems.length
|
|
748
|
+
end
|
|
749
|
+
jump_to_element(elems[@focus_index])
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def jump_to_element(elem)
|
|
753
|
+
lines = current_tab.content.split("\n")
|
|
754
|
+
# Remove old highlight
|
|
755
|
+
if @highlighted_line && @highlighted_line < lines.length
|
|
756
|
+
lines[@highlighted_line] = lines[@highlighted_line]
|
|
757
|
+
.gsub("\e[7m", "").gsub("\e[27m", "")
|
|
758
|
+
end
|
|
759
|
+
# Highlight current element line
|
|
760
|
+
if elem[:line] < lines.length
|
|
761
|
+
lines[elem[:line]] = "\e[7m#{lines[elem[:line]]}\e[27m"
|
|
762
|
+
@highlighted_line = elem[:line]
|
|
763
|
+
end
|
|
764
|
+
current_tab.content = lines.join("\n")
|
|
765
|
+
@pMain.ix = [elem[:line] - 3, 0].max
|
|
766
|
+
current_tab.ix = @pMain.ix
|
|
767
|
+
@focused_element = elem
|
|
768
|
+
|
|
769
|
+
if elem[:type] == :link
|
|
770
|
+
l = elem[:data]
|
|
771
|
+
@current_link = l[:index]
|
|
772
|
+
@status_msg = "[#{l[:index]}] #{l[:text][0..60]} -> #{l[:href][0..60]}"
|
|
773
|
+
else
|
|
774
|
+
f = elem[:data]
|
|
775
|
+
val = f[:value].to_s
|
|
776
|
+
@current_link = -1
|
|
777
|
+
@status_msg = "[#{f[:type]}] #{f[:placeholder] || f[:name]}: #{val[0..40]}"
|
|
778
|
+
end
|
|
779
|
+
refresh_main
|
|
780
|
+
refresh_cmd
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def follow_link_prompt
|
|
784
|
+
# If focused on a link, follow it
|
|
785
|
+
if @focused_element && @focused_element[:type] == :link
|
|
786
|
+
navigate(@focused_element[:data][:href])
|
|
787
|
+
return
|
|
788
|
+
end
|
|
789
|
+
# If focused on a form field, edit it
|
|
790
|
+
if @focused_element && @focused_element[:type] == :field
|
|
791
|
+
edit_focused_field
|
|
792
|
+
return
|
|
793
|
+
end
|
|
794
|
+
return if current_tab.links.empty?
|
|
795
|
+
@pInput.x = 2
|
|
796
|
+
@pInput.w = @w - 1
|
|
797
|
+
@pInput.y = @h
|
|
798
|
+
num = @pInput.ask("Link #: ", "")
|
|
799
|
+
if num.nil? || num.strip.empty?
|
|
800
|
+
refresh_cmd
|
|
801
|
+
return
|
|
802
|
+
end
|
|
803
|
+
idx = num.strip.to_i
|
|
804
|
+
link = current_tab.links.find { |l| l[:index] == idx }
|
|
805
|
+
if link
|
|
806
|
+
navigate(link[:href])
|
|
807
|
+
else
|
|
808
|
+
@status_msg = "No link ##{idx}"
|
|
809
|
+
refresh_cmd
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def edit_focused_field
|
|
814
|
+
return unless @focused_element && @focused_element[:type] == :field
|
|
815
|
+
field = @focused_element[:data]
|
|
816
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
817
|
+
val = @pInput.ask("#{field[:placeholder] || field[:name]}: ", field[:value].to_s)
|
|
818
|
+
if val && !val.strip.empty?
|
|
819
|
+
field[:value] = val.strip
|
|
820
|
+
@status_msg = "Set #{field[:name]} = #{val.strip}"
|
|
821
|
+
end
|
|
822
|
+
refresh_cmd
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def edit_field_in_editor
|
|
826
|
+
return unless @focused_element && @focused_element[:type] == :field
|
|
827
|
+
field = @focused_element[:data]
|
|
828
|
+
tmpfile = File.join(Dir.tmpdir, "brrowser_field_#{Process.pid}.txt")
|
|
829
|
+
File.write(tmpfile, field[:value].to_s)
|
|
830
|
+
editor = ENV["EDITOR"] || "vim"
|
|
831
|
+
Rcurses.cleanup!
|
|
832
|
+
system("#{editor} #{Shellwords.escape(tmpfile)}")
|
|
833
|
+
Rcurses.init!
|
|
834
|
+
if File.exist?(tmpfile)
|
|
835
|
+
field[:value] = File.read(tmpfile).strip
|
|
836
|
+
File.delete(tmpfile)
|
|
837
|
+
@status_msg = "Set #{field[:name]} from editor"
|
|
838
|
+
end
|
|
839
|
+
refresh_all
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Yank/copy {{{2
|
|
843
|
+
def yank_url
|
|
844
|
+
url = current_tab.url.to_s
|
|
845
|
+
copy_to_clipboard(url)
|
|
846
|
+
@status_msg = "Copied: #{url}"
|
|
847
|
+
refresh_cmd
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def yank_element
|
|
851
|
+
if @focused_element
|
|
852
|
+
if @focused_element[:type] == :link
|
|
853
|
+
text = @focused_element[:data][:href]
|
|
854
|
+
else
|
|
855
|
+
text = @focused_element[:data][:value].to_s
|
|
856
|
+
end
|
|
857
|
+
else
|
|
858
|
+
text = current_tab.url.to_s
|
|
859
|
+
end
|
|
860
|
+
copy_to_clipboard(text)
|
|
861
|
+
@status_msg = "Copied: #{text[0..60]}"
|
|
862
|
+
refresh_cmd
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def copy_to_clipboard(text)
|
|
866
|
+
# Try xclip, xsel, wl-copy in order
|
|
867
|
+
%w[xclip xsel wl-copy].each do |cmd|
|
|
868
|
+
if system("which #{cmd} > /dev/null 2>&1")
|
|
869
|
+
case cmd
|
|
870
|
+
when "xclip"
|
|
871
|
+
IO.popen("xclip -selection clipboard", "w") { |io| io.write(text) }
|
|
872
|
+
when "xsel"
|
|
873
|
+
IO.popen("xsel --clipboard --input", "w") { |io| io.write(text) }
|
|
874
|
+
when "wl-copy"
|
|
875
|
+
IO.popen("wl-copy", "w") { |io| io.write(text) }
|
|
876
|
+
end
|
|
877
|
+
return
|
|
878
|
+
end
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def new_tab_with(url)
|
|
883
|
+
tab = Tab.new
|
|
884
|
+
@tabs.insert(@current_tab + 1, tab)
|
|
885
|
+
@current_tab += 1
|
|
886
|
+
navigate(url)
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Open URL {{{2
|
|
890
|
+
def open_url(prefill = "")
|
|
891
|
+
@pUrlInput.x = 2
|
|
892
|
+
@pUrlInput.w = @w - 1
|
|
893
|
+
@pUrlInput.y = @h
|
|
894
|
+
url = @pUrlInput.ask(":open ", prefill)
|
|
895
|
+
if url.nil? || url.strip.empty?
|
|
896
|
+
refresh_cmd
|
|
897
|
+
return
|
|
898
|
+
end
|
|
899
|
+
navigate(url.strip)
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
def tabopen_url
|
|
903
|
+
@pUrlInput.x = 2
|
|
904
|
+
@pUrlInput.w = @w - 1
|
|
905
|
+
@pUrlInput.y = @h
|
|
906
|
+
url = @pUrlInput.ask(":tabopen ", "")
|
|
907
|
+
if url.nil? || url.strip.empty?
|
|
908
|
+
refresh_cmd
|
|
909
|
+
return
|
|
910
|
+
end
|
|
911
|
+
new_tab_with(url.strip)
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
# Command mode {{{2
|
|
915
|
+
def command_mode
|
|
916
|
+
@pInput.x = 2
|
|
917
|
+
@pInput.w = @w - 1
|
|
918
|
+
@pInput.y = @h
|
|
919
|
+
cmd = @pInput.ask(":", "")
|
|
920
|
+
if cmd.nil? || cmd.strip.empty?
|
|
921
|
+
refresh_cmd
|
|
922
|
+
return
|
|
923
|
+
end
|
|
924
|
+
execute_command(cmd.strip)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def execute_command(cmd)
|
|
928
|
+
parts = cmd.split(" ", 2)
|
|
929
|
+
command = parts[0].downcase
|
|
930
|
+
args = parts[1]
|
|
931
|
+
|
|
932
|
+
case command
|
|
933
|
+
when "open", "o"
|
|
934
|
+
navigate(args) if args
|
|
935
|
+
when "tabopen", "to"
|
|
936
|
+
new_tab_with(args) if args
|
|
937
|
+
when "back"
|
|
938
|
+
go_back
|
|
939
|
+
when "forward"
|
|
940
|
+
go_forward
|
|
941
|
+
when "close", "q"
|
|
942
|
+
if @tabs.length > 1
|
|
943
|
+
close_tab
|
|
944
|
+
else
|
|
945
|
+
@running = false
|
|
946
|
+
end
|
|
947
|
+
when "quit", "qa"
|
|
948
|
+
@running = false
|
|
949
|
+
when "reload"
|
|
950
|
+
reload
|
|
951
|
+
when "help"
|
|
952
|
+
show_help
|
|
953
|
+
when "about"
|
|
954
|
+
new_tab_with("https://github.com/isene/brrowser")
|
|
955
|
+
when "bookmark", "bm"
|
|
956
|
+
if args
|
|
957
|
+
open_bookmark(args)
|
|
958
|
+
else
|
|
959
|
+
add_bookmark
|
|
960
|
+
end
|
|
961
|
+
when "bookmarks", "bms"
|
|
962
|
+
show_bookmarks
|
|
963
|
+
when "download", "dl"
|
|
964
|
+
download_file(args) if args
|
|
965
|
+
when "adblock"
|
|
966
|
+
update_adblock
|
|
967
|
+
when "password", "pw"
|
|
968
|
+
save_password
|
|
969
|
+
else
|
|
970
|
+
@status_msg = "Unknown command: #{command}"
|
|
971
|
+
refresh_cmd
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Help {{{2
|
|
976
|
+
def show_help
|
|
977
|
+
tab = Tab.new
|
|
978
|
+
@tabs.insert(@current_tab + 1, tab)
|
|
979
|
+
@current_tab += 1
|
|
980
|
+
current_tab.url = "about:help"
|
|
981
|
+
renderer = Renderer.new(@w - 2)
|
|
982
|
+
page = renderer.render(HELP_HTML)
|
|
983
|
+
current_tab.title = "Help"
|
|
984
|
+
current_tab.content = page[:text]
|
|
985
|
+
current_tab.links = page[:links]
|
|
986
|
+
current_tab.images = []
|
|
987
|
+
current_tab.ix = 0
|
|
988
|
+
@pMain.ix = 0
|
|
989
|
+
refresh_all
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# AI summary {{{2
|
|
993
|
+
def ai_summarize
|
|
994
|
+
key_file = "/home/.safe/openai.txt"
|
|
995
|
+
unless File.exist?(key_file)
|
|
996
|
+
@status_msg = "No OpenAI key found at #{key_file}"
|
|
997
|
+
refresh_cmd
|
|
998
|
+
return
|
|
999
|
+
end
|
|
1000
|
+
api_key = File.read(key_file).strip
|
|
1001
|
+
|
|
1002
|
+
# Get page text (strip ANSI)
|
|
1003
|
+
text = current_tab.content.to_s.gsub(/\e\[[0-9;]*m/, "")
|
|
1004
|
+
if text.strip.empty?
|
|
1005
|
+
@status_msg = "No content to summarize"
|
|
1006
|
+
refresh_cmd
|
|
1007
|
+
return
|
|
1008
|
+
end
|
|
1009
|
+
# Truncate to ~4000 chars to fit in context
|
|
1010
|
+
text = text[0..4000] if text.length > 4000
|
|
1011
|
+
|
|
1012
|
+
@status_msg = "AI is thinking..."
|
|
1013
|
+
refresh_cmd
|
|
1014
|
+
|
|
1015
|
+
prompt = "Summarize the following web page concisely. Include key points, main topics, and any important links or actions. Page title: #{current_tab.title}\nURL: #{current_tab.url}\n\nContent:\n#{text}"
|
|
1016
|
+
|
|
1017
|
+
begin
|
|
1018
|
+
uri = URI.parse("https://api.openai.com/v1/chat/completions")
|
|
1019
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
1020
|
+
http.use_ssl = true
|
|
1021
|
+
http.read_timeout = 30
|
|
1022
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
1023
|
+
req["Authorization"] = "Bearer #{api_key}"
|
|
1024
|
+
req["Content-Type"] = "application/json"
|
|
1025
|
+
req.body = JSON.generate({
|
|
1026
|
+
model: "gpt-4o-mini",
|
|
1027
|
+
messages: [{ role: "user", content: prompt }],
|
|
1028
|
+
max_tokens: 600
|
|
1029
|
+
})
|
|
1030
|
+
response = http.request(req)
|
|
1031
|
+
data = JSON.parse(response.body)
|
|
1032
|
+
answer = data.dig("choices", 0, "message", "content") || "No response from AI"
|
|
1033
|
+
rescue => e
|
|
1034
|
+
answer = "AI error: #{e.message}"
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
# Show in popup
|
|
1038
|
+
pw = [@w - 10, 80].min
|
|
1039
|
+
ph = [@h - 6, 30].min
|
|
1040
|
+
px = (@w - pw) / 2
|
|
1041
|
+
py = (@h - ph) / 2
|
|
1042
|
+
popup = Pane.new(px, py, pw, ph, 230, 234)
|
|
1043
|
+
popup.border = true
|
|
1044
|
+
popup.scroll = true
|
|
1045
|
+
popup.text = " AI Summary\n\n#{answer}"
|
|
1046
|
+
popup.refresh
|
|
1047
|
+
|
|
1048
|
+
loop do
|
|
1049
|
+
chr = getchr
|
|
1050
|
+
case chr
|
|
1051
|
+
when "ESC", "q", "I"
|
|
1052
|
+
break
|
|
1053
|
+
when "j", "DOWN"
|
|
1054
|
+
popup.linedown
|
|
1055
|
+
when "k", "UP"
|
|
1056
|
+
popup.lineup
|
|
1057
|
+
when "PgDOWN", " "
|
|
1058
|
+
popup.pagedown
|
|
1059
|
+
when "PgUP"
|
|
1060
|
+
popup.pageup
|
|
1061
|
+
end
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
popup.clear
|
|
1065
|
+
@pInfo.full_refresh
|
|
1066
|
+
@pTab.full_refresh
|
|
1067
|
+
@pMain.full_refresh
|
|
1068
|
+
@pCmd.full_refresh
|
|
1069
|
+
@status_msg = ""
|
|
1070
|
+
show_visible_image
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
# Edit source {{{2
|
|
1074
|
+
def edit_source
|
|
1075
|
+
return unless current_tab.url
|
|
1076
|
+
result = @fetcher.fetch(current_tab.url)
|
|
1077
|
+
return if result[:status] != 200
|
|
1078
|
+
|
|
1079
|
+
tmpfile = File.join(Dir.tmpdir, "brrowser_edit_#{Process.pid}.html")
|
|
1080
|
+
File.write(tmpfile, result[:body])
|
|
1081
|
+
|
|
1082
|
+
editor = ENV["EDITOR"] || "vim"
|
|
1083
|
+
Rcurses.cleanup!
|
|
1084
|
+
system("#{editor} #{Shellwords.escape(tmpfile)}")
|
|
1085
|
+
Rcurses.init!
|
|
1086
|
+
|
|
1087
|
+
# Re-render the edited content
|
|
1088
|
+
if File.exist?(tmpfile)
|
|
1089
|
+
edited = File.read(tmpfile)
|
|
1090
|
+
render_page({ body: edited, url: current_tab.url, content_type: "text/html", status: 200 })
|
|
1091
|
+
File.delete(tmpfile)
|
|
1092
|
+
end
|
|
1093
|
+
refresh_all
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# Bookmarks {{{2
|
|
1097
|
+
BOOKMARKS_FILE = File.join(CONFIG_DIR, "bookmarks.yml")
|
|
1098
|
+
|
|
1099
|
+
def load_bookmarks
|
|
1100
|
+
@bookmarks = if File.exist?(BOOKMARKS_FILE)
|
|
1101
|
+
YAML.safe_load(File.read(BOOKMARKS_FILE)) rescue []
|
|
1102
|
+
else
|
|
1103
|
+
[]
|
|
1104
|
+
end
|
|
1105
|
+
@bookmarks = [] unless @bookmarks.is_a?(Array)
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
def save_bookmarks
|
|
1109
|
+
Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
|
|
1110
|
+
File.write(BOOKMARKS_FILE, @bookmarks.to_yaml)
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
def add_bookmark
|
|
1114
|
+
return unless current_tab.url
|
|
1115
|
+
entry = { "url" => current_tab.url, "title" => current_tab.title.to_s }
|
|
1116
|
+
load_bookmarks
|
|
1117
|
+
unless @bookmarks.any? { |b| b["url"] == entry["url"] }
|
|
1118
|
+
@bookmarks << entry
|
|
1119
|
+
save_bookmarks
|
|
1120
|
+
@status_msg = "Bookmarked: #{entry["title"]}"
|
|
1121
|
+
else
|
|
1122
|
+
@status_msg = "Already bookmarked"
|
|
1123
|
+
end
|
|
1124
|
+
refresh_cmd
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
def open_bookmark(query)
|
|
1128
|
+
load_bookmarks
|
|
1129
|
+
match = @bookmarks.find { |b| b["title"]&.downcase&.include?(query.downcase) || b["url"]&.include?(query) }
|
|
1130
|
+
if match
|
|
1131
|
+
navigate(match["url"])
|
|
1132
|
+
else
|
|
1133
|
+
@status_msg = "No bookmark matching: #{query}"
|
|
1134
|
+
refresh_cmd
|
|
1135
|
+
end
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
def show_bookmarks
|
|
1139
|
+
load_bookmarks
|
|
1140
|
+
if @bookmarks.empty?
|
|
1141
|
+
@status_msg = "No bookmarks"
|
|
1142
|
+
refresh_cmd
|
|
1143
|
+
return
|
|
1144
|
+
end
|
|
1145
|
+
html = "<html><body><h1>Bookmarks</h1><ul>"
|
|
1146
|
+
@bookmarks.each { |b| html << "<li><a href=\"#{b["url"]}\">#{b["title"] || b["url"]}</a></li>" }
|
|
1147
|
+
html << "</ul></body></html>"
|
|
1148
|
+
|
|
1149
|
+
tab = Tab.new
|
|
1150
|
+
@tabs.insert(@current_tab + 1, tab)
|
|
1151
|
+
@current_tab += 1
|
|
1152
|
+
current_tab.url = "about:bookmarks"
|
|
1153
|
+
renderer = Renderer.new(@w - 2)
|
|
1154
|
+
page = renderer.render(html)
|
|
1155
|
+
current_tab.title = "Bookmarks"
|
|
1156
|
+
current_tab.content = page[:text]
|
|
1157
|
+
current_tab.links = page[:links]
|
|
1158
|
+
current_tab.images = []
|
|
1159
|
+
current_tab.ix = 0
|
|
1160
|
+
@pMain.ix = 0
|
|
1161
|
+
refresh_all
|
|
1162
|
+
end
|
|
1163
|
+
|
|
1164
|
+
# Downloads {{{2
|
|
1165
|
+
def download_file(url)
|
|
1166
|
+
url = resolve_search(url) unless url.match?(%r{^https?://})
|
|
1167
|
+
@status_msg = "Downloading #{url}..."
|
|
1168
|
+
refresh_cmd
|
|
1169
|
+
result = @fetcher.fetch(url)
|
|
1170
|
+
if result[:status] == 200
|
|
1171
|
+
filename = File.basename(URI.parse(result[:url]).path)
|
|
1172
|
+
filename = "download" if filename.empty?
|
|
1173
|
+
dir = File.expand_path("~/Downloads")
|
|
1174
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
1175
|
+
path = File.join(dir, filename)
|
|
1176
|
+
# Avoid overwriting
|
|
1177
|
+
if File.exist?(path)
|
|
1178
|
+
ext = File.extname(filename)
|
|
1179
|
+
base = File.basename(filename, ext)
|
|
1180
|
+
i = 1
|
|
1181
|
+
i += 1 while File.exist?(File.join(dir, "#{base}_#{i}#{ext}"))
|
|
1182
|
+
path = File.join(dir, "#{base}_#{i}#{ext}")
|
|
1183
|
+
end
|
|
1184
|
+
File.binwrite(path, result[:body])
|
|
1185
|
+
@status_msg = "Saved: #{path} (#{result[:body].length} bytes)"
|
|
1186
|
+
else
|
|
1187
|
+
@status_msg = "Download failed: #{result[:status]}"
|
|
1188
|
+
end
|
|
1189
|
+
refresh_cmd
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
# Quickmarks {{{2
|
|
1193
|
+
QUICKMARKS_FILE = File.join(CONFIG_DIR, "quickmarks.yml")
|
|
1194
|
+
|
|
1195
|
+
def load_quickmarks
|
|
1196
|
+
@quickmarks = if File.exist?(QUICKMARKS_FILE)
|
|
1197
|
+
YAML.safe_load(File.read(QUICKMARKS_FILE)) rescue {}
|
|
1198
|
+
else
|
|
1199
|
+
{}
|
|
1200
|
+
end
|
|
1201
|
+
@quickmarks = {} unless @quickmarks.is_a?(Hash)
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1204
|
+
def save_quickmarks
|
|
1205
|
+
Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
|
|
1206
|
+
File.write(QUICKMARKS_FILE, @quickmarks.to_yaml)
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
def set_quickmark
|
|
1210
|
+
@status_msg = "Set quickmark (0-9):"
|
|
1211
|
+
refresh_cmd
|
|
1212
|
+
chr = getchr
|
|
1213
|
+
if chr && chr.match?(/^[0-9]$/)
|
|
1214
|
+
load_quickmarks
|
|
1215
|
+
@quickmarks[chr] = { "url" => current_tab.url, "title" => current_tab.title.to_s }
|
|
1216
|
+
save_quickmarks
|
|
1217
|
+
@status_msg = "Quickmark #{chr} set: #{current_tab.title}"
|
|
1218
|
+
else
|
|
1219
|
+
@status_msg = "Cancelled"
|
|
1220
|
+
end
|
|
1221
|
+
refresh_cmd
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
def goto_quickmark
|
|
1225
|
+
@status_msg = "Go to quickmark (0-9):"
|
|
1226
|
+
refresh_cmd
|
|
1227
|
+
chr = getchr
|
|
1228
|
+
if chr && chr.match?(/^[0-9]$/)
|
|
1229
|
+
load_quickmarks
|
|
1230
|
+
if @quickmarks[chr]
|
|
1231
|
+
navigate(@quickmarks[chr]["url"])
|
|
1232
|
+
else
|
|
1233
|
+
@status_msg = "No quickmark #{chr}"
|
|
1234
|
+
refresh_cmd
|
|
1235
|
+
end
|
|
1236
|
+
else
|
|
1237
|
+
@status_msg = "Cancelled"
|
|
1238
|
+
refresh_cmd
|
|
1239
|
+
end
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
# Ad blocking {{{2
|
|
1243
|
+
ADBLOCK_FILE = File.join(CONFIG_DIR, "adblock.txt")
|
|
1244
|
+
ADBLOCK_DEFAULT_URL = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
|
|
1245
|
+
|
|
1246
|
+
def load_adblock
|
|
1247
|
+
@adblock_domains = Set.new
|
|
1248
|
+
if File.exist?(ADBLOCK_FILE)
|
|
1249
|
+
File.readlines(ADBLOCK_FILE).each do |line|
|
|
1250
|
+
line = line.strip
|
|
1251
|
+
next if line.empty? || line.start_with?("#")
|
|
1252
|
+
@adblock_domains << line
|
|
1253
|
+
end
|
|
1254
|
+
end
|
|
1255
|
+
end
|
|
1256
|
+
|
|
1257
|
+
def update_adblock
|
|
1258
|
+
@status_msg = "Downloading adblock list..."
|
|
1259
|
+
refresh_cmd
|
|
1260
|
+
result = @fetcher.fetch(ADBLOCK_DEFAULT_URL)
|
|
1261
|
+
if result[:status] == 200
|
|
1262
|
+
domains = []
|
|
1263
|
+
result[:body].each_line do |line|
|
|
1264
|
+
line = line.strip
|
|
1265
|
+
next if line.empty? || line.start_with?("#")
|
|
1266
|
+
parts = line.split(/\s+/)
|
|
1267
|
+
if parts.length >= 2 && parts[0] == "0.0.0.0"
|
|
1268
|
+
domain = parts[1]
|
|
1269
|
+
next if domain == "0.0.0.0"
|
|
1270
|
+
domains << domain
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
|
|
1274
|
+
File.write(ADBLOCK_FILE, domains.join("\n"))
|
|
1275
|
+
load_adblock
|
|
1276
|
+
@status_msg = "Adblock updated: #{domains.length} domains blocked"
|
|
1277
|
+
else
|
|
1278
|
+
@status_msg = "Adblock update failed"
|
|
1279
|
+
end
|
|
1280
|
+
refresh_cmd
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
def blocked?(url)
|
|
1284
|
+
return false if @adblock_domains.nil? || @adblock_domains.empty?
|
|
1285
|
+
begin
|
|
1286
|
+
host = URI.parse(url).host
|
|
1287
|
+
@adblock_domains.include?(host)
|
|
1288
|
+
rescue
|
|
1289
|
+
false
|
|
1290
|
+
end
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
# Passwords {{{2
|
|
1294
|
+
PASSWORDS_FILE = File.join(CONFIG_DIR, "passwords.yml")
|
|
1295
|
+
|
|
1296
|
+
def load_passwords
|
|
1297
|
+
@passwords = if File.exist?(PASSWORDS_FILE)
|
|
1298
|
+
YAML.safe_load(File.read(PASSWORDS_FILE)) rescue {}
|
|
1299
|
+
else
|
|
1300
|
+
{}
|
|
1301
|
+
end
|
|
1302
|
+
@passwords = {} unless @passwords.is_a?(Hash)
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1305
|
+
def save_passwords
|
|
1306
|
+
Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
|
|
1307
|
+
File.write(PASSWORDS_FILE, @passwords.to_yaml)
|
|
1308
|
+
File.chmod(0600, PASSWORDS_FILE)
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
def save_password
|
|
1312
|
+
return unless current_tab.url
|
|
1313
|
+
host = URI.parse(current_tab.url).host rescue nil
|
|
1314
|
+
return unless host
|
|
1315
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
1316
|
+
user = @pInput.ask("Username for #{host}: ", "")
|
|
1317
|
+
if user.nil? || user.strip.empty?
|
|
1318
|
+
refresh_cmd; return
|
|
1319
|
+
end
|
|
1320
|
+
pass = @pInput.ask("Password for #{host}: ", "")
|
|
1321
|
+
if pass.nil? || pass.strip.empty?
|
|
1322
|
+
refresh_cmd; return
|
|
1323
|
+
end
|
|
1324
|
+
load_passwords
|
|
1325
|
+
@passwords[host] = { "username" => user.strip, "password" => pass.strip }
|
|
1326
|
+
save_passwords
|
|
1327
|
+
@status_msg = "Password saved for #{host}"
|
|
1328
|
+
refresh_cmd
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
def show_password
|
|
1332
|
+
return unless current_tab.url
|
|
1333
|
+
host = URI.parse(current_tab.url).host rescue nil
|
|
1334
|
+
return unless host
|
|
1335
|
+
load_passwords
|
|
1336
|
+
if @passwords[host]
|
|
1337
|
+
@status_msg = "#{host} - user: #{@passwords[host]["username"]} pass: #{@passwords[host]["password"]}"
|
|
1338
|
+
else
|
|
1339
|
+
@status_msg = "No password stored for #{host}"
|
|
1340
|
+
end
|
|
1341
|
+
refresh_cmd
|
|
1342
|
+
end
|
|
1343
|
+
|
|
1344
|
+
# Form interaction {{{2
|
|
1345
|
+
def check_autofill
|
|
1346
|
+
forms = current_tab.forms || []
|
|
1347
|
+
pw_form = forms.find { |f| f[:has_password] }
|
|
1348
|
+
return unless pw_form
|
|
1349
|
+
host = URI.parse(current_tab.url).host rescue nil
|
|
1350
|
+
return unless host
|
|
1351
|
+
load_passwords
|
|
1352
|
+
if @passwords[host]
|
|
1353
|
+
@status_msg = "Credentials available for #{host}. Press 'e' to fill form."
|
|
1354
|
+
refresh_cmd
|
|
1355
|
+
end
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
def fill_form
|
|
1359
|
+
forms = current_tab.forms || []
|
|
1360
|
+
return if forms.empty?
|
|
1361
|
+
|
|
1362
|
+
# Prefer login forms, fall back to first form
|
|
1363
|
+
form = forms.find { |f| f[:has_password] } || forms.first
|
|
1364
|
+
host = URI.parse(current_tab.url).host rescue nil
|
|
1365
|
+
|
|
1366
|
+
# Try auto-fill from stored passwords
|
|
1367
|
+
load_passwords
|
|
1368
|
+
stored = host ? @passwords[host] : nil
|
|
1369
|
+
|
|
1370
|
+
params = {}
|
|
1371
|
+
has_password_field = false
|
|
1372
|
+
|
|
1373
|
+
form[:fields].each do |field|
|
|
1374
|
+
case field[:type]
|
|
1375
|
+
when "hidden"
|
|
1376
|
+
params[field[:name]] = field[:value] unless field[:name].empty?
|
|
1377
|
+
when "submit"
|
|
1378
|
+
# skip
|
|
1379
|
+
when "password"
|
|
1380
|
+
has_password_field = true
|
|
1381
|
+
prefill = stored ? stored["password"] : ""
|
|
1382
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
1383
|
+
val = @pInput.ask("#{field[:placeholder] || field[:name]}: ", prefill)
|
|
1384
|
+
if val.nil?
|
|
1385
|
+
refresh_cmd; return
|
|
1386
|
+
end
|
|
1387
|
+
params[field[:name]] = val.strip unless field[:name].empty?
|
|
1388
|
+
when "select"
|
|
1389
|
+
opts = (field[:options] || []).map { |o| o[:text] }
|
|
1390
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
1391
|
+
val = @pInput.ask("#{field[:name]} (#{opts.join('/')}): ", field[:value].to_s)
|
|
1392
|
+
if val.nil?
|
|
1393
|
+
refresh_cmd; return
|
|
1394
|
+
end
|
|
1395
|
+
# Match by text or value
|
|
1396
|
+
opt = field[:options]&.find { |o| o[:text].downcase.start_with?(val.strip.downcase) }
|
|
1397
|
+
params[field[:name]] = opt ? opt[:value] : val.strip unless field[:name].empty?
|
|
1398
|
+
else
|
|
1399
|
+
# text, email, etc.
|
|
1400
|
+
prefill = ""
|
|
1401
|
+
if stored && field[:type] =~ /email|text/ && field[:name] =~ /user|email|login|name/i
|
|
1402
|
+
prefill = stored["username"]
|
|
1403
|
+
end
|
|
1404
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
1405
|
+
val = @pInput.ask("#{field[:placeholder] || field[:name]}: ", prefill)
|
|
1406
|
+
if val.nil?
|
|
1407
|
+
refresh_cmd; return
|
|
1408
|
+
end
|
|
1409
|
+
params[field[:name]] = val.strip unless field[:name].empty?
|
|
1410
|
+
end
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
# Submit
|
|
1414
|
+
action = form[:action]
|
|
1415
|
+
action = current_tab.url if action.nil? || action.empty?
|
|
1416
|
+
method = form[:method] == "post" ? :post : :get
|
|
1417
|
+
|
|
1418
|
+
@status_msg = "Submitting form..."
|
|
1419
|
+
refresh_cmd
|
|
1420
|
+
|
|
1421
|
+
if method == :post
|
|
1422
|
+
result = @fetcher.fetch(action, method: :post, params: params)
|
|
1423
|
+
else
|
|
1424
|
+
query = URI.encode_www_form(params)
|
|
1425
|
+
url = action.include?("?") ? "#{action}&#{query}" : "#{action}?#{query}"
|
|
1426
|
+
result = @fetcher.fetch(url)
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
# Offer to save password if this was a new login
|
|
1430
|
+
if has_password_field && host && !stored
|
|
1431
|
+
username = params.find { |k, _| k =~ /user|email|login|name/i }&.last
|
|
1432
|
+
password = params.find { |k, _| k =~ /pass/i }&.last
|
|
1433
|
+
if username && password
|
|
1434
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
1435
|
+
save = @pInput.ask("Save password for #{host}? (y/n): ", "y")
|
|
1436
|
+
if save&.strip&.downcase == "y"
|
|
1437
|
+
@passwords[host] = { "username" => username, "password" => password }
|
|
1438
|
+
save_passwords
|
|
1439
|
+
@status_msg = "Password saved for #{host}"
|
|
1440
|
+
refresh_cmd
|
|
1441
|
+
end
|
|
1442
|
+
end
|
|
1443
|
+
end
|
|
1444
|
+
|
|
1445
|
+
current_tab.navigate(action)
|
|
1446
|
+
current_tab.url = result[:url]
|
|
1447
|
+
render_page(result)
|
|
1448
|
+
refresh_all
|
|
1449
|
+
end
|
|
1450
|
+
|
|
1451
|
+
# Config {{{2
|
|
1452
|
+
def load_config
|
|
1453
|
+
@conf = DEFAULTS.dup
|
|
1454
|
+
if File.exist?(CONFIG_FILE)
|
|
1455
|
+
saved = YAML.safe_load(File.read(CONFIG_FILE)) rescue {}
|
|
1456
|
+
@conf.merge!(saved) if saved.is_a?(Hash)
|
|
1457
|
+
end
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1460
|
+
def save_config
|
|
1461
|
+
Dir.mkdir(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
|
|
1462
|
+
File.write(CONFIG_FILE, @conf.to_yaml)
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
def apply_colors
|
|
1466
|
+
@pInfo.fg = @conf["info_fg"]; @pInfo.bg = @conf["info_bg"]
|
|
1467
|
+
@pTab.fg = @conf["tab_fg"]; @pTab.bg = @conf["tab_bg"]
|
|
1468
|
+
@pMain.fg = @conf["content_fg"]; @pMain.bg = @conf["content_bg"]
|
|
1469
|
+
@pCmd.fg = @conf["cmd_fg"]; @pCmd.bg = @conf["cmd_bg"]
|
|
1470
|
+
refresh_all
|
|
1471
|
+
end
|
|
1472
|
+
|
|
1473
|
+
# Preferences {{{2
|
|
1474
|
+
PREF_SETTINGS = [
|
|
1475
|
+
["Image mode", "image_mode", %w[auto termpix ascii off]],
|
|
1476
|
+
["Show images", "show_images", [true, false]],
|
|
1477
|
+
["Homepage", "homepage", :text],
|
|
1478
|
+
["Search engine", "search_engine", %w[g ddg w]],
|
|
1479
|
+
["Info bar fg", "info_fg", :color],
|
|
1480
|
+
["Info bar bg", "info_bg", :color],
|
|
1481
|
+
["Tab bar fg", "tab_fg", :color],
|
|
1482
|
+
["Tab bar bg", "tab_bg", :color],
|
|
1483
|
+
["Active tab", "tab_active", :color],
|
|
1484
|
+
["Content fg", "content_fg", :color],
|
|
1485
|
+
["Content bg", "content_bg", :color],
|
|
1486
|
+
["Status bar fg", "cmd_fg", :color],
|
|
1487
|
+
["Status bar bg", "cmd_bg", :color],
|
|
1488
|
+
["Link color", "link_color", :color],
|
|
1489
|
+
["Link number", "link_num", :color],
|
|
1490
|
+
["Heading h1", "heading_h1", :color],
|
|
1491
|
+
["Heading h2", "heading_h2", :color],
|
|
1492
|
+
["Heading h3", "heading_h3", :color],
|
|
1493
|
+
]
|
|
1494
|
+
|
|
1495
|
+
def show_preferences
|
|
1496
|
+
pw = 44
|
|
1497
|
+
ph = PREF_SETTINGS.length + 4
|
|
1498
|
+
px = (@w - pw) / 2
|
|
1499
|
+
py = (@h - ph) / 2
|
|
1500
|
+
popup = Pane.new(px, py, pw, ph, 252, 234)
|
|
1501
|
+
popup.border = true
|
|
1502
|
+
sel = 0
|
|
1503
|
+
|
|
1504
|
+
build = lambda do
|
|
1505
|
+
lines = [" brrowser Preferences".b, ""]
|
|
1506
|
+
PREF_SETTINGS.each_with_index do |(label, key, type), i|
|
|
1507
|
+
val = @conf[key]
|
|
1508
|
+
disp = case type
|
|
1509
|
+
when :color
|
|
1510
|
+
"\u2588\u2588".fg(val) + " #{val}"
|
|
1511
|
+
else
|
|
1512
|
+
val.to_s
|
|
1513
|
+
end
|
|
1514
|
+
prefix = i == sel ? " \u25b6 ".fg(220) : " "
|
|
1515
|
+
line = "#{prefix}#{label.ljust(16)} \u25c4 #{disp.to_s.ljust(12)} \u25b6"
|
|
1516
|
+
line = line.r if i == sel
|
|
1517
|
+
lines << line
|
|
1518
|
+
end
|
|
1519
|
+
lines << ""
|
|
1520
|
+
lines << " h/l:change Enter:edit q:save & close".fg(245)
|
|
1521
|
+
popup.text = lines.join("\n")
|
|
1522
|
+
popup.refresh
|
|
1523
|
+
end
|
|
1524
|
+
|
|
1525
|
+
build.call
|
|
1526
|
+
|
|
1527
|
+
loop do
|
|
1528
|
+
chr = getchr
|
|
1529
|
+
case chr
|
|
1530
|
+
when "ESC"
|
|
1531
|
+
save_config
|
|
1532
|
+
apply_colors
|
|
1533
|
+
break
|
|
1534
|
+
when "j", "DOWN"
|
|
1535
|
+
sel = (sel + 1) % PREF_SETTINGS.length
|
|
1536
|
+
when "k", "UP"
|
|
1537
|
+
sel = (sel - 1) % PREF_SETTINGS.length
|
|
1538
|
+
when "l", "RIGHT"
|
|
1539
|
+
label, key, type = PREF_SETTINGS[sel]
|
|
1540
|
+
case type
|
|
1541
|
+
when Array
|
|
1542
|
+
idx = type.index(@conf[key]) || 0
|
|
1543
|
+
@conf[key] = type[(idx + 1) % type.length]
|
|
1544
|
+
when :color
|
|
1545
|
+
@conf[key] = (@conf[key] + 1) % 256
|
|
1546
|
+
end
|
|
1547
|
+
when "h", "LEFT"
|
|
1548
|
+
label, key, type = PREF_SETTINGS[sel]
|
|
1549
|
+
case type
|
|
1550
|
+
when Array
|
|
1551
|
+
idx = type.index(@conf[key]) || 0
|
|
1552
|
+
@conf[key] = type[(idx - 1) % type.length]
|
|
1553
|
+
when :color
|
|
1554
|
+
@conf[key] = (@conf[key] - 1) % 256
|
|
1555
|
+
end
|
|
1556
|
+
when "L" # Shift-L: +10 for colors
|
|
1557
|
+
label, key, type = PREF_SETTINGS[sel]
|
|
1558
|
+
@conf[key] = (@conf[key] + 10) % 256 if type == :color
|
|
1559
|
+
when "H" # Shift-H: -10 for colors
|
|
1560
|
+
label, key, type = PREF_SETTINGS[sel]
|
|
1561
|
+
@conf[key] = (@conf[key] - 10) % 256 if type == :color
|
|
1562
|
+
when "ENTER"
|
|
1563
|
+
label, key, type = PREF_SETTINGS[sel]
|
|
1564
|
+
if type == :text
|
|
1565
|
+
@pCmd.text = ""
|
|
1566
|
+
@pCmd.refresh
|
|
1567
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
1568
|
+
val = @pInput.ask("#{label}: ", @conf[key].to_s)
|
|
1569
|
+
@conf[key] = val.strip unless val.nil? || val.strip.empty?
|
|
1570
|
+
elsif type == :color
|
|
1571
|
+
@pCmd.text = ""
|
|
1572
|
+
@pCmd.refresh
|
|
1573
|
+
@pInput.x = 2; @pInput.w = @w - 1; @pInput.y = @h
|
|
1574
|
+
val = @pInput.ask("#{label} (0-255): ", @conf[key].to_s)
|
|
1575
|
+
if val && val.strip.match?(/^\d+$/)
|
|
1576
|
+
@conf[key] = val.strip.to_i % 256
|
|
1577
|
+
end
|
|
1578
|
+
refresh_cmd
|
|
1579
|
+
end
|
|
1580
|
+
end
|
|
1581
|
+
build.call
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
# Restore screen
|
|
1585
|
+
popup.clear
|
|
1586
|
+
@pInfo.full_refresh
|
|
1587
|
+
@pTab.full_refresh
|
|
1588
|
+
@pMain.full_refresh
|
|
1589
|
+
@pCmd.full_refresh
|
|
1590
|
+
show_visible_image
|
|
1591
|
+
end
|
|
1592
|
+
|
|
1593
|
+
# Main loop {{{2
|
|
1594
|
+
NORMAL_KEYMAP = {
|
|
1595
|
+
"j" => :scroll_down, "k" => :scroll_up,
|
|
1596
|
+
"DOWN" => :scroll_down, "UP" => :scroll_up,
|
|
1597
|
+
"LEFT" => :prev_tab, "RIGHT" => :next_tab,
|
|
1598
|
+
"G" => :go_bottom,
|
|
1599
|
+
"d" => :close_tab, "u" => :undo_close_tab,
|
|
1600
|
+
"o" => :open_url,
|
|
1601
|
+
"t" => :tabopen_url,
|
|
1602
|
+
"H" => :go_back, "L" => :go_forward,
|
|
1603
|
+
"BACK" => :go_back,
|
|
1604
|
+
"r" => :reload,
|
|
1605
|
+
"J" => :next_tab, "K" => :prev_tab,
|
|
1606
|
+
"/" => :search_page,
|
|
1607
|
+
"n" => :search_next, "N" => :search_prev,
|
|
1608
|
+
"?" => :show_help,
|
|
1609
|
+
":" => :command_mode,
|
|
1610
|
+
"TAB" => :next_link, "S-TAB" => :prev_link,
|
|
1611
|
+
"ENTER" => :follow_link_prompt,
|
|
1612
|
+
"C-L" => :redraw_screen,
|
|
1613
|
+
"C-D" => :half_page_down, "C-U" => :half_page_up,
|
|
1614
|
+
"PgDOWN" => :page_down, "PgUP" => :page_up,
|
|
1615
|
+
" " => :page_down,
|
|
1616
|
+
"y" => :yank_url,
|
|
1617
|
+
"Y" => :yank_element,
|
|
1618
|
+
"e" => :edit_source,
|
|
1619
|
+
"f" => :fill_form,
|
|
1620
|
+
"C-G" => :edit_field_in_editor,
|
|
1621
|
+
"b" => :add_bookmark,
|
|
1622
|
+
"B" => :show_bookmarks,
|
|
1623
|
+
"m" => :set_quickmark,
|
|
1624
|
+
"'" => :goto_quickmark,
|
|
1625
|
+
"i" => :toggle_images,
|
|
1626
|
+
"p" => :show_password,
|
|
1627
|
+
"I" => :ai_summarize,
|
|
1628
|
+
"P" => :show_preferences,
|
|
1629
|
+
"q" => :quit_browser,
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
def main_loop(initial_url)
|
|
1633
|
+
Rcurses.init!
|
|
1634
|
+
@h, @w = IO.console.winsize
|
|
1635
|
+
setup_panes
|
|
1636
|
+
new_tab(initial_url)
|
|
1637
|
+
refresh_all
|
|
1638
|
+
$stdin.getc while $stdin.wait_readable(0)
|
|
1639
|
+
|
|
1640
|
+
@showing_image = false
|
|
1641
|
+
@last_img_count = 0
|
|
1642
|
+
|
|
1643
|
+
while @running
|
|
1644
|
+
if @winch
|
|
1645
|
+
@winch = false
|
|
1646
|
+
resize
|
|
1647
|
+
end
|
|
1648
|
+
|
|
1649
|
+
# Auto-show first visible image when downloads complete
|
|
1650
|
+
if @termpix.supported? && !@showing_image
|
|
1651
|
+
new_count = @img_cache.size
|
|
1652
|
+
if new_count > @last_img_count
|
|
1653
|
+
@last_img_count = new_count
|
|
1654
|
+
show_visible_image
|
|
1655
|
+
end
|
|
1656
|
+
end
|
|
1657
|
+
|
|
1658
|
+
chr = getchr(1)
|
|
1659
|
+
next unless chr
|
|
1660
|
+
|
|
1661
|
+
if @g_pressed
|
|
1662
|
+
@g_pressed = false
|
|
1663
|
+
if chr == "g"
|
|
1664
|
+
go_top
|
|
1665
|
+
end
|
|
1666
|
+
next
|
|
1667
|
+
end
|
|
1668
|
+
|
|
1669
|
+
if chr == "g"
|
|
1670
|
+
@g_pressed = true
|
|
1671
|
+
next
|
|
1672
|
+
end
|
|
1673
|
+
|
|
1674
|
+
if chr == "O"
|
|
1675
|
+
open_url(current_tab.url.to_s)
|
|
1676
|
+
next
|
|
1677
|
+
end
|
|
1678
|
+
|
|
1679
|
+
handler = NORMAL_KEYMAP[chr]
|
|
1680
|
+
if handler
|
|
1681
|
+
m = method(handler)
|
|
1682
|
+
m.arity == 0 ? m.call : m.call
|
|
1683
|
+
end
|
|
1684
|
+
end
|
|
1685
|
+
rescue => e
|
|
1686
|
+
Rcurses.cleanup!
|
|
1687
|
+
puts "brrowser error: #{e.message}"
|
|
1688
|
+
puts e.backtrace.first(5).join("\n")
|
|
1689
|
+
exit 1
|
|
1690
|
+
ensure
|
|
1691
|
+
clear_images
|
|
1692
|
+
FileUtils.rm_rf(@img_dir) if @img_dir && Dir.exist?(@img_dir)
|
|
1693
|
+
Rcurses.cleanup!
|
|
1694
|
+
end
|
|
1695
|
+
|
|
1696
|
+
def toggle_images
|
|
1697
|
+
if @showing_image
|
|
1698
|
+
clear_images
|
|
1699
|
+
@status_msg = "Images hidden"
|
|
1700
|
+
else
|
|
1701
|
+
show_visible_image
|
|
1702
|
+
@status_msg = @showing_image ? "Images shown" : "No images in view"
|
|
1703
|
+
end
|
|
1704
|
+
refresh_cmd
|
|
1705
|
+
end
|
|
1706
|
+
|
|
1707
|
+
def quit_browser
|
|
1708
|
+
@running = false
|
|
1709
|
+
end
|
|
1710
|
+
end # }}}1
|
|
1711
|
+
end
|
|
1712
|
+
|
|
1713
|
+
# Entry point
|
|
1714
|
+
url = ARGV[0]
|
|
1715
|
+
Brrowser::Browser.new(url)
|