commit 4c12afe47ec1da4a068e19582c29b77a5b727e8c Author: Luxferre Date: Sun Oct 6 19:09:26 2024 +0300 initial upload diff --git a/README.gmi b/README.gmi new file mode 100644 index 0000000..c7f8473 --- /dev/null +++ b/README.gmi @@ -0,0 +1,185 @@ +# BFG: a multiprotocol "small web" browser in Tcl/Tk + +BFG (stands for Back/Forward/Go) is a: +* free (absolutely), +* open-source, +* cross-platform, +* single-script, +* self-contained +GUI client for a number of "small web" protocols like Gopher, Finger, Nex, Spartan and Gemini. + +BFG is written in under 1000 SLOC of Tcl 8.6 and depends upon Tcllib, Tk (obviously) and TclTLS. + +If you found any bugs, feel free to submit an issue into this repo! + + +## Supported features + +The following features are already (fully or partially) supported in BFG. + +### Complete + +* Finger protocol (finger:// URLs) +* Gopher protocol with optional TLS support (gopher:// and gophers:// URLs) +* Gophermap rendering (entry types 0, 1, 3, 5, 7, 8, 9, i, h) +* Single-line search query support for Gopher type 7 resources +* Nex protocol (nex:// URLs) +* Spartan protocol (spartan:// URLs) with single-line text query support +* Gemini protocol (gemini:// URLs) with single-line custom input support +* UTF-8 support for text content +* Basic history support (back/forward actions) +* Page refresh action +* Page/file download action +* Status bar +* Bookmarks +* Opening a URL from the command line +* Opening unknown URL schemes in the outside OS environment +* Opening the links in a new window +* Easy keybindings for fully mouseless navigation (see "Usage" section) +* Dynamic window title based on the current URL +* In-page text search +* Ini-based customization of the current Ttk theme, content fonts and colors + +## Non-supported features + +The following features aren't supported by BFG as of now, some of them never will be. +In general, there are two reasons why a feature might not be implemented in BFG: + +* it bloats the scope too much (making the codebase over 1000 SLOC with no space for optimization), +* it requires more third-party package dependencies (other than Tk, Tcllib and TclTLS). + +### Still pending (might be imlpemented someday) + +* Source view toggle (between plaintext and rendered gophermap/Gemtext/etc) +* Local plaintext/Gemtext reader (file:// URL scheme) +* ANSI terminal coloring (basic 16 colors only) +* Image viewer (non-embedded, only PNG/GIF/PPM/PGM types) +* Gemini client certificates (might never be implemented; don't ask for it unless absolutely necessary) + +### Unsupported by design + +* Tabs (they complicate the architecture a lot; use your WM's tabbing functionality instead) +* Horizontal scrolling (word wrapping is mandatory; expand the window if the content doesn't fit) +* Saved history (unsupported for security reasons; use bookmarks if you want to save a page URL) +* Any other text content encoding except UTF-8 +* Context menus +* Any sort of sound or video output +* Any sort of embedded non-text content +* NPS and Titan protocols and other means of content uploading \* +* Guppy protocol (UDP-based, don't want to introduce another third-party dependency) + +(\* probably will be implemented in a separate program) + + +## Usage + +You can run the script with an optional URL to open, e.g.: wish bfg.tcl gopher://hoi.st + +The following keybindings are currently supported: + +* b: bo back in history +* f: go forward in history +* g: focus the address bar and select the current address (to be able to easily overwrite it) +* r or F5: refresh the content +* a: select and highlight the previous link +* s: select and highlight the next link +* d: download the file or page contents under the selected link +* Shift+d: download the file or page contents under the selected link and open it with your default application +* Space / Return (Enter): click on the selected link +* Shift + Space / Shift + Return (Enter): open the selected link in a new window +* h / Arrow Left / PgUp: scroll the content up 10 lines +* j / Arrow Down: scroll the content down 1 line +* k / Arrow Up: scroll the content up 1 line +* l / Arrow Right / PgDn: scroll the content down 10 lines +* / (forward slash) or ctrl+f: enter a term to find in the text (enter nothing to find the next one) +* Esc: reset all search highlights +* Ctrl+b: open bookmarks menu (you can navigate with arrows after opening) +* Ctrl+q: quit BFG + +The following mouse bindings are currently supported: + +* Left click: open the link +* Shift + Left click or Middle click: open the link in a new window +* Right click: download the link contents +* Shift + Right click: download the link contents and open it with your default application +* Scroll wheel: scroll the page content +* Mouse button 8 ("Back") if present: go back in history +* Mouse button 9 ("Forward") if present: go forward in history + + +## Customization + +BFG ships with a bfg.ini file that holds your configuration values and bookmarks. +The main configuration sections are: + +* [net]: network-related configs, currently only has "timeout" parameter +* [widget]: general UI widget appearance (ttkTheme, font (button font), entryfont, statusfont) +* [style.general]: general content area appearance (textfont, foreground color, background color) +* [style.link.normal]: normal link colors (foreground, background) +* [style.link.focused]: currently focused link colors (foreground, background) +* [style.highlight]: highlighted area colors (foreground, background) - used with search functionality +* [style.error]: error message colors (foreground, background) + +Feel free to modify the bfg.ini file according to your own preferences, the one shipped is just an example. + + +## FAQ + +### Why yet another "small web" client? + +I like the idea of browsing without bloatware. I also like Lagrange but it already looks too bloated to me. +I also like the idea of Tcl/Tk as the universal tool for creating lightweight and portable GUI applications. +BFG was created as a great playground to combine those ideas and as the next iteration over Bopher-NG. +Which, as you might have guessed, only supports Gopher. + +Contrary to most GUI clients, BFG is: + +* keyboard-friendly (everything that can be done with a mouse can be done with a keyboard), +* trackball-friendly (the middle click action can be substituted with Shift + left click), +* not overloaded with menus, +* under 50K bytes in size. + +### Why such keybindings? + +When using "normal" browsers, we don't fully utilize the keyboard. BFG combines the best practices from: +* Links/Elinks, +* less, +* Bopher-NG +to create the most comfortable UX in case you choose to not use the mouse at all. +In fact, the browser name itself contains the first three keybindings to start using it without a mouse. + +### Can you build a binary distribuition of BFG? + +The main idea of BFG is to be as portable as possible, so no, binaries will not be distributed here. +However, you always can do it yourself. Here's how. + +1. Obtain the tclkits for your host and target platforms, e.g. build them here: +=> https://kitcreator.rkeene.org/kitcreator +Make sure you're building the target tclkit with Metakit, Tcllib, Tk and TLS packages enabled. +Optionally you can select "TLS: Statically link to LibSSL" and/or "TLS: Build LibreSSL for this platform". +If the host and target platform are the same, you still need two kit binaries (just make a copy). + +2. Download sdx.kit from somewhere, e.g. from here: +=> https://codeberg.org/luxferre/StreamGoose/raw/branch/main/tcldep/sdx.kit + +3. Put the two binaries and sdx.kit into the same directory as bfg.tcl, then run from this directory: +``` +[/path/to/host/tclkit] sdx.kit qwrap bfg.tcl -runtime [/path/to/target/tclkit] +``` +A file just named "bfg" should appear, this is your executable. + +4. If building for Windows, rename "bfg" to "bfg.exe". + +### I want this feature X to be implemented! Can you do this? + +If the feature doesn't belong to the pending items list above, the answer most probably is no. +The pending list can be updated from time to time, so please check it periodically. +Alternatively, you can do a proposal in the "Issues" section or even create a patch or a pull request. +However, one of the BFG's goals is to stay under 1000 SLOC no matter what. +If you want feature creep, feel free to switch to more suitable clients like Lagrange. + +### + +## Credits + +Created by Luxferre in 2024, released into the public domain. diff --git a/bfg.ini b/bfg.ini new file mode 100644 index 0000000..5a3ccc4 --- /dev/null +++ b/bfg.ini @@ -0,0 +1,43 @@ +[widget] +entryfont=Noto Sans +entryfontsize=11 +font=Noto Sans +fontsize=16 +statusfont=Noto Sans +statusfontsize=11 +ttkTheme=gtkTtk + +[style.link.normal] +background=#111 +foreground=#eee + +[style.link.focused] +background=#111 +foreground=cyan + +[bookmarks] +HOI.ST=gopher://hoi.st +Nightfall City=nex://nightfall.city +Project Gemini=gemini://geminiprotocol.net +Spartan Portal=spartan://mozz.us +TLGS=gemini://tlgs.one + +[style.general] +background=#111 +foreground=#eee +monofont=Fira Code +monofontsize=12 +textfont=Noto Sans +textfontsize=12 + +[style.error] +background=#111 +foreground=red + +[net] +timeout=5000 + +[style.highlight] +background=yellow +foreground=#111 + diff --git a/bfg.tcl b/bfg.tcl new file mode 100755 index 0000000..e0ca2c6 --- /dev/null +++ b/bfg.tcl @@ -0,0 +1,1110 @@ +#!/usr/bin/env tclsh +# BFG (aka Back/Forward/Go): a no-nonsense Tcl/Tk 8.6+ client for "small web" +# currently supported protocols: +# Gopher (incl. gophers://), Finger, Nex, Spartan, Gemini +# depends on Tk, Tcllib and TclTLS +# created by Luxferre in 2024, released into public domain + +package require uri +package require tls +package require inifile +package require Tk + +# overall vars +set tclpath [ file normalize [ info nameofexecutable ] ] +set scriptpath [ file normalize [ info script ] ] +set appdir [ file dirname $scriptpath ] +# check if we're running from a starpack +if [string match *app-bfg $appdir] { + set appdir [ file normalize [ file join $appdir ".." ".." ".." ] ] +} +set config_file [ file join $appdir "bfg.ini" ] + +# configuration defaults +set bfg_net_timeout 4000 +set bfg_theme "clam" +set bfg_widgetfont "TkDefaultFont" +set bfg_widgetfontsize 16 +set bfg_entryfont "TkTextFont" +set bfg_entryfontsize 10 +set bfg_statusfont "TkDefaultFont" +set bfg_statusfontsize 10 +set bfg_textfont "TkFixedFont" +set bfg_textfontsize 12 +set bfg_monofont "TkFixedFont" +set bfg_monofontsize 12 +set bfg_main_fgcolor black +set bfg_main_bgcolor white +set bfg_link_fgcolor $bfg_main_fgcolor +set bfg_link_bgcolor $bfg_main_bgcolor +set bfg_focusedlink_fgcolor blue +set bfg_focusedlink_bgcolor $bfg_main_bgcolor +set bfg_highlight_fgcolor $bfg_main_fgcolor +set bfg_highlight_bgcolor yellow +set bfg_error_fgcolor red +set bfg_error_bgcolor $bfg_main_bgcolor + +# read configuration from ini +set confini "" +catch {set confini [::ini::open $config_file -encoding utf-8]} +set val "" +set bookmarks "" +if {$confini ne ""} { + set val [::ini::value $confini net timeout $bfg_net_timeout] + if {$val ne ""} {set bfg_net_timeout $val} + set val [::ini::value $confini widget ttkTheme $bfg_theme] + if {$val ne ""} {set bfg_theme $val} + set val [::ini::value $confini widget font $bfg_widgetfont] + if {$val ne ""} {set bfg_widgetfont $val} + set val [::ini::value $confini widget fontsize $bfg_widgetfontsize] + if {$val ne ""} {set bfg_widgetfontsize $val} + set val [::ini::value $confini widget entryfont $bfg_entryfont] + if {$val ne ""} {set bfg_entryfont $val} + set val [::ini::value $confini widget entryfontsize $bfg_entryfontsize] + if {$val ne ""} {set bfg_entryfontsize $val} + set val [::ini::value $confini widget statusfont $bfg_statusfont] + if {$val ne ""} {set bfg_statusfont $val} + set val [::ini::value $confini widget statusfontsize $bfg_statusfontsize] + if {$val ne ""} {set bfg_statusfontsize $val} + set val [::ini::value $confini style.general textfont $bfg_textfont] + if {$val ne ""} {set bfg_textfont $val} + set val [::ini::value $confini style.general textfontsize $bfg_textfontsize] + if {$val ne ""} {set bfg_textfontsize $val} + set val [::ini::value $confini style.general monofont $bfg_monofont] + if {$val ne ""} {set bfg_monofont $val} + set val [::ini::value $confini style.general monofontsize $bfg_monofontsize] + if {$val ne ""} {set bfg_monofontsize $val} + set val [::ini::value $confini style.general foreground $bfg_main_fgcolor] + if {$val ne ""} {set bfg_main_fgcolor $val} + set val [::ini::value $confini style.general background $bfg_main_bgcolor] + if {$val ne ""} {set bfg_main_bgcolor $val} + set val [::ini::value $confini style.link.normal foreground $bfg_link_fgcolor] + if {$val ne ""} {set bfg_link_fgcolor $val} + set val [::ini::value $confini style.link.normal background $bfg_link_bgcolor] + if {$val ne ""} {set bfg_link_bgcolor $val} + set val [::ini::value $confini style.link.focused foreground $bfg_focusedlink_fgcolor] + if {$val ne ""} {set bfg_focusedlink_fgcolor $val} + set val [::ini::value $confini style.link.focused background $bfg_focusedlink_bgcolor] + if {$val ne ""} {set bfg_focusedlink_bgcolor $val} + set val [::ini::value $confini style.highlight foreground $bfg_highlight_fgcolor] + if {$val ne ""} {set bfg_highlight_fgcolor $val} + set val [::ini::value $confini style.highlight background $bfg_highlight_bgcolor] + if {$val ne ""} {set bfg_highlight_bgcolor $val} + set val [::ini::value $confini style.error foreground $bfg_error_fgcolor] + if {$val ne ""} {set bfg_error_fgcolor $val} + set val [::ini::value $confini style.error background $bfg_error_bgcolor] + if {$val ne ""} {set bfg_error_bgcolor $val} + + # load bookmarks + if {[::ini::exists $confini bookmarks]} { + foreach key [::ini::keys $confini bookmarks] { + dict set bookmarks $key [::ini::value $confini bookmarks $key] + } + } + + # close the ini for now + ::ini::close $confini +} + +# generate fonts +foreach fid {widget entry status text mono} { + set vname [string cat bfg_ $fid font] + set ffamily [set $vname] + set fsize [set [string cat $vname size]] + unset -nocomplain $vname + font create $vname -family "$ffamily" -size $fsize -weight normal +} + +# logic part + +# store browser history here +set bfg_history "" +set bfg_histidx 0 + +# truncate status characters +proc trunc {s} { + set mlen 60 + set nlen [expr { ($mlen / 2) - 1 }] + set len [string length $s] + if {$len > $mlen} { + # truncate characters in the middle + set p1 [string range $s 0 $nlen] + set p2 [string range $s [expr {$len - $nlen}] end] + return [string cat $p1 "…" $p2] + } else {return $s} +} + +# common request logic: +# 1) connect via TCP (or TLS) +# 2) send a single data chunk with selector etc +# 3) read the incoming data +# 4) close the connection +proc reqresp {host port reqline is_tls encoding} { + global bfg_response bfg_net_timeout + if {$is_tls eq 1} { + set sock [::tls::socket -autoservername true -async $host $port] + } else { + set sock [socket -async $host $port] + } + global rcv_end_$sock + unset -nocomplain rcv_end_$sock + if {$encoding eq ""} {set encoding utf-8} + fconfigure $sock -translation binary -buffering none -encoding $encoding + fileevent $sock writable [list connected $sock $reqline] + proc connected {sock reqline} { + fileevent $sock writable {} + puts -nonewline $sock "$reqline" + flush $sock + fileevent $sock readable [list rdbl $sock] + } + set bfg_response "" + proc rdbl {sock} { + global bfg_response rcv_end_$sock + while {![eof $sock]} { + append bfg_response [read $sock] + } + set rcv_end_$sock 0 + } + after $bfg_net_timeout "global rcv_end_$sock; set rcv_end_$sock 1" + vwait rcv_end_$sock + catch {close $sock} + if {"$bfg_response" eq ""} { + set bfg_response "Connection refused or timed out" + } + unset -nocomplain rcv_end_$sock +} + +# parse target URL +# returns dictionary {scheme host port path handler} +# uses some hacky http-like logic of Tcllib uri module +proc url2dict {inputurl} { + set out [dict create] + if [regexp {^(.*)://} $inputurl _ lscheme] { + dict set out scheme $lscheme + } else { + dict set out handler "render_handler_invalid" + return $out + } + set rawout [::uri::split [regsub {^.*://} $inputurl "http://"]] + set rhost [dict get $rawout host] + set rpath [dict get $rawout path] + set rport [dict get $rawout port] + set secondarydata [dict get $rawout query] + dict set out host $rhost + set selector $rpath + dict set out handler "render_handler_$lscheme" + # protocol-specific request logic + switch "$lscheme" { + gophers - + gopher { + if {$rport eq ""} {set rport 70} + set selector [string cat [string range $rpath 1 end] "\r\n"] + dict set out handler render_handler_gopher + } + finger { + if {$rport eq ""} {set rport 79} + set selector "$selector\r\n" + } + spartan { + if {$rport eq ""} {set rport 300} + if {$rpath eq ""} {set rpath "/"} + if {![string match "/*" $rpath]} { + set rpath "/$rpath" + } + set blen [string length $secondarydata] + set selector "$rhost $rpath $blen\r\n$secondarydata" + } + nex { + if {$rport eq ""} {set rport 1900} + set selector "$selector\r\n" + } + gemini { + if {$rport eq ""} {set rport 1965} + set selector "$inputurl\r\n" + } + default {dict set out handler render_handler_none} + } + dict set out path $rpath + dict set out selector $selector + dict set out port $rport + return $out +} + +# link clicking logic +set linkcount 0 +set curlink 0 +# all link tracker +set linklist "" +# interactive link tracker (prefixes) +set ilinklist "" +proc clicklink {linknum} { + global bfg_prompt sentry_status linklist ilinklist + set url [lindex $linklist $linknum] + set iprefix [lindex $ilinklist $linknum] + if {$iprefix ne ""} { + grid .sframe.sentry + .sframe.sentry state !disabled + set sentry_status "Prompt from [trunc $url]:" + focus .sframe.sentry + vwait bfg_prompt + .sframe.sentry state disabled + grid remove .sframe.sentry + set url "$url$iprefix$bfg_prompt" + unset -nocomplain bfg_prompt + } + urlfetch $url 1 +} + +# open link in a new window +proc windowlink {linknum} { + global bfg_prompt sentry_status linklist ilinklist + global tclpath scriptpath + set url [lindex $linklist $linknum] + set iprefix [lindex $ilinklist $linknum] + if {$iprefix ne ""} { + grid .sframe.sentry + .sframe.sentry state !disabled + set sentry_status "Prompt from [trunc $url]:" + focus .sframe.sentry + vwait bfg_prompt + .sframe.sentry state disabled + grid remove .sframe.sentry + set url "$url$iprefix$bfg_prompt" + unset -nocomplain bfg_prompt + } + # forward the URL to a new window + exec -ignorestderr -- $tclpath $scriptpath $url & +} + +# link downloading logic +proc downloadlink {linknum openExternally} { + global bfg_prompt sentry_status linklist ilinklist + global bfg_status bfg_response + set url [lindex $linklist $linknum] + set iprefix [lindex $ilinklist $linknum] + if {$iprefix ne ""} { + grid .sframe.sentry + .sframe.sentry state !disabled + set sentry_status "Prompt from [trunc $url]:" + focus .sframe.sentry + vwait bfg_prompt + .sframe.sentry state disabled + grid remove .sframe.sentry + set url "$url$iprefix$bfg_prompt" + unset -nocomplain bfg_prompt + } + # get the raw contents in $bfg_response + set scheme [urldl $url 1] + # sanitize the contents for Gemini or Spartan + if {($scheme eq "spartan") || ($scheme eq "gemini")} { + regsub {^\d[^\r].*\r\n} $bfg_response "" bfg_response + } + # get the last part of the URL as suggested filename + set uname [file tail $url] + set outfname [tk_getSaveFile -confirmoverwrite true -initialfile $uname] + if {$outfname ne ""} { + set fp [open $outfname w] + fconfigure $fp -translation binary + puts -nonewline $fp $bfg_response + close $fp + set bfg_response "" + set bfg_status "Downloaded [trunc $url]" + after 2000 {set bfg_status Ready} + if {$openExternally eq 1} { + extOpen $outfname + } + } +} + +# link insertion logic +proc linkinsert {textw prevurl rooturl url name iprefix tag} { + global linkcount linklist ilinklist + global contentcursor + global bfg_status bfg_link_fgcolor bfg_link_bgcolor + set linktext "$url" + if {$name ne ""} { + set linktext $name + } + set linktext "=> $linktext" + set tagname "bfglink_$linkcount" + # resolve the url as local if it doesn't contain a scheme + if {[::uri::isrelative "$url"]} { + regexp {^(.*)://} $rooturl _ lscheme + # case 1: it has 2 slashes in the forward, case 2: it has 1 slash + # case 3: it has a suffix on the end, case 4: the rest + if {[string match "//*" "$url"]} { + set url [string cat "$lscheme" ":" "$url"] + } elseif {[string match "/*" "$url"]} { + set url [string cat "$rooturl" "$url"] + } elseif {[string match {*/*.???} "$prevurl"]} { + set url [string cat "$prevurl" "/../" "$url"] + } else { + set url [string cat "$prevurl" "/" "$url"] + } + # recanonicalize the URL + set url [regsub {^http://} [::uri::canonicalize [regsub {^.*://} "$url" "http://"]] "$lscheme://"] + } + # handle possible interactive part + set bindcmd "clicklink $linkcount" + set bindcmd2 "windowlink $linkcount" + set bindcmd3 "downloadlink $linkcount 0" + set bindcmd4 "downloadlink $linkcount 1" + lappend ilinklist $iprefix + # perform the insertion + $textw insert end $linktext "$tagname $tag" + $textw tag configure $tagname -underline on -foreground $bfg_link_fgcolor -background $bfg_link_bgcolor + $textw tag bind $tagname $bindcmd + $textw tag bind $tagname $bindcmd2 + $textw tag bind $tagname $bindcmd2 + $textw tag bind $tagname $bindcmd3 + $textw tag bind $tagname $bindcmd4 + $textw tag bind $tagname "set prev_status \"\$bfg_status\";set bfg_status [trunc \"$url\"];%W configure -cursor hand2" + $textw tag bind $tagname { + set bfg_status $prev_status + %W configure -cursor $contentcursor + } + $textw insert end "\n" + # populate the link list + lappend linklist $url + incr linkcount +} + +# open unknown protocols in the external OS +proc extOpen {url} { + global tcl_platform + if {$tcl_platform(platform) eq "windows"} { + set command [list {*}[auto_execok start] {}] + if {[file isdirectory $url]} { + set url [file nativename [file join $url .]] + } + } elseif {$tcl_platform(os) eq "Darwin"} { + set command [list open] + } else { + set command [list xdg-open] + } + exec {*}$command $url & +} + +# common URL downloader +# return the contents in $bfg_response +proc urldl {inputurl isbinary} { + set parts [url2dict $inputurl] + set host [dict get $parts host] + set port [dict get $parts port] + set path [dict get $parts path] + set selector [dict get $parts selector] + set scheme [dict get $parts scheme] + set is_tls 0 + if {$scheme eq "gemini"} {set is_tls 1} + if {$scheme eq "gophers"} {set is_tls 1} + set encoding "utf-8" + if {$isbinary eq 1} {set encoding "binary"} + reqresp $host $port $selector $is_tls $encoding + return $scheme +} + +# common URL fetcher: pass to handler +proc urlfetch {inputurl refhist} { + set parts [url2dict $inputurl] + set handler [dict get $parts handler] + if {$handler eq "render_handler_none"} { + extOpen $inputurl; # attempt to pass the URL to the external OS + } elseif {$handler eq "render_handler_invalid"} { + global linkcount linklist ilinklist curlink bookmarkactive bookmarklabel + set linkcount 0 + set curlink 0 + set linklist "" + set ilinklist "" + .content.text configure -state normal + .content.text delete 1.0 end; # clear the previous contents + .content.text insert end "Please enter a valid URL" + .content.text configure -state disabled + # append to the history + if {$refhist eq 1} {histappend "about:invalid"} + set bookmarkactive "\u2605" + set bookmarklabel "Add bookmark" + refreshbookmarks + } else { + global bfg_status bfg_response linkcount linklist targeturl + global bfg_history bfg_histidx curlink ilinklist + global bookmarks bookmarkactive bookmarklabel + set bfg_status [trunc "Fetching $inputurl..."] + set linkcount 0 + set curlink 0 + set linklist "" + set ilinklist "" + set scheme [dict get $parts scheme] + set host [dict get $parts host] + set port [dict get $parts port] + set path [dict get $parts path] + set rooturl "$scheme://$host:$port" + urldl $inputurl 0 + set bfg_status [trunc "Rendering $inputurl..."] + .content.text configure -state normal + .content.text delete 1.0 end; # clear the previous contents + $handler "$inputurl" "$rooturl" "$path" "$bfg_response" .content.text + .content.text configure -state disabled + # append to the history + if {$refhist eq 1} {histappend $inputurl} + # finalize + if {$linkcount > 0} {linkfocus 0 1; set curlink 0} + set targeturl $inputurl + wm title . [string cat "BFG - " [trunc $targeturl]] + # update the bookmark character if the URL is bookmarked + if {$targeturl in [dict values $bookmarks]} { + set bookmarkactive "\u2605" + set bookmarklabel "Remove bookmark" + } else { + set bookmarkactive "\u2606" + set bookmarklabel "Add bookmark" + } + refreshbookmarks + set bfg_status Ready + focus .content.text + } +} + +# plaintext sanitizer: trim every line end +proc textsanitize {text} { + set newtext "" + foreach line [split $text \n] { + set line [string trimright $line] + set newtext [string cat $newtext $line "\n"] + } + return $newtext +} + +# link insertion helper +proc widget_linsert {textw prevurl rooturl linkpart prefix tag} { + set llist [split [string trim "$linkpart"]] + set linkurl [lindex $llist 0] + set linkname "" + if {[llength $llist] > 1} { + set linkname [string trim [join [lrange $llist 1 end] " "]] + } + linkinsert $textw "$prevurl" "$rooturl" "$linkurl" "$linkname" "$prefix" "$tag" +} + +# Gemtext rendering (for Gemini and Spartan) +# Since BFG displays contents in monospace, only three things +# are processed (per the specification): +# links, preformatting (with optional suffixes) and Spartan's input prompts + +proc render_gemtext {textw prevurl rooturl gemtext is_spartan} { + set preformat false + foreach line [split "$gemtext" "\n"] { + set line [string trimright "$line"] + if {[string match "```*" $line]} {; # preformatting mode + set preformat [expr {!$preformat}] + } else { + if {$preformat} { + $textw insert end "$line\n" preformatter + } else { + if {[regexp {^=>(.*)$} $line _ linkpart]} {; # regular link detected + widget_linsert $textw "$prevurl" "$rooturl" "$linkpart" "" "" + } elseif {$is_spartan && [regexp {^=:(.*)$} $line _ linkpart]} {; # input link detected + widget_linsert $textw "$prevurl" "$rooturl" "$linkpart" "?" "" + } else {$textw insert end "$line\n"} + } + } + } +} + +# individual protocol rendering handlers + +# finger content is just plain text +proc render_handler_finger {prevurl rooturl path body textw} { + $textw insert end [textsanitize $body] preformatter +} + +# nex content depends on the path: +# with "" or ending in "/", it is plain text with => links +# otherwise, it's just plain text +proc render_handler_nex {prevurl rooturl path body textw} { + if {$path eq ""} {set path /} + if {[string index $path end] eq "/"} { + foreach line [split $body \n] { + set line [string trimright $line] + if [regexp {^=>\s(.*)$} $line _ linkpart] {; # link detected + widget_linsert $textw $prevurl $rooturl $linkpart "" preformatter + } else {; # normal line + $textw insert end "$line\n" preformatter + } + } + } else { + $textw insert end [textsanitize $body] preformatter + } +} + +# gopher content depends on the first character in the selector path +proc render_handler_gopher {prevurl rooturl path body textw} { + if {($path eq "") || ($path eq "/")} { + set gophertype 1 + set selector "" + } else { + set gophertype [string index $path 0] + set selector [string range $path 1 end] + } + # for now, let's support TSV gophermaps and treat everything else as plaintext + if {($gophertype eq 1) || ($gophertype eq 7)} { + # get root url scheme, host and port + regexp {^(.*)://} $rooturl _ rootscheme + set parts [::uri::split [regsub {^.*://} $rooturl "http://"]] + set roothost [dict get $parts host] + set rootport [dict get $parts port] + # parse the gophermap body + foreach line [split $body \n] { + set fields [split [string trimright $line] \t] + set l [llength $fields] + set rtype "" + set rdesc "" + set rsel "" + set rhost "" + set rport "" + if {$l > 0} {set rdesc [string trim [lindex $fields 0]]} + if {$l > 1} {set rsel [string trim [lindex $fields 1]]} + if {$l > 2} {set rhost [string trim [lindex $fields 2]]} + if {$l > 3} {set rport [string trim [lindex $fields 3]]} + if {[string length $rdesc] > 0} { + set rtype [string index $rdesc 0] + set rdesc [string range $rdesc 1 end] + if {$l < 2} { + set rsel $rdesc + } + } + # fill in the missing host and port + if {$rhost eq ""} {set rhost $roothost} + if {$rport eq ""} {set rport $rootport} + # update the scheme if the resource is the same + set tscheme gopher + if {($rhost eq $roothost) && ($rport eq $rootport)} { + set tscheme $rootscheme + } + # now we have resource type, description, selector, host and port + # let's output it depending on the type and ignore unknown types + switch $rtype { + 0 - + 1 - + 5 - + 9 { # plain text, gophermap, binary + linkinsert $textw $prevurl $rooturl "$tscheme://$rhost:$rport/$rtype$rsel" $rdesc "" preformatter + } + 7 { # gophermap with input + linkinsert $textw $prevurl $rooturl "$tscheme://$rhost:$rport/$rtype$rsel" $rdesc "\t" preformatter + } + 8 { # telnet session link + set username "" + if {$rsel ne ""} {set username "$rsel@"} + linkinsert $textw $prevurl $rooturl "telnet://$username$rhost:$rport" $rdesc "" preformatter + } + 3 { # error message + $textw insert end "$rdesc\n" {errormsg preformatter} + } + i { # info message + $textw insert end "$rdesc\n" preformatter + } + h { # HTTP URL + regsub ***=URL: $rsel "" clearurl + linkinsert $textw $prevurl $rooturl [string trim $clearurl] $rdesc "" preformatter + } + default {} + } + } + } elseif {($gophertype eq 5) || ($gophertype eq 9)} { + $textw insert end $body preformatter + } else { + $textw insert end [textsanitize $body] preformatter + } +} + +# Spartan has a status line in response and multiple MIME types +# we support only text/gemini and text/plain for now +proc render_handler_spartan {prevurl rooturl path body textw} { + # get the status line + if {[regexp {^(.*)\n} $body _ statusline]} { + set statusline [string trimright $statusline] + set statusparts [split $statusline " "] + set statuscode [lindex $statusparts 0] + set statusinfo [join [lrange $statusparts 1 end] " "] + if {$statuscode eq 2} { # success - get the remaining body + regsub {.*?\n} $body "" body + if {[string match "text/gemini*" $statusinfo]} { + render_gemtext $textw "$prevurl" "$rooturl" "$body" true + } elseif {[string match "text/plain*" $statusinfo]} { + $textw insert end [textsanitize $body] preformatter + } else { + $textw insert end $body preformatter + } + } elseif {$statuscode eq 3} { # redirect + if {![string match "/*" $statusinfo]} { + set statusinfo "/$statusinfo" + } + set newurl [string cat $rooturl $statusinfo] + urlfetch "$newurl" 0 + } else { # error + $textw insert end "Error $statuscode: $statusinfo\n" + } + } else { + $textw insert end "Error: empty response from server\n" + } +} + +# Gemini is like Spartan but with a separate input logic +# and relative URL support required +proc render_handler_gemini {prevurl rooturl path body textw} { + if {![string match "/*" "$path"]} {set path "/$path"} + if {[string match "*/" "$rooturl"]} {set rooturl [string range $rooturl 0 end-1]} + set baseurl [string cat "$rooturl" "$path"] + # get the status line + if {[regexp {^(.*)\n} $body _ statusline]} { + set statusline [string trimright $statusline] + set statusparts [split $statusline " "] + set statuscode [lindex $statusparts 0] + set mainstatuscode [string index $statuscode 0] + set statusinfo [join [lrange $statusparts 1 end] " "] + # we have several code groups + switch $mainstatuscode { + 1 { # input required + global sentry_status bfg_prompt + grid .sframe.sentry + .sframe.sentry state !disabled + unset -nocomplain bfg_prompt + set sentry_status "$statusinfo:" + focus .sframe.sentry + vwait bfg_prompt + .sframe.sentry state disabled + grid remove .sframe.sentry + set newurl "$baseurl?$bfg_prompt" + urlfetch "$newurl" 1 + } + 2 { # success - get the remaining body + regsub {.*?\n} $body "" body + if {[string match "text/gemini*" $statusinfo]} { + render_gemtext $textw "$prevurl" "$rooturl" "$body" true + } elseif {[string match "text/plain*" $statusinfo]} { + $textw insert end [textsanitize $body] preformatter + } else { + $textw insert end $body preformatter + } + } + 3 { # redirect + if {[::uri::isrelative $statusinfo]} { + set newurl [string cat $baseurl $statusinfo] + } else {set newurl $statusinfo} + urlfetch "$newurl" 0 + } + 6 { # client certificate status - not supported yet + $textw insert end "The server requires a client certificate — not implemented yet\n" + $textw insert end "Status $statuscode: $statusinfo\n" errormsg + } + default { # error + $textw insert end "Error $statuscode: $statusinfo\n" errormsg + } + } + } else { + $textw insert end "Error: empty response from server\n" + } +} + +# history navigation part + +proc histappend {inputurl} { + global bfg_history bfg_histidx + set prevurl "" + set previdx [expr {$bfg_histidx - 1}] + if {$bfg_histidx > 0} { + set prevurl [lindex $bfg_history $previdx] + } + if {$inputurl ne $prevurl} { + set bfg_history [lrange $bfg_history 0 $previdx] + lappend bfg_history $inputurl + incr bfg_histidx + .ctrl.back configure -state normal + .ctrl.fwd configure -state disabled + } +} + +proc goback {} { + global bfg_history bfg_histidx targeturl + if {$bfg_histidx > 0} { + set newurl [lindex $bfg_history [expr {$bfg_histidx - 2}]] + if {($newurl ne "") && ($newurl ne $targeturl)} { + incr bfg_histidx -1 + urlfetch "$newurl" 0 + .ctrl.fwd configure -state normal + if {$bfg_histidx < 2} { + .ctrl.back configure -state disabled + } + } + } else { + .ctrl.back configure -state disabled + } +} + +proc goforward {} { + global bfg_history bfg_histidx targeturl + set newurl [lindex $bfg_history $bfg_histidx] + if {($newurl ne "") && ($newurl ne $targeturl)} { + incr bfg_histidx + urlfetch "$newurl" 0 + .ctrl.back configure -state normal + if {$bfg_histidx >= [llength $bfg_history]} { + .ctrl.fwd configure -state disabled + } + } +} + +# in-page text search functionality +set cursearchidx "1.0" +set cursearchpattern "" +proc textsearch {pattern dir isregex} { + global cursearchidx cursearchpattern + global bfg_highlight_bgcolor bfg_highlight_fgcolor + if {("$pattern" eq "") && ("$cursearchpattern" ne "")} { + set pattern "$cursearchpattern" + } + if {("$pattern" ne "$cursearchpattern") && ("$pattern" ne "")} { + set cursearchidx "1.0" + } + set mode {-exact} + if {$isregex} {set mode {-regexp}} + set numdir [expr {int($dir)}] + if {$dir eq -1} { + set dir {-backwards} + } else {set dir {-forwards}} + set fcount 0 + set foundidx [.content.text search $mode $dir -nocase -count fcount $pattern $cursearchidx] + .content.text tag delete highlight + if {$foundidx ne ""} { + # add search highlight tag + set endidx "$foundidx + $fcount chars" + .content.text tag add highlight $foundidx $endidx + .content.text tag configure highlight -background $bfg_highlight_bgcolor -foreground $bfg_highlight_fgcolor + .content.text see $foundidx + set cursearchidx "$foundidx + $numdir chars" + set cursearchpattern "$pattern" + } else {set cursearchidx "1.0"} +} + +# search GUI action +proc searchui {dir} { + global cursearchidx cursearchpattern + global sentry_status bfg_status bfg_prompt bfg_entry + set prevstatus "$bfg_status" + grid .sframe.sentry + .sframe.sentry state !disabled + unset -nocomplain bfg_prompt + set sentry_status "Search:" + set bfg_entry 1 + if {$dir eq -1} { + set sentry_status "Search backwards:" + } + set bfg_status $sentry_status + focus .sframe.sentry + vwait bfg_prompt + .sframe.sentry state disabled + grid remove .sframe.sentry + set bfg_entry 0 + textsearch "$bfg_prompt" $dir 0 + set bfg_status "$prevstatus" + unset -nocomplain bfg_prompt +} + +# Bookmark logic +proc addremovebookmark {url} { + global bookmarks bookmarklabel bookmarkactive + global sentry_status bfg_status bfg_rawprompt bfg_prompt bfg_entry + set urlkey "" + dict for {bkey bval} $bookmarks { + if {$bval eq $url} { + set urlkey $bkey + } + } + if {$urlkey ne ""} { # found the URL, removing the bookmark + set bookmarks [dict remove $bookmarks $urlkey] + set bookmarkactive "\u2606" + set bookmarklabel "Add bookmark" + } else { # adding a new bookmark + set prevstatus "$bfg_status" + grid .sframe.sentry + .sframe.sentry state !disabled + unset -nocomplain bfg_prompt + set sentry_status "Bookmark name:" + set bfg_entry 1 + set bfg_status $sentry_status + set bfg_rawprompt $url + focus .sframe.sentry + .sframe.sentry selection range 0 end + vwait bfg_prompt + .sframe.sentry state disabled + grid remove .sframe.sentry + set bfg_entry 0 + # we have the name in bfg_prompt + set bname [string trim $bfg_prompt] + dict set bookmarks $bname $url + set bookmarkactive "\u2605" + set bookmarklabel "Remove bookmark" + unset -nocomplain bfg_prompt + set bfg_status "$prevstatus" + set sentry_status "" + } + savebookmarks + refreshbookmarks +} + +# save the bookmarks in the configuration INI +proc savebookmarks {} { + global config_file bookmarks + set confini [::ini::open $config_file -encoding utf-8] + ::ini::delete $confini bookmarks + dict for {bkname bkurl} $bookmarks { + ::ini::set $confini bookmarks $bkname $bkurl + } + ::ini::commit $confini + ::ini::close $confini +} + +# build or refresh bookmark menu +proc refreshbookmarks {} { + global bookmarklabel bookmarks targeturl + .ctrl.bkm.menu delete 0 end + .ctrl.bkm.menu add command -label "$bookmarklabel" -command {addremovebookmark $targeturl} + .ctrl.bkm.menu add separator + dict for {bkname bkurl} $bookmarks { + .ctrl.bkm.menu add command -label "$bkname" -command "urlfetch $bkurl 1" + } +} + +# UI part + +set contentcursor "left_ptr" +set targeturl "" +if {$argc > 0} { + set targeturl [lindex $argv 0] +} + +# set the window title +wm title . "BFG" + +# set the window geometry +wm minsize . 800 600 + +# control bar +set btnwidth 2 +set btnpad 2 +ttk::frame .ctrl -padding 4 +# change this to 2605 when active +set bookmarkactive "\u2606" +set bookmarklabel "Add bookmark" +grid [ttk::button .ctrl.back -text "↩" -width $btnwidth -style bfg.TButton -command {goback}] \ + -row 0 -column 0 -sticky nes -padx $btnpad +grid [ttk::button .ctrl.fwd -text "↪️" -width $btnwidth -style bfg.TButton -command {goforward}] \ + -row 0 -column 1 -sticky nes -padx $btnpad +grid [ttk::button .ctrl.refresh -text "↻" -width $btnwidth -style bfg.TButton -command {urlfetch "$targeturl" 0}] \ + -row 0 -column 2 -sticky nes -padx $btnpad +grid [ttk::entry .ctrl.addr -textvariable targeturl -width 40 -font bfg_entryfont] -row 0 -column 3 -sticky nswe -ipadx $btnpad +grid [ttk::menubutton .ctrl.bkm -textvariable bookmarkactive -style bfg.TButton -width $btnwidth -menu .ctrl.bkm.menu] \ + -row 0 -column 4 -sticky nws -padx $btnpad +grid columnconfigure .ctrl 3 -weight 1 + +# bookmarks menu +menu .ctrl.bkm.menu -tearoff 0 -cursor $contentcursor +refreshbookmarks + +# main content widget +ttk::frame .content +tk::text .content.text -cursor $contentcursor -yscrollcommand ".content.yscroll set" -wrap word \ + -font bfg_textfont -foreground $bfg_main_fgcolor -background $bfg_main_bgcolor -state disabled +ttk::scrollbar .content.yscroll -orient vertical -command ".content.text yview" +grid .content.text -column 0 -row 0 -sticky nsew +grid .content.yscroll -column 1 -row 0 -sticky ns +grid columnconfigure .content 0 -weight 1 +grid rowconfigure .content 0 -weight 1 + +# status bar +ttk::frame .sframe +ttk::label .sframe.status -padding 4 -textvariable bfg_status -font bfg_statusfont +ttk::entry .sframe.sentry -textvariable bfg_rawprompt -state disabled -font bfg_entryfont +grid .sframe.status -column 0 -row 0 -sticky nsew +grid .sframe.sentry -column 1 -row 0 -sticky nsw +grid columnconfigure .sframe 0 -weight 1 +set bfg_status Ready + +# place them all +grid .ctrl -column 0 -row 0 -sticky nsew +grid .content -column 0 -row 1 -sticky nsew +grid .sframe -column 0 -row 2 -sticky nsew +grid remove .sframe.sentry + +# root grid geometry: don't resize the top and bottom row +grid rowconfigure . 1 -weight 1 +grid columnconfigure . 0 -weight 1 + +# iterate over available platform-dependent themes, apply "clam" if none found +catch {ttk::style theme use clam} +catch {ttk::style theme use aqua} +catch {ttk::style theme use winnative} +catch {ttk::style theme use $bfg_theme} + +# apply custom styles +ttk::style configure bfg.TButton -font bfg_widgetfont + +# set up keybindings + +# address entry +bind .ctrl.addr {urlfetch "$targeturl" 1} +bind .ctrl.addr {set bfg_entry 1; .ctrl.addr selection range 0 end} +bind .ctrl.addr {set bfg_entry 0; .ctrl.addr selection clear} +focus .ctrl.addr + +# scroll and focus into the numbered link +proc linkfocus {lnum noscroll} { + global bfg_focusedlink_fgcolor bfg_focusedlink_bgcolor + # scroll into the link + set ci [lindex [.content.text tag ranges "bfglink_$lnum"] 0] + if {$noscroll eq 0} { + .content.text see $ci + } + # add highlight tag + set rng [string cat [lindex [split $ci .] 0] ".end"] + .content.text tag delete focuslink + .content.text tag add focuslink $ci $rng + .content.text tag configure focuslink -foreground $bfg_focusedlink_fgcolor -background $bfg_focusedlink_bgcolor +} + +# do this on startup +if {$targeturl ne ""} { + .ctrl.addr selection clear + urlfetch "$targeturl" 1 +} +set bfg_entry 0 +set bfg_rawprompt "" +.ctrl.back configure -state disabled +.content.text tag configure errormsg -foreground $bfg_error_fgcolor -background $bfg_error_bgcolor +.content.text tag configure preformatter -font bfg_monofont + +# general keybinding switch + +bind . { + if {$bfg_entry eq 0} { + switch "%K" { + g {focus .ctrl.addr} + F5 - + r {urlfetch "$targeturl" 0} + default {} + b {goback} + f {goforward} + Prior - + Left - + h {.content.text yview scroll -1 pages} + Next - + Right - + l {.content.text yview scroll 1 pages} + Down - + j {.content.text yview scroll 1 units} + Up - + k {.content.text yview scroll -1 units} + a { # select previous link + if {$linkcount > 0} { # we have some links + incr curlink -1 + if {$curlink < 0} { + set curlink [expr {$linkcount - 1}] + } + linkfocus $curlink 0 + set vurl [lindex $linklist $curlink] + set bfg_status "=> [trunc $vurl]" + } + } + s { # select next link + if {$linkcount > 0} { # we have some links + incr curlink + if {$curlink >= $linkcount} { + set curlink 0 + } + linkfocus $curlink 0 + set vurl [lindex $linklist $curlink] + set bfg_status "=> [trunc $vurl]" + } + } + space - + Return { # visit the focused link + if {$linkcount > 0} { # we have some links + .content.text tag delete focuslink + clicklink $curlink + } + } + d { # download the focused link + if {$linkcount > 0} { # we have some links + downloadlink $curlink 0 + } + } + D { # download the focused link and open it externally + if {$linkcount > 0} { # we have some links + downloadlink $curlink 1 + } + } + Escape { # reset search highlights + set cursearchpattern "" + set cursearchidx "1.0" + .content.text tag delete highlight + } + slash {searchui 1} + default {} + } + } +} + +bind . { + if {$linkcount > 0} { # we have some links + windowlink $curlink + } +} + +bind . { + if {$linkcount > 0} { # we have some links + windowlink $curlink + } +} + +# generic mouse click events +# 1 - left, 2 - mid, 3 - right, 8 - back, 9 - forward +bind .