1aaa70fdbe73821838071efd23f73f7ae8e6e6af
[bollux.git/.git] / bollux
1 #!/usr/bin/env bash
2 # bollux: a bash gemini client
3 # Author: Case Duckworth
4 # License: MIT
5 # Version: 0.4.0
6
7 # Program information
8 PRGN="${0##*/}"
9 VRSN=0.4.0
10
11 bollux_usage() {
12         cat <<END
13 $PRGN (v. $VRSN): a bash gemini client
14 usage:
15         $PRGN [-h]
16         $PRGN [-q] [-v] [URL]
17 flags:
18         -h      show this help and exit
19         -q      be quiet: log no messages
20         -v      verbose: log more messages
21 parameters:
22         URL     the URL to start in
23                 If not provided, the user will be prompted.
24 END
25 }
26
27 run() {
28         log debug "$@"
29         "$@"
30 }
31
32 die() {
33         local ec="$1"
34         shift
35         log error "$*"
36         exit "$ec"
37 }
38
39 # pure bash bible trim_string
40 trim() {
41         : "${1#"${1%%[![:space:]]*}"}"
42         : "${_%"${_##*[![:space:]]}"}"
43         printf '%s\n' "$_"
44 }
45
46 log() {
47         [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
48         local fmt
49
50         case "$1" in
51         [dD]*) # debug
52                 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
53                 fmt=34
54                 ;;
55         [eE]*) # error
56                 fmt=31
57                 ;;
58         *) fmt=1 ;;
59         esac
60         shift
61
62         printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
63 }
64
65 # main entry point
66 bollux() {
67         run bollux_config    # TODO: figure out better config method
68         run bollux_args "$@" # and argument parsing
69         run bollux_init
70
71         if [[ ! "${BOLLUX_URL:+x}" ]]; then
72                 run prompt GO BOLLUX_URL
73         fi
74
75         run blastoff "$BOLLUX_URL"
76 }
77
78 bollux_args() {
79         while getopts :hvq OPT; do
80                 case "$OPT" in
81                 h)
82                         bollux_usage
83                         exit
84                         ;;
85                 v) BOLLUX_LOGLEVEL=DEBUG ;;
86                 q) BOLLUX_LOGLEVEL=QUIET ;;
87                 :) die 1 "Option -$OPTARG requires an argument" ;;
88                 *) die 1 "Unknown option: -$OPTARG" ;;
89                 esac
90         done
91         shift $((OPTIND - 1))
92         if (($# == 1)); then
93                 BOLLUX_URL="$1"
94         fi
95 }
96
97 bollux_config() {
98         : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
99
100         if [ -f "$BOLLUX_CONFIG" ]; then
101                 # shellcheck disable=1090
102                 . "$BOLLUX_CONFIG"
103         else
104                 log debug "Can't load config file '$BOLLUX_CONFIG'."
105         fi
106
107         ## behavior
108         : "${BOLLUX_TIMEOUT:=30}"                     # connection timeout
109         : "${BOLLUX_MAXREDIR:=5}"                     # max redirects
110         : "${BOLLUX_PORT:=1965}"                      # port number
111         : "${BOLLUX_PROTO:=gemini}"                   # default protocol
112         : "${BOLLUX_URL:=}"                           # start url
113         : "${BOLLUX_BYEMSG:=See You Space Cowboy...}" # bye message
114         ## files
115         : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}"
116         : "${BOLLUX_DOWNDIR:=.}"                       # where to save downloads
117         : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds
118         : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source
119         BOLLUX_HISTFILE="$BOLLUX_DATADIR/history"      # where to save the history
120         ## typesetting
121         : "${T_MARGIN:=4}"      # left and right margin
122         : "${T_WIDTH:=0}"       # width of the viewport -- 0 = get term width
123         # colors -- these will be wrapped in \e[ __ m
124         C_RESET='\e[0m'         # reset
125         : "${C_SIGIL:=35}"      # sigil (=>, #, ##, ###, *, ```)
126         : "${C_LINK_NUMBER:=1}" # link number
127         : "${C_LINK_TITLE:=4}"  # link title
128         : "${C_LINK_URL:=36}"   # link URL
129         : "${C_HEADER1:=1;4}"   # header 1 formatting
130         : "${C_HEADER2:=1}"     # header 2 formatting
131         : "${C_HEADER3:=3}"     # header 3 formatting
132         : "${C_LIST:=0}"        # list formatting
133         : "${C_PRE:=0}"         # preformatted text formatting
134 }
135
136 bollux_quit() {
137         log x "$BOLLUX_BYEMSG"
138         exit
139 }
140
141 set_title() {
142         printf '\e]2;%s - bollux\007' "$*"
143 }
144
145 prompt() { # prompt [-u] PROMPT [READ_ARGS...]
146         local read_cmd=(read -e -r)
147         if [[ "$1" == "-u" ]]; then
148                 read_cmd+=(-i "$BOLLUX_URL")
149                 shift
150         fi
151         local prompt="$1"
152         shift
153         read_cmd+=(-p "$prompt> ")
154         "${read_cmd[@]}" </dev/tty "$@"
155 }
156
157 blastoff() { # load a url
158         local well_formed=true
159         local proto url
160         if [[ "$1" == "-u" ]]; then
161                 well_formed=false
162                 shift
163         fi
164         url="$1"
165
166         if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
167                 url="$(run transform_resource "$BOLLUX_URL" "$1")"
168         fi
169         [[ "$url" != *://* ]] && url="$BOLLUX_PROTO://$url"
170         url="$(trim "$url")"
171         proto="${url%://*}"
172
173         log d "PROTO='$proto' URL='$url'"
174
175         {
176                 if declare -Fp "${proto}_request" >/dev/null; then
177                         run "${proto}_request" "$url"
178                 else
179                         log d "No request handler for '$proto'; trying gemini"
180                         run gemini_request "$url"
181                 fi
182         } | run normalize |
183                 {
184                         if declare -Fp "${proto}_response" >/dev/null; then
185                                 run "${proto}_response" "$url"
186                         else
187                                 log d "No response handler for '$proto'; handling raw response"
188                                 raw_response
189                         fi
190                 }
191 }
192
193 transform_resource() { # transform_resource BASE_URL REFERENCE_URL
194         local -A R B T # reference, base url, target
195         eval "$(run parse_url B "$1")"
196         eval "$(run parse_url R "$2")"
197         # A non-strict parser may ignore a scheme in the reference
198         # if it is identical to the base URI's scheme.
199         if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
200                 unset "${R[scheme]}"
201         fi
202
203         # basically pseudo-code from spec ported to bash
204         if isdefined "R[scheme]"; then
205                 T[scheme]="${R[scheme]}"
206                 isdefined "R[authority]" && T[authority]="${R[authority]}"
207                 isdefined R[path] &&
208                         T[path]="$(run remove_dot_segments "${R[path]}")"
209                 isdefined "R[query]" && T[query]="${R[query]}"
210         else
211                 if isdefined "R[authority]"; then
212                         T[authority]="${R[authority]}"
213                         isdefined "R[authority]" &&
214                                 T[path]="$(remove_dot_segments "${R[path]}")"
215                         isdefined R[query] && T[query]="${R[query]}"
216                 else
217                         if isempty "R[path]"; then
218                                 T[path]="${B[path]}"
219                                 if isdefined R[query]; then
220                                         T[query]="${R[query]}"
221                                 else
222                                         T[query]="${B[query]}"
223                                 fi
224                         else
225                                 if [[ "${R[path]}" == /* ]]; then
226                                         T[path]="$(remove_dot_segments "${R[path]}")"
227                                 else
228                                         T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
229                                         T[path]="$(remove_dot_segments "${T[path]}")"
230                                 fi
231                                 isdefined R[query] && T[query]="${R[query]}"
232                         fi
233                         T[authority]="${B[authority]}"
234                 fi
235                 T[scheme]="${B[scheme]}"
236         fi
237         isdefined R[fragment] && T[fragment]="${R[fragment]}"
238         # cf. 5.3 -- recomposition
239         local r
240         isdefined "T[scheme]" && r="$r${T[scheme]}:"
241         # remove the port from the authority
242         isdefined "T[authority]" && r="$r//${T[authority]%:*}"
243         r="$r${T[path]}"
244         isdefined T[query] && r="$r?${T[query]}"
245         isdefined T[fragment] && r="$r#${T[fragment]}"
246         printf '%s\n' "$r"
247 }
248
249 merge_paths() { # 5.2.3
250         # shellcheck disable=2034
251         local B_authority="$1"
252         local B_path="$2"
253         local R_path="$3"
254         # if R_path is empty, get rid of // in B_path
255         if [[ -z "$R_path" ]]; then
256                 printf '%s\n' "${B_path//\/\//\//}"
257                 return
258         fi
259
260         if isdefined "B_authority" && isempty "B_path"; then
261                 printf '/%s\n' "${R_path//\/\//\//}"
262         else
263                 if [[ "$B_path" == */* ]]; then
264                         B_path="${B_path%/*}/"
265                 else
266                         B_path=""
267                 fi
268                 printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
269         fi
270 }
271
272 remove_dot_segments() { # 5.2.4
273         local input="$1"
274         local output
275         while [[ "$input" ]]; do
276                 if [[ "$input" =~ ^\.\.?/ ]]; then
277                         input="${input#${BASH_REMATCH[0]}}"
278                 elif [[ "$input" =~ ^/\.(/|$) ]]; then
279                         input="/${input#${BASH_REMATCH[0]}}"
280                 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
281                         input="/${input#${BASH_REMATCH[0]}}"
282                         [[ "$output" =~ /?[^/]+$ ]]
283                         output="${output%${BASH_REMATCH[0]}}"
284                 elif [[ "$input" == . || "$input" == .. ]]; then
285                         input=
286                 else
287                         [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH
288                         output="$output${BASH_REMATCH[1]}"
289                         input="${BASH_REMATCH[2]}"
290                 fi
291         done
292         printf '%s\n' "${output//\/\//\//}"
293 }
294
295 parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
296         local name="$1"
297         local string="$2"
298         # shopt -u extglob # TODO port re ^ to extglob syntax
299         local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
300         [[ $string =~ $re ]] || return $?
301         # shopt -s extglob
302
303         local scheme="${BASH_REMATCH[2]}"
304         local authority="${BASH_REMATCH[4]}"
305         local path="${BASH_REMATCH[5]}"
306         local query="${BASH_REMATCH[7]}"
307         local fragment="${BASH_REMATCH[9]}"
308
309         for c in scheme authority query fragment; do
310                 [[ "${!c}" ]] &&
311                         run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
312         done
313         # unclear if the path is always set even if empty but it looks that way
314         run printf '%s[path]=%q\n' "$name" "$path"
315 }
316
317 # is a NAME defined ('set' in bash)?
318 isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
319 # is a NAME defined AND empty?
320 isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
321 # split a string -- see pure bash bible
322 split() { # split STRING DELIMITER
323         local -a arr
324         IFS=$'\n' read -d "" -ra arr <<<"${1//$2/$'\n'}"
325         printf '%s\n' "${arr[@]}"
326 }
327
328 # GEMINI
329 # https://gemini.circumlunar.space/docs/spec-spec.txt
330 gemini_request() {
331         local url port server
332         local ssl_cmd
333         url="$1"
334         port=1965
335         server="${url#*://}"
336         server="${server%%/*}"
337
338         ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
339         # disable old TLS/SSL versions (thanks makeworld!)
340         ssl_cmd+=(-no_ssl3 -no_tls1 -no_tls1_1)
341
342         # always try to connect with TLS v1.3 first
343         run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
344 }
345
346 gemini_response() {
347         local url code meta
348         local title
349         url="$1"
350
351         # we need a loop here so it waits for the first line
352         while read -t "$BOLLUX_TIMEOUT" -r code meta ||
353                 { (($? > 128)) && die 99 "Timeout."; }; do
354                 break
355         done
356
357         log d "[$code] $meta"
358
359         case "$code" in
360         1*) # input
361                 REDIRECTS=0
362                 run prompt "$meta"
363                 run blastoff "?$REPLY"
364                 ;;
365         2*) # OK
366                 REDIRECTS=0
367                 # read ahead to find a title
368                 local pretitle
369                 while read -r; do
370                         pretitle="$pretitle$REPLY"$'\n'
371                         if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then
372                                 title="${BASH_REMATCH[1]}"
373                                 break
374                         fi
375                 done
376                 run history_append "$url" "${title:-}"
377                 # read the body out and pipe it to display
378                 {
379                         printf '%s' "$pretitle"
380                         passthru
381                 } | run display "$meta" "${title:-}"
382                 ;;
383         3*) # redirect
384                 ((REDIRECTS += 1))
385                 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
386                         die $((100 + code)) "Too many redirects!"
387                 fi
388                 run blastoff "$meta" # TODO: confirm redirect
389                 ;;
390         4*) # temporary error
391                 REDIRECTS=0
392                 die "$((100 + code))" "Temporary error [$code]: $meta"
393                 ;;
394         5*) # permanent error
395                 REDIRECTS=0
396                 die "$((100 + code))" "Permanent error [$code]: $meta"
397                 ;;
398         6*) # certificate error
399                 REDIRECTS=0
400                 log d "Not implemented: Client certificates"
401                 # TODO: recheck the speck
402                 die "$((100 + code))" "[$code] $meta"
403                 ;;
404         *)
405                 [[ -z "${code-}" ]] && die 100 "Empty response code."
406                 die "$((100 + code))" "Unknown response code: $code."
407                 ;;
408         esac
409 }
410
411 # GOPHER
412 # https://tools.ietf.org/html/rfc1436 protocol
413 # https://tools.ietf.org/html/rfc4266 url
414 gopher_request() {
415         local url server port type path
416         url="$1"
417         port=70
418
419         # RFC 4266
420         [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
421         server="${BASH_REMATCH[1]}"
422         port="${BASH_REMATCH[3]:-70}"
423         type="${BASH_REMATCH[6]:-1}"
424         path="${BASH_REMATCH[7]}"
425
426         log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'"
427
428         exec 9<>"/dev/tcp/$server/$port"
429         printf '%s\r\n' "$path" >&9
430         passthru <&9
431 }
432
433 gopher_response() {
434         local url pre type cur_server
435         pre=false
436         url="$1"
437         # RFC 4266
438         [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
439         cur_server="${BASH_REMATCH[1]}"
440         type="${BASH_REMATCH[6]:-1}"
441
442         run history_append "$url" "" # TODO: get the title ??
443
444         log d "TYPE='$type'"
445
446         case "$type" in
447         0) # text
448                 run display text/plain
449                 ;;
450         1) # menu
451                 run gopher_convert | run display text/gemini
452                 ;;
453         3) # failure
454                 die 203 "GOPHER: failed"
455                 ;;
456         7) # search
457                 if [[ "$url" =~ $'\t' ]]; then
458                         run gopher_convert | run display text/gemini
459                 else
460                         run prompt 'SEARCH'
461                         run blastoff "$url      $REPLY"
462                 fi
463                 ;;
464         *) # something else
465                 run download "$url"
466                 ;;
467         esac
468 }
469
470 passthru() {
471         while IFS= read -r; do
472                 printf '%s\n' "$REPLY"
473         done
474 }
475
476 gopher_convert() {
477         local type label path server port regex
478         # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk
479         while IFS= read -r; do
480                 printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?'
481                 if [[ "$REPLY" =~ $regex ]]; then
482                         type="${BASH_REMATCH[1]}"
483                         label="${BASH_REMATCH[2]}"
484                         path="${BASH_REMATCH[4]:-/}"
485                         server="${BASH_REMATCH[5]:-$cur_server}"
486                         port="${BASH_REMATCH[6]}"
487                 else
488                         log e "CAN'T PARSE LINE"
489                         printf '%s\n' "$REPLY"
490                         continue
491                 fi
492                 case "$type" in
493                 .) # end of file
494                         printf '.\n'
495                         break
496                         ;;
497                 i) # label
498                         case "$label" in
499                         '#'* | '*'[[:space:]]*)
500                                 if $pre; then
501                                         printf '%s\n' '```'
502                                         pre=false
503                                 fi
504                                 ;;
505                         *)
506                                 if ! $pre; then
507                                         printf '%s\n' '```'
508                                         pre=true
509                                 fi
510                                 ;;
511                         esac
512                         printf '%s\n' "$label"
513                         ;;
514                 h) # html link
515                         if $pre; then
516                                 printf '%s\n' '```'
517                                 pre=false
518                         fi
519                         printf '=> %s %s\n' "${path:4}" "$label"
520                         ;;
521                 T) # telnet link
522                         if $pre; then
523                                 printf '%s\n' '```'
524                                 pre=false
525                         fi
526                         printf '=> telnet://%s:%s/%s%s %s\n' \
527                                 "$server" "$port" "$type" "$path" "$label"
528                         ;;
529                 *) # other type
530                         if $pre; then
531                                 printf '%s\n' '```'
532                                 pre=false
533                         fi
534                         printf '=> gopher://%s:%s/%s%s %s\n' \
535                                 "$server" "$port" "$type" "$path" "$label"
536                         ;;
537                 esac
538         done
539         if $pre; then
540                 printf '%s\n' '```'
541         fi
542         # close the connection
543         exec 9<&-
544         exec 9>&-
545 }
546
547 display() { # display METADATA [TITLE]
548         local -a less_cmd
549         local i mime charset
550         # split header line
551         local -a hdr
552         IFS=';' read -ra hdr <<<"$1"
553         # title is optional but nice looking
554         local title
555         if (($# == 2)); then
556                 title="$2"
557         fi
558
559         mime="$(trim "${hdr[0],,}")"
560         for ((i = 1; i <= "${#hdr[@]}"; i++)); do
561                 h="${hdr[$i]}"
562                 case "$h" in
563                 *charset=*) charset="${h#*=}" ;;
564                 esac
565         done
566
567         [[ -z "$mime" ]] && mime="text/gemini"
568         [[ -z "$charset" ]] && charset="utf-8"
569
570         log debug "mime='$mime'; charset='$charset'"
571
572         case "$mime" in
573         text/*)
574                 set_title "$BOLLUX_URL"
575                 less_cmd=(less -R)
576                 mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY")
577                 less_cmd+=(
578                         -Pm"$title${title:+ - }bollux$"
579                         -PM'o\:open, g\:goto, [\:back, ]\:forward, r\:refresh$'
580                         -m
581                 )
582
583                 local typeset
584                 local submime="${mime#*/}"
585                 if declare -Fp "typeset_$submime" >/dev/null; then
586                         typeset="typeset_$submime"
587                 else
588                         typeset="passthru"
589                 fi
590
591                 {
592                         run iconv -f "${charset^^}" -t "UTF-8" |
593                                 run tee "$BOLLUX_PAGESRC" |
594                                 run "$typeset" |
595                                 run "${less_cmd[@]}" && bollux_quit
596                 } || run handle_keypress "$?"
597                 ;;
598         *) run download "$BOLLUX_URL" ;;
599         esac
600 }
601
602 mklesskey() {
603         lesskey -o "$1" - <<-END
604                 #command
605                 o quit 0 # 48 open a link
606                 g quit 1 # 49 goto a url
607                 [ quit 2 # 50 back
608                 ] quit 3 # 51 forward
609                 r quit 4 # 52 re-request / download
610                 G quit 5 # 53 goto a url (pre-filled)
611                 # other keybinds
612                 \\40 forw-screen-force
613         END
614 }
615
616 normalize() {
617         shopt -s extglob
618         while IFS= read -r; do
619                 printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
620         done
621         shopt -u extglob
622 }
623
624 typeset_gemini() {
625         local pre=false
626         local ln=0 # link number
627
628         if ((T_WIDTH == 0)); then
629                 shopt -s checkwinsize
630                 (
631                         :
632                         :
633                 ) # XXX this doesn't work!?
634                 log d "LINES=$LINES; COLUMNS=$COLUMNS"
635                 T_WIDTH=$COLUMNS
636         fi
637         WIDTH=$((T_WIDTH - T_MARGIN))
638         ((WIDTH < 0)) && WIDTH=80  # default if dumb
639         S_MARGIN=$((T_MARGIN - 1)) # spacing
640
641         log d "T_WIDTH=$T_WIDTH"
642         log d "WIDTH=$WIDTH"
643
644         while IFS= read -r; do
645                 case "$REPLY" in
646                 '```'*)
647                         if $pre; then
648                                 pre=false
649                         else
650                                 pre=true
651                         fi
652                         continue
653                         ;;
654                 '=>'*)
655                         : $((ln += 1))
656                         gemini_link "$REPLY" $pre "$ln"
657                         ;;
658                 '#'*) gemini_header "$REPLY" $pre ;;
659                 '*'[[:space:]]*)
660                         gemini_list "$REPLY" $pre
661                         ;;
662                 *) gemini_text "$REPLY" $pre ;;
663                 esac
664         done
665 }
666
667 gemini_link() {
668         local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
669         local s t a l # sigil, text, annotation(url), line
670         if ! ${2-false} && [[ "$1" =~ $re ]]; then
671                 s="${BASH_REMATCH[1]}"
672                 a="${BASH_REMATCH[2]}"
673                 t="${BASH_REMATCH[3]}"
674                 if [[ -z "$t" ]]; then
675                         t="$a"
676                         a=
677                 fi
678
679                 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
680                 printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \
681                         \e[${C_LINK_TITLE}m%s${C_RESET} \
682                         \e[${C_LINK_URL}m%s${C_RESET}\n" \
683                         "$3" "$t" "$a"
684                 fold_line "$WIDTH" "$l"
685         else
686                 gemini_pre "$1"
687         fi
688 }
689
690 gemini_header() {
691         local re="^(#+)[[:blank:]]*(.*)"
692         local s t a l # sigil, text, annotation(lvl), line
693         if ! ${2-false} && [[ "$1" =~ $re ]]; then
694                 s="${BASH_REMATCH[1]}"
695                 a="${#BASH_REMATCH[1]}"
696                 t="${BASH_REMATCH[2]}"
697                 local hdrfmt
698                 hdrfmt="$(eval echo "\$C_HEADER$a")"
699
700                 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
701                 printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t"
702                 fold_line "$WIDTH" "$l"
703         else
704                 gemini_pre "$1"
705         fi
706 }
707
708 gemini_list() {
709         local re="^(\*)[[:blank:]]*(.*)"
710         local s t a l # sigil, text, annotation(n/a), line
711         if ! ${2-false} && [[ "$1" =~ $re ]]; then
712                 s="${BASH_REMATCH[1]}"
713                 t="${BASH_REMATCH[2]}"
714
715                 printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s"
716                 printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t"
717                 fold_line "$WIDTH" "$l"
718         else
719                 gemini_pre "$1"
720         fi
721 }
722
723 gemini_text() {
724         if ! ${2-false}; then
725                 printf "%${S_MARGIN}s " ' '
726                 fold_line "$WIDTH" "$1"
727         else
728                 gemini_pre "$1"
729         fi
730 }
731
732 gemini_pre() {
733         printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
734         printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
735 }
736
737 fold_line() { # fold_line WIDTH TEXT
738         local width="$1"
739         local margin="${2%%[![:space:]]*}"
740         if [[ "$margin" ]]; then
741                 margin="${#margin}"
742         else
743                 margin="$T_MARGIN"
744         fi
745         local ll=0 wl plain
746         # shellcheck disable=2086
747         set -- $2 # TODO: is this the best way?
748
749         for word; do
750                 plain="${word//$'\x1b'\[*([0-9;])m/}"
751                 wl=$((${#plain} + 1))
752                 if (((ll + wl) >= width)); then
753                         printf "\n%${margin}s" ' '
754                         ll=$wl
755                 else
756                         ll=$((ll + wl))
757                 fi
758                 printf '%s ' "$word"
759         done
760         printf '\n'
761 }
762
763 handle_keypress() {
764         case "$1" in
765         48) # o - open a link -- show a menu of links on the page
766                 run select_url "$BOLLUX_PAGESRC"
767                 ;;
768         49) # g - goto a url -- input a new url
769                 prompt GO
770                 run blastoff -u "$REPLY"
771                 ;;
772         50) # [ - back in the history
773                 run history_back || {
774                         sleep 0.5
775                         run blastoff "$BOLLUX_URL"
776                 }
777                 ;;
778         51) # ] - forward in the history
779                 run history_forward || {
780                         sleep 0.5
781                         run blastoff "$BOLLUX_URL"
782                 }
783                 ;;
784         52) # r - re-request the current resource
785                 run blastoff "$BOLLUX_URL"
786                 ;;
787         53) # G - goto a url (pre-filled with current)
788                 prompt -u GO
789                 run blastoff -u "$REPLY"
790                 ;;
791         *) # 54-57 -- still available for binding
792                 die "$?" "less(1) error"
793                 ;;
794         esac
795 }
796
797 select_url() {
798         run mapfile -t < <(extract_links <"$1")
799         PS3="OPEN> "
800         select u in "${MAPFILE[@]}"; do
801                 case "$REPLY" in
802                 q) bollux_quit ;;
803                 [^0-9]*) run blastoff -u "$REPLY" && break ;;
804                 esac
805                 run blastoff "${u%%[[:space:]]*}" && break
806         done </dev/tty
807 }
808
809 extract_links() {
810         local url alt
811         while read -r; do
812                 if [[ "$REPLY" =~ ^=\>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then
813                         url="${BASH_REMATCH[1]}"
814                         alt="${BASH_REMATCH[3]}"
815
816                         if [[ "$alt" ]]; then
817                                 printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt"
818                         else
819                                 printf '%s\n' "$url"
820                         fi
821                 fi
822         done
823 }
824
825 download() {
826         tn="$(mktemp)"
827         log x "Downloading: '$BOLLUX_URL' => '$tn'..."
828         dd status=progress >"$tn"
829         fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
830         if [[ -f "$fn" ]]; then
831                 log x "Saved '$tn'."
832         elif mv "$tn" "$fn"; then
833                 log x "Saved '$fn'."
834         else
835                 log error "Error saving '$fn': downloaded to '$tn'."
836         fi
837 }
838
839 bollux_init() {
840         # Trap cleanup
841         trap bollux_cleanup INT QUIT EXIT
842         # State
843         REDIRECTS=0
844         set -f
845         # History
846         declare -a HISTORY # history is kept in an array
847         HN=0               # position of history in the array
848         run mkdir -p "${BOLLUX_HISTFILE%/*}"
849 }
850
851 bollux_cleanup() {
852         # XXX
853         :
854         #kill $(jobs -p)
855 }
856
857 history_append() { # history_append url TITLE
858         BOLLUX_URL="$1"
859         # date/time, url, title (best guess)
860         run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE"
861         HISTORY[$HN]="$BOLLUX_URL"
862         ((HN += 1))
863 }
864
865 history_back() {
866         log d "HN=$HN"
867         ((HN -= 2))
868         if ((HN < 0)); then
869                 HN=0
870                 log e "Beginning of history."
871                 return 1
872         fi
873         run blastoff "${HISTORY[$HN]}"
874 }
875
876 history_forward() {
877         log d "HN=$HN"
878         if ((HN >= ${#HISTORY[@]})); then
879                 HN="${#HISTORY[@]}"
880                 log e "End of history."
881                 return 1
882         fi
883         run blastoff "${HISTORY[$HN]}"
884 }
885
886 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
887         run bollux "$@"
888 fi