diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bed2a4f..6fc679df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ copied to. The default is `primary`, which corresponds to the behavior in older foot releases (https://codeberg.org/dnkl/foot/issues/288). +* URL detection. URLs are highlighted and activated using the keyboard + (**no** mouse support). See **foot**(1)::URLs, or + [README.md](README.md#urls) for details + (https://codeberg.org/dnkl/foot/issues/14). ### Changed diff --git a/README.md b/README.md index e55d3e82..428d4958 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The fast, lightweight and minimalistic Wayland terminal emulator. 1. [Scrollback search](#scrollback-search) 1. [Mouse](#mouse) 1. [Server (daemon) mode](#server-daemon-mode) +1. [URLs](#urls) 1. [Alt/meta](#alt-meta) 1. [Backspace](#backspace) 1. [Keypad](#keypad) @@ -43,12 +44,13 @@ The fast, lightweight and minimalistic Wayland terminal emulator. * Lightweight, in dependencies, on-disk and in-memory * Wayland native * DE agnostic +* Server/daemon mode * User configurable font fallback * On-the-fly font resize * On-the-fly DPI font size adjustment * Scrollback search +* Keyboard driven URL detection * Color emoji support -* Server/daemon mode * IME (via `text-input-v3`) * Multi-seat * [Synchronized Updates](https://gitlab.freedesktop.org/terminal-wg/specifications/-/merge_requests/2) support @@ -154,6 +156,10 @@ These are the default shortcuts. See `man foot.ini` and the example sequence](https://codeberg.org/dnkl/foot/wiki#user-content-how-to-configure-my-shell-to-emit-the-osc-7-escape-sequence), the new terminal will start in the current working directory. +ctrl+shift+u +: Enter URL mode, where all currently visible URLs are tagged with a + jump label with a key sequence that will open the URL. + #### Scrollback search @@ -247,6 +253,32 @@ desktop), and then run `footclient` instead of `foot` whenever you want to launch a new terminal. +## URLs + +Foot supports URL detection. But, unlike many other terminal +emulators, where URLs are highlighted when they are hovered and opened +by clicking on them, foot uses a keyboard driven approach. + +Pressing ctrl+shift+u enters _“URL +mode”_, where all currently visible URLs are underlined, and is +associated with a _“jump-label”_. The jump-label indicates the _key +sequence_ (e.g. **”AF”**) to use to activate the URL. + +The key binding can, of course, be customized, like all other key +bindings in foot. See `show-urls-launch` and `show-urls-copy` in the +`foot.ini` man page. + +`show-urls-launch` by default opens the URL with `xdg-open`. This can +be changed with the `url-launch` option. + +`show-urls-copy` is an alternative to `show-urls-launch`, that changes +what activating an URL _does_; instead of opening it, it copies it to +the clipboard. It is unbound by default. + +Both the jump label colors, and the URL underline color can be +configured, independently. + + ## Alt/meta By default, foot prefixes _Meta characters_ with ESC. This corresponds diff --git a/commands.c b/commands.c index ac91ab19..01ad4e1e 100644 --- a/commands.c +++ b/commands.c @@ -83,6 +83,7 @@ cmd_scrollback_up(struct terminal *term, int rows) } else term_damage_view(term); + render_refresh_urls(term); render_refresh(term); } @@ -157,5 +158,6 @@ cmd_scrollback_down(struct terminal *term, int rows) } else term_damage_view(term); + render_refresh_urls(term); render_refresh(term); } diff --git a/config.c b/config.c index e2d9e2cb..f7452994 100644 --- a/config.c +++ b/config.c @@ -75,6 +75,8 @@ static const char *const binding_action_map[] = { [BIND_ACTION_PIPE_SCROLLBACK] = "pipe-scrollback", [BIND_ACTION_PIPE_VIEW] = "pipe-visible", [BIND_ACTION_PIPE_SELECTED] = "pipe-selected", + [BIND_ACTION_SHOW_URLS_COPY] = "show-urls-copy", + [BIND_ACTION_SHOW_URLS_LAUNCH] = "show-urls-launch", /* Mouse-specific actions */ [BIND_ACTION_SELECT_BEGIN] = "select-begin", @@ -114,6 +116,14 @@ static const char *const search_binding_action_map[] = { static_assert(ALEN(search_binding_action_map) == BIND_ACTION_SEARCH_COUNT, "search binding action map size mismatch"); +static const char *const url_binding_action_map[] = { + [BIND_ACTION_URL_NONE] = NULL, + [BIND_ACTION_URL_CANCEL] = "cancel", +}; + +static_assert(ALEN(url_binding_action_map) == BIND_ACTION_URL_COUNT, + "URL binding action map size mismatch"); + #define LOG_AND_NOTIFY_ERR(...) \ do { \ LOG_ERR(__VA_ARGS__); \ @@ -410,6 +420,30 @@ str_to_color(const char *s, uint32_t *color, bool allow_alpha, return true; } +static bool +str_to_two_colors(const char *s, uint32_t *first, uint32_t *second, + bool allow_alpha, struct config *conf, const char *path, + int lineno, const char *section, const char *key) +{ + /* TODO: do this without strdup() */ + char *value_copy = xstrdup(s); + const char *first_as_str = strtok(value_copy, " "); + const char *second_as_str = strtok(NULL, " "); + + if (first_as_str == NULL || second_as_str == NULL || + !str_to_color(first_as_str, first, allow_alpha, conf, path, lineno, section, key) || + !str_to_color(second_as_str, second, allow_alpha, conf, path, lineno, section, key)) + { + LOG_AND_NOTIFY_ERR("%s:%d: [%s]: %s: invalid colors: %s", + path, lineno, section, key, s); + free(value_copy); + return false; + } + + free(value_copy); + return true; +} + static bool str_to_pt_or_px(const char *s, struct pt_or_px *res, struct config *conf, const char *path, int lineno, const char *section, const char *key) @@ -444,6 +478,33 @@ str_to_pt_or_px(const char *s, struct pt_or_px *res, struct config *conf, return true; } +static bool +str_to_spawn_template(struct config *conf, + const char *s, struct config_spawn_template *template, + const char *path, int lineno, const char *section, + const char *key) +{ + free(template->raw_cmd); + free(template->argv); + + template->raw_cmd = NULL; + template->argv = NULL; + + char *raw_cmd = xstrdup(s); + char **argv = NULL; + + if (!tokenize_cmdline(raw_cmd, &argv)) { + LOG_AND_NOTIFY_ERR( + "%s:%d: [%s]: %s: syntax error in command line", + path, lineno, section, key); + return false; + } + + template->raw_cmd = raw_cmd; + template->argv = argv; + return true; +} + static bool parse_section_main(const char *key, const char *value, struct config *conf, const char *path, unsigned lineno) @@ -666,24 +727,19 @@ parse_section_main(const char *key, const char *value, struct config *conf, } else if (strcmp(key, "notify") == 0) { - free(conf->notify.raw_cmd); - free(conf->notify.argv); - - conf->notify.raw_cmd = NULL; - conf->notify.argv = NULL; - - char *raw_cmd = xstrdup(value); - char **argv = NULL; - - if (!tokenize_cmdline(raw_cmd, &argv)) { - LOG_AND_NOTIFY_ERR( - "%s:%d: [default]: notify: syntax error in command line", - path, lineno); + if (!str_to_spawn_template(conf, value, &conf->notify, path, lineno, + "default", "notify")) + { return false; } + } - conf->notify.raw_cmd = raw_cmd; - conf->notify.argv = argv; + else if (strcmp(key, "url-launch") == 0) { + if (!str_to_spawn_template(conf, value, &conf->url_launch, path, lineno, + "default", "url-launch")) + { + return false; + } } else if (strcmp(key, "selection-target") == 0) { @@ -813,6 +869,30 @@ parse_section_colors(const char *key, const char *value, struct config *conf, else if (strcmp(key, "bright7") == 0) color = &conf->colors.bright[7]; else if (strcmp(key, "selection-foreground") == 0) color = &conf->colors.selection_fg; else if (strcmp(key, "selection-background") == 0) color = &conf->colors.selection_bg; + + else if (strcmp(key, "jump-labels") == 0) { + if (!str_to_two_colors( + value, &conf->colors.jump_label.fg, &conf->colors.jump_label.bg, + false, conf, path, lineno, "colors", "jump-labels")) + { + return false; + } + + conf->colors.use_custom.jump_label = true; + return true; + } + + else if (strcmp(key, "urls") == 0) { + if (!str_to_color(value, &conf->colors.url, false, + conf, path, lineno, "colors", "urls")) + { + return false; + } + + conf->colors.use_custom.url = true; + return true; + } + else if (strcmp(key, "alpha") == 0) { double alpha; if (!str_to_double(value, &alpha) || alpha < 0. || alpha > 1.) { @@ -860,23 +940,15 @@ parse_section_cursor(const char *key, const char *value, struct config *conf, conf->cursor.blink = str_to_bool(value); else if (strcmp(key, "color") == 0) { - char *value_copy = xstrdup(value); - const char *text = strtok(value_copy, " "); - const char *cursor = strtok(NULL, " "); - - uint32_t text_color, cursor_color; - if (text == NULL || cursor == NULL || - !str_to_color(text, &text_color, false, conf, path, lineno, "cursor", "color") || - !str_to_color(cursor, &cursor_color, false, conf, path, lineno, "cursor", "color")) + if (!str_to_two_colors( + value, &conf->cursor.color.text, &conf->cursor.color.cursor, + false, conf, path, lineno, "cursor", "color")) { - LOG_AND_NOTIFY_ERR("%s:%d: invalid cursor colors: %s", path, lineno, value); - free(value_copy); return false; } - conf->cursor.color.text = 1u << 31 | text_color; - conf->cursor.color.cursor = 1u << 31 | cursor_color; - free(value_copy); + conf->cursor.color.text |= 1u << 31; + conf->cursor.color.cursor |= 1u << 31; } else { @@ -1168,6 +1240,37 @@ has_search_binding_collisions(struct config *conf, enum bind_action_search actio return false; } +static bool +has_url_binding_collisions(struct config *conf, enum bind_action_url action, + const key_combo_list_t *key_combos, + const char *path, unsigned lineno) +{ + tll_foreach(conf->bindings.url, it) { + if (it->item.action == action) + continue; + + tll_foreach(*key_combos, it2) { + const struct config_key_modifiers *mods1 = &it->item.modifiers; + const struct config_key_modifiers *mods2 = &it2->item.modifiers; + + bool shift = mods1->shift == mods2->shift; + bool alt = mods1->alt == mods2->alt; + bool ctrl = mods1->ctrl == mods2->ctrl; + bool meta = mods1->meta == mods2->meta; + bool sym = it->item.sym == it2->item.sym; + + if (shift && alt && ctrl && meta && sym) { + LOG_AND_NOTIFY_ERR("%s:%d: %s already mapped to '%s'", + path, lineno, it2->item.text, + url_binding_action_map[it->item.action]); + return true; + } + } + } + + return false; +} + static int argv_compare(char *const *argv1, char *const *argv2) { @@ -1403,6 +1506,63 @@ parse_section_search_bindings( } +static bool +parse_section_url_bindings( + const char *key, const char *value, struct config *conf, + const char *path, unsigned lineno) +{ + for (enum bind_action_url action = 0; + action < BIND_ACTION_URL_COUNT; + action++) + { + if (url_binding_action_map[action] == NULL) + continue; + + if (strcmp(key, url_binding_action_map[action]) != 0) + continue; + + /* Unset binding */ + if (strcasecmp(value, "none") == 0) { + tll_foreach(conf->bindings.url, it) { + if (it->item.action == action) + tll_remove(conf->bindings.url, it); + } + return true; + } + + key_combo_list_t key_combos = tll_init(); + if (!parse_key_combos(conf, value, &key_combos, path, lineno) || + has_url_binding_collisions(conf, action, &key_combos, path, lineno)) + { + free_key_combo_list(&key_combos); + return false; + } + + /* Remove existing bindings for this action */ + tll_foreach(conf->bindings.url, it) { + if (it->item.action == action) + tll_remove(conf->bindings.url, it); + } + + /* Emit key bindings */ + tll_foreach(key_combos, it) { + struct config_key_binding_url binding = { + .action = action, + .modifiers = it->item.modifiers, + .sym = it->item.sym, + }; + tll_push_back(conf->bindings.url, binding); + } + + free_key_combo_list(&key_combos); + return true; + } + + LOG_AND_NOTIFY_ERR("%s:%u: [url-bindings]: %s: invalid key", path, lineno, key); + return false; + +} + static bool parse_mouse_combos(struct config *conf, const char *combos, key_combo_list_t *key_combos, const char *path, unsigned lineno) @@ -1778,6 +1938,7 @@ parse_config_file(FILE *f, struct config *conf, const char *path, bool errors_ar SECTION_CSD, SECTION_KEY_BINDINGS, SECTION_SEARCH_BINDINGS, + SECTION_URL_BINDINGS, SECTION_MOUSE_BINDINGS, SECTION_TWEAK, SECTION_COUNT, @@ -1800,6 +1961,7 @@ parse_config_file(FILE *f, struct config *conf, const char *path, bool errors_ar [SECTION_CSD] = {&parse_section_csd, "csd"}, [SECTION_KEY_BINDINGS] = {&parse_section_key_bindings, "key-bindings"}, [SECTION_SEARCH_BINDINGS] = {&parse_section_search_bindings, "search-bindings"}, + [SECTION_URL_BINDINGS] = {&parse_section_url_bindings, "url-bindings"}, [SECTION_MOUSE_BINDINGS] = {&parse_section_mouse_bindings, "mouse-bindings"}, [SECTION_TWEAK] = {&parse_section_tweak, "tweak"}, }; @@ -1993,6 +2155,7 @@ add_default_key_bindings(struct config *conf) add_binding(BIND_ACTION_FONT_SIZE_RESET, ctrl, XKB_KEY_0); add_binding(BIND_ACTION_FONT_SIZE_RESET, ctrl, XKB_KEY_KP_0); add_binding(BIND_ACTION_SPAWN_TERMINAL, ctrl_shift, XKB_KEY_N); + add_binding(BIND_ACTION_SHOW_URLS_LAUNCH, ctrl_shift, XKB_KEY_U); #undef add_binding } @@ -2045,6 +2208,26 @@ add_default_search_bindings(struct config *conf) #undef add_binding } +static void +add_default_url_bindings(struct config *conf) +{ +#define add_binding(action, mods, sym) \ + do { \ + tll_push_back( \ + conf->bindings.url, \ + ((struct config_key_binding_url){action, mods, sym})); \ +} while (0) + + const struct config_key_modifiers none = {0}; + const struct config_key_modifiers ctrl = {.ctrl = true}; + + add_binding(BIND_ACTION_URL_CANCEL, ctrl, XKB_KEY_g); + add_binding(BIND_ACTION_URL_CANCEL, ctrl, XKB_KEY_d); + add_binding(BIND_ACTION_URL_CANCEL, none, XKB_KEY_Escape); + +#undef add_binding +} + static void add_default_mouse_bindings(struct config *conf) { @@ -2138,7 +2321,11 @@ config_load(struct config *conf, const char *conf_path, .alpha = 0xffff, .selection_fg = 0x80000000, /* Use default bg */ .selection_bg = 0x80000000, /* Use default fg */ - .selection_uses_custom_colors = false, + .use_custom = { + .selection = false, + .jump_label = false, + .url = false, + }, }, .cursor = { @@ -2189,12 +2376,16 @@ config_load(struct config *conf, const char *conf_path, "notify-send -a foot -i foot ${title} ${body}"); tokenize_cmdline(conf->notify.raw_cmd, &conf->notify.argv); + conf->url_launch.raw_cmd = xstrdup("xdg-open ${url}"); + tokenize_cmdline(conf->url_launch.raw_cmd, &conf->url_launch.argv); + tll_foreach(*initial_user_notifications, it) tll_push_back(conf->notifications, it->item); tll_free(*initial_user_notifications); add_default_key_bindings(conf); add_default_search_bindings(conf); + add_default_url_bindings(conf); add_default_mouse_bindings(conf); struct config_file conf_file = {.path = NULL, .fd = -1}; @@ -2231,7 +2422,7 @@ config_load(struct config *conf, const char *conf_path, ret = parse_config_file(f, conf, conf_file.path, errors_are_fatal); fclose(f); - conf->colors.selection_uses_custom_colors = + conf->colors.use_custom.selection = conf->colors.selection_fg >> 24 == 0 && conf->colors.selection_bg >> 24 == 0; @@ -2252,6 +2443,13 @@ out: return ret; } +static void +free_spawn_template(struct config_spawn_template *template) +{ + free(template->raw_cmd); + free(template->argv); +} + void config_free(struct config conf) { @@ -2261,8 +2459,8 @@ config_free(struct config conf) free(conf.app_id); free(conf.word_delimiters); free(conf.scrollback.indicator.text); - free(conf.notify.raw_cmd); - free(conf.notify.argv); + free_spawn_template(&conf.notify); + free_spawn_template(&conf.url_launch); for (size_t i = 0; i < ALEN(conf.fonts); i++) { tll_foreach(conf.fonts[i], it) config_font_destroy(&it->item); @@ -2286,6 +2484,7 @@ config_free(struct config conf) tll_free(conf.bindings.key); tll_free(conf.bindings.mouse); tll_free(conf.bindings.search); + tll_free(conf.bindings.url); user_notifications_free(&conf.notifications); } diff --git a/config.h b/config.h index 56273053..5ae70ae3 100644 --- a/config.h +++ b/config.h @@ -42,6 +42,12 @@ struct config_key_binding_search { xkb_keysym_t sym; }; +struct config_key_binding_url { + enum bind_action_url action; + struct config_key_modifiers modifiers; + xkb_keysym_t sym; +}; + struct config_mouse_binding { enum bind_action_normal action; struct config_key_modifiers modifiers; @@ -60,6 +66,11 @@ struct pt_or_px { float pt; }; +struct config_spawn_template { + char *raw_cmd; + char **argv; +}; + struct config { char *term; char *shell; @@ -128,7 +139,18 @@ struct config { uint16_t alpha; uint32_t selection_fg; uint32_t selection_bg; - bool selection_uses_custom_colors; + uint32_t url; + + struct { + uint32_t fg; + uint32_t bg; + } jump_label; + + struct { + bool selection:1; + bool jump_label:1; + bool url:1; + } use_custom; } colors; struct { @@ -157,6 +179,9 @@ struct config { /* While searching (not - action to *start* a search is in the * 'key' bindings above */ tll(struct config_key_binding_search) search; + + /* While showing URL jump labels */ + tll(struct config_key_binding_url) url; } bindings; struct { @@ -167,10 +192,10 @@ struct config { int button_width; struct { - bool title_set; - bool minimize_set; - bool maximize_set; - bool close_set; + bool title_set:1; + bool minimize_set:1; + bool maximize_set:1; + bool close_set:1; uint32_t title; uint32_t minimize; uint32_t maximize; @@ -189,10 +214,8 @@ struct config { SELECTION_TARGET_BOTH } selection_target; - struct { - char *raw_cmd; - char **argv; - } notify; + struct config_spawn_template notify; + struct config_spawn_template url_launch; struct { enum fcft_scaling_filter fcft_filter; diff --git a/doc/foot.1.scd b/doc/foot.1.scd index 9099adc2..4965ca56 100644 --- a/doc/foot.1.scd +++ b/doc/foot.1.scd @@ -236,6 +236,31 @@ _Examples_: - Dina:weight=bold:slant=italic - Courier New:size=12 +# URLs + +Foot supports URL detection. But, unlike many other terminal +emulators, where URLs are highlighted when they are hovered and opened +by clicking on them, foot uses a keyboard driven approach. + +Pressing *ctrl*+*shift*+*u* enters _“URL mode”_, where all currently +visible URLs are underlined, and is associated with a +_“jump-label”_. The jump-label indicates the _key sequence_ +(e.g. *”AF”*) to use to activate the URL. + +The key binding can, of course, be customized, like all other key +bindings in foot. See *show-urls-launch* and *show-urls-copy* in +*foot.ini*(5). + +*show-urls-launch* by default opens the URL with *xdg-open*. This can +be changed with the *url-launch* option. + +*show-urls-copy* is an alternative to *show-urls-launch*, that changes +what activating an URL _does_; instead of opening it, it copies it to +the clipboard. It is unbound by default. + +Both the jump label colors, and the URL underline color can be +configured, independently. + # ALT/META CHARACTERS By default, foot prefixes meta characters with *ESC*. This corresponds diff --git a/doc/foot.ini.5.scd b/doc/foot.ini.5.scd index 8684850b..5bf9bbf5 100644 --- a/doc/foot.ini.5.scd +++ b/doc/foot.ini.5.scd @@ -225,6 +225,10 @@ in this order: Default: _notify-send -a foot -i foot ${title} ${body}_. +*url-launch* + Command to execute when opening URLs. _${url}_ will be replaced + with the actual URL. Default: _xdg-open ${url}_. + *selection-target* Clipboard target to automatically copy selected text to. One of *none*, *primary*, *clipboard* or *both*. Default: _primary_. @@ -347,6 +351,15 @@ _alpha_ option. text. Note that *both* options must be set, or the default will be used. Default: _inverse foreground/background_. +*jump-labels* + To RRGGBB values specifying the foreground (text) and background + colors to use when rendering jump labels in URL mode. Default: + _regular0 regular3_. + +*urls* + Color to use for the underline used to highlight URLs in URL + mode. Default: _regular3_. + # SECTION: csd @@ -491,6 +504,16 @@ e.g. *search-start=none*. Default: _not bound_ +*show-urls-launch* + Enter URL mode, where all currently visible URLs are tagged with a + jump label with a key sequence that will open the URL. Default: + _Control+Shift+U_. + +*show-urls-copy* + Enter URL mode, where all currently visible URLs are tagged with a + jump label with a key sequence that will place the URL in the + clipboard. Default: _none_. + # SECTION: search-bindings @@ -569,6 +592,17 @@ scrollback search mode. The syntax is exactly the same as the regular Paste from the _primary selection_ into the search buffer. Default: _Shift+Insert_. + +# SECTION: url-bindings + +This section lets you override the default key bindings used in URL +mode. The syntax is exactly the same as the regular **key-bindings**. + +*cancel* + Exits URL mode without opening an URL. Default: _Control+g + Control+d Escape_. + + # SECTION: mouse-bindings This section lets you override the default mouse bindings. diff --git a/foot.ini b/foot.ini index a9c0108f..8896eef0 100644 --- a/foot.ini +++ b/foot.ini @@ -20,10 +20,12 @@ # pad=2x2 # optionally append 'center' # resize-delay-ms=100 +# notify=notify-send -a foot -i foot ${title} ${body} +# url-launch=xdg-open ${url} + # bold-text-in-bright=no # bell=none # word-delimiters=,│`|:"'()[]{}<> -# notify=notify-send -a foot -i foot ${title} ${body} # selection-target=primary # workers= @@ -64,15 +66,17 @@ # bright7=ffffff # bright white # selection-foreground= # selection-background= +# jump-labels= +# urls= [csd] # preferred=server # size=26 # color= # button-width=26 -# button-minimize-color=ff0000ff -# button-maximize-color=ff00ff00 -# button-close-color=ffff0000 +# button-minimize-color= +# button-maximize-color= +# button-close-color= [key-bindings] # scrollback-up-page=Shift+Page_Up @@ -95,6 +99,8 @@ # pipe-visible=[sh -c "xurls | bemenu | xargs -r firefox"] none # pipe-scrollback=[sh -c "xurls | bemenu | xargs -r firefox"] none # pipe-selected=[xargs -r firefox] none +# show-urls-launch=Control+Shift+U +# show-urls-copy=none [search-bindings] # cancel=Control+g Escape @@ -116,6 +122,9 @@ # clipboard-paste=Control+v Control+y # primary-paste=Shift+Insert +[url-bindings] +# cancel=Control+g Control+d Escape + [mouse-bindings] # primary-paste=BTN_MIDDLE # select-begin=BTN_LEFT diff --git a/input.c b/input.c index 8052e265..69fce248 100644 --- a/input.c +++ b/input.c @@ -34,6 +34,7 @@ #include "spawn.h" #include "terminal.h" #include "tokenize.h" +#include "url-mode.h" #include "util.h" #include "vt.h" #include "xmalloc.h" @@ -271,6 +272,20 @@ execute_binding(struct seat *seat, struct terminal *term, return true; } + case BIND_ACTION_SHOW_URLS_COPY: + case BIND_ACTION_SHOW_URLS_LAUNCH: { + xassert(!urls_mode_is_active(term)); + + enum url_action url_action = action == BIND_ACTION_SHOW_URLS_COPY + ? URL_ACTION_COPY + : URL_ACTION_LAUNCH; + + urls_collect(term, url_action, &term->urls); + urls_assign_key_combos(&term->urls); + urls_render(term); + return true; + } + case BIND_ACTION_SELECT_BEGIN: selection_start( term, seat->mouse.col, seat->mouse.row, SELECTION_CHAR_WISE, false); @@ -403,6 +418,29 @@ convert_search_bindings(const struct config *conf, struct seat *seat) convert_search_binding(seat, &it->item); } +static void +convert_url_binding(struct seat *seat, + const struct config_key_binding_url *conf_binding) +{ + struct key_binding_url binding = { + .action = conf_binding->action, + .bind = { + .mods = conf_modifiers_to_mask(seat, &conf_binding->modifiers), + .sym = conf_binding->sym, + .key_codes = key_codes_for_xkb_sym( + seat->kbd.xkb_keymap, conf_binding->sym), + }, + }; + tll_push_back(seat->kbd.bindings.url, binding); +} + +static void +convert_url_bindings(const struct config *conf, struct seat *seat) +{ + tll_foreach(conf->bindings.url, it) + convert_url_binding(seat, &it->item); +} + static void convert_mouse_binding(struct seat *seat, const struct config_mouse_binding *conf_binding) @@ -528,6 +566,7 @@ keyboard_keymap(void *data, struct wl_keyboard *wl_keyboard, convert_key_bindings(wayl->conf, seat); convert_search_bindings(wayl->conf, seat); + convert_url_bindings(wayl->conf, seat); convert_mouse_bindings(wayl->conf, seat); } @@ -826,6 +865,11 @@ key_press_release(struct seat *seat, struct terminal *term, uint32_t serial, start_repeater(seat, key); search_input(seat, term, key, sym, effective_mods, serial); return; + } else if (urls_mode_is_active(term)) { + if (should_repeat) + start_repeater(seat, key); + urls_input(seat, term, key, sym, effective_mods, serial); + return; } #if 0 @@ -1194,6 +1238,7 @@ wl_pointer_enter(void *data, struct wl_pointer *wl_pointer, case TERM_SURF_SEARCH: case TERM_SURF_SCROLLBACK_INDICATOR: case TERM_SURF_RENDER_TIMER: + case TERM_SURF_JUMP_LABEL: case TERM_SURF_TITLE: render_xcursor_set(seat, term, XCURSOR_LEFT_PTR); break; @@ -1282,6 +1327,7 @@ wl_pointer_leave(void *data, struct wl_pointer *wl_pointer, case TERM_SURF_SEARCH: case TERM_SURF_SCROLLBACK_INDICATOR: case TERM_SURF_RENDER_TIMER: + case TERM_SURF_JUMP_LABEL: case TERM_SURF_TITLE: case TERM_SURF_BORDER_LEFT: case TERM_SURF_BORDER_RIGHT: @@ -1330,6 +1376,7 @@ wl_pointer_motion(void *data, struct wl_pointer *wl_pointer, case TERM_SURF_SEARCH: case TERM_SURF_SCROLLBACK_INDICATOR: case TERM_SURF_RENDER_TIMER: + case TERM_SURF_JUMP_LABEL: case TERM_SURF_BUTTON_MINIMIZE: case TERM_SURF_BUTTON_MAXIMIZE: case TERM_SURF_BUTTON_CLOSE: @@ -1674,6 +1721,7 @@ wl_pointer_button(void *data, struct wl_pointer *wl_pointer, case TERM_SURF_SEARCH: case TERM_SURF_SCROLLBACK_INDICATOR: case TERM_SURF_RENDER_TIMER: + case TERM_SURF_JUMP_LABEL: break; case TERM_SURF_GRID: { diff --git a/meson.build b/meson.build index b9168206..f2e6da2b 100644 --- a/meson.build +++ b/meson.build @@ -180,6 +180,7 @@ executable( 'slave.c', 'slave.h', 'spawn.c', 'spawn.h', 'tokenize.c', 'tokenize.h', + 'url-mode.c', 'url-mode.h', 'user-notification.h', 'wayland.c', 'wayland.h', wl_proto_src + wl_proto_headers, version, diff --git a/notify.c b/notify.c index b8480d68..3f36e904 100644 --- a/notify.c +++ b/notify.c @@ -30,69 +30,20 @@ notify_notify(const struct terminal *term, const char *title, const char *body) if (term->conf->notify.argv == NULL) return; - size_t argv_size = 0; - for (; term->conf->notify.argv[argv_size] != NULL; argv_size++) - ; + char **argv = NULL; + size_t argc = 0; -#define append(s, n) \ - do { \ - expanded = xrealloc(expanded, len + (n) + 1); \ - memcpy(&expanded[len], s, n); \ - len += n; \ - expanded[len] = '\0'; \ - } while (0) - - char **argv = malloc((argv_size + 1) * sizeof(argv[0])); - - /* Expand ${title} and ${body} */ - for (size_t i = 0; i < argv_size; i++) { - size_t len = 0; - char *expanded = NULL; - - char *start = NULL; - char *last_end = term->conf->notify.argv[i]; - - while ((start = strstr(last_end, "${")) != NULL) { - /* Append everything from the last template's end to this - * one's beginning */ - append(last_end, start - last_end); - - /* Find end of template */ - start += 2; - char *end = strstr(start, "}"); - - if (end == NULL) { - /* Ensure final append() copies the unclosed '${' */ - last_end = start - 2; - LOG_WARN("notify: unclosed template: %s", last_end); - break; - } - - /* Expand template */ - if (strncmp(start, "title", end - start) == 0) - append(title, strlen(title)); - else if (strncmp(start, "body", end - start) == 0) - append(body, strlen(body)); - else { - /* Unrecognized template - append it as-is */ - start -= 2; - append(start, end + 1 - start); - LOG_WARN("notify: unrecognized template: %.*s", - (int)(end + 1 - start), start); - } - - last_end = end + 1;; - } - - append(last_end, term->conf->notify.argv[i] + strlen(term->conf->notify.argv[i]) - last_end); - argv[i] = expanded; + if (!spawn_expand_template( + &term->conf->notify, 2, + (const char *[]){"title", "body"}, + (const char *[]){title, body}, + &argc, &argv)) + { + return; } - argv[argv_size] = NULL; - -#undef append LOG_DBG("notify command:"); - for (size_t i = 0; i < argv_size; i++) + for (size_t i = 0; i < argc; i++) LOG_DBG(" argv[%zu] = \"%s\"", i, argv[i]); /* Redirect stdin to /dev/null, but ignore failure to open */ @@ -102,7 +53,7 @@ notify_notify(const struct terminal *term, const char *title, const char *body) if (devnull >= 0) close(devnull); - for (size_t i = 0; i < argv_size; i++) + for (size_t i = 0; i < argc; i++) free(argv[i]); free(argv); } diff --git a/pgo/pgo.c b/pgo/pgo.c index fa95a0ec..4bb67d35 100644 --- a/pgo/pgo.c +++ b/pgo/pgo.c @@ -138,6 +138,7 @@ notify_notify(const struct terminal *term, const char *title, const char *body) void reaper_add(struct reaper *reaper, pid_t pid, reaper_cb cb, void *cb_data) {} void reaper_del(struct reaper *reaper, pid_t pid) {} +void urls_reset(struct terminal *term) {} int main(int argc, const char *const *argv) diff --git a/render.c b/render.c index aa415a18..c64692b8 100644 --- a/render.c +++ b/render.c @@ -1,6 +1,7 @@ #include "render.h" #include +#include #include #include @@ -28,13 +29,14 @@ #include "config.h" #include "grid.h" #include "hsl.h" +#include "ime.h" #include "quirks.h" #include "selection.h" -#include "sixel.h" #include "shm.h" +#include "sixel.h" +#include "url-mode.h" #include "util.h" #include "xmalloc.h" -#include "ime.h" #define TIME_SCROLL_DAMAGE 0 @@ -411,7 +413,7 @@ render_cell(struct terminal *term, pixman_image_t *pix, uint32_t _fg = 0; uint32_t _bg = 0; - if (is_selected && term->conf->colors.selection_uses_custom_colors) { + if (is_selected && term->conf->colors.use_custom.selection) { _fg = term->conf->colors.selection_fg; _bg = term->conf->colors.selection_bg; } else { @@ -615,6 +617,15 @@ render_cell(struct terminal *term, pixman_image_t *pix, if (cell->attrs.strikethrough) draw_strikeout(term, pix, font, &fg, x, y, cell_cols); + if (unlikely(cell->attrs.url)) { + pixman_color_t url_color = color_hex_to_pixman( + term->conf->colors.use_custom.url + ? term->conf->colors.url + : term->colors.table[3] + ); + draw_underline(term, pix, font, &url_color, x, y, cell_cols); + } + draw_cursor: if (has_cursor && (term->cursor_style != CURSOR_BLOCK || !term->kbd_focus)) draw_cursor(term, cell, font, pix, &fg, &bg, x, y, cell_cols); @@ -1604,27 +1615,27 @@ render_csd_button(struct terminal *term, enum csd_surface surf_idx) uint32_t _color; uint16_t alpha = 0xffff; bool is_active = false; - const bool *is_set = NULL; + bool is_set = false; const uint32_t *conf_color = NULL; switch (surf_idx) { case CSD_SURF_MINIMIZE: _color = term->colors.default_table[4]; /* blue */ - is_set = &term->conf->csd.color.minimize_set; + is_set = term->conf->csd.color.minimize_set; conf_color = &term->conf->csd.color.minimize; is_active = term->active_surface == TERM_SURF_BUTTON_MINIMIZE; break; case CSD_SURF_MAXIMIZE: _color = term->colors.default_table[2]; /* green */ - is_set = &term->conf->csd.color.maximize_set; + is_set = term->conf->csd.color.maximize_set; conf_color = &term->conf->csd.color.maximize; is_active = term->active_surface == TERM_SURF_BUTTON_MAXIMIZE; break; case CSD_SURF_CLOSE: _color = term->colors.default_table[1]; /* red */ - is_set = &term->conf->csd.color.close_set; + is_set = term->conf->csd.color.close_set; conf_color = &term->conf->csd.color.close; is_active = term->active_surface == TERM_SURF_BUTTON_CLOSE; break; @@ -1635,7 +1646,7 @@ render_csd_button(struct terminal *term, enum csd_surface surf_idx) } if (is_active) { - if (*is_set) { + if (is_set) { _color = *conf_color; alpha = _color >> 24 | (_color >> 24 << 8); } @@ -2517,6 +2528,120 @@ render_search_box(struct terminal *term) #undef WINDOW_Y } +static void +render_urls(struct terminal *term) +{ + struct wl_window *win = term->window; + xassert(tll_length(win->urls) > 0); + + /* Calculate view start, counted from the *current* scrollback start */ + const int scrollback_end + = (term->grid->offset + term->rows) & (term->grid->num_rows - 1); + const int view_start + = (term->grid->view + - scrollback_end + + term->grid->num_rows) & (term->grid->num_rows - 1); + const int view_end = view_start + term->rows - 1; + + tll_foreach(win->urls, it) { + const struct url *url = it->item.url; + const wchar_t *text = url->text; + const wchar_t *key = url->key; + const size_t entered_key_len = wcslen(term->url_keys); + + struct wl_surface *surf = it->item.surf; + struct wl_subsurface *sub_surf = it->item.sub_surf; + + if (surf == NULL || sub_surf == NULL) + continue; + + bool hide = false; + const struct coord *pos = &url->start; + const int _row + = (pos->row + - scrollback_end + + term->grid->num_rows) & (term->grid->num_rows - 1); + + if (_row < view_start || _row > view_end) + hide = true; + if (wcslen(key) <= entered_key_len) + hide = true; + if (wcsncmp(term->url_keys, key, entered_key_len) != 0) + hide = true; + + if (hide) { + wl_surface_attach(surf, NULL, 0, 0); + wl_surface_commit(surf); + continue; + } + + size_t text_len = wcslen(text); + size_t chars = wcslen(key) + (text_len > 0 ? 3 + text_len : 0); + + const size_t max_chars = 50; + chars = min(chars, max_chars); + + wchar_t label[chars + 2]; + if (text_len == 0) + wcscpy(label, key); + else { + int count = swprintf(label, chars + 1, L"%ls - %ls", key, text); + if (count >= max_chars) { + label[max_chars] = L'…'; + label[max_chars + 1] = L'\0'; + } + } + + for (size_t i = 0; i < wcslen(key); i++) + label[i] = towupper(label[i]); + + for (size_t i = 0; i < entered_key_len; i++) + label[i] = L' '; + + size_t len = wcslen(label); + int cols = wcswidth(label, len); + + const int x_margin = 2 * term->scale; + const int y_margin = 1 * term->scale; + int width = 2 * x_margin + cols * term->cell_width; + int height = 2 * y_margin + term->cell_height; + + struct buffer *buf = shm_get_buffer( + term->wl->shm, width, height, shm_cookie_url(url), false, 1); + + int col = pos->col; + int row = pos->row - term->grid->view; + while (row < 0) + row += term->grid->num_rows; + row &= (term->grid->num_rows - 1); + + int x = col * term->cell_width - 15 * term->cell_width / 10; + int y = row * term->cell_height - 5 * term->cell_height / 10; + + if (x < 0) + x = 0; +#if 0 + if (y < 0) + y += 15 * term->cell_height / 10; +#endif + + wl_subsurface_set_position( + sub_surf, + (term->margins.left + x) / term->scale, + (term->margins.top + y) / term->scale); + + uint32_t fg = term->conf->colors.use_custom.jump_label + ? term->conf->colors.jump_label.fg + : term->colors.table[0]; + uint32_t bg = term->conf->colors.use_custom.jump_label + ? term->conf->colors.jump_label.bg + : term->colors.table[3]; + + render_osd(term, surf, sub_surf, buf, label, + fg, bg, width, height, x_margin, y_margin); + } +} + static void render_update_title(struct terminal *term) { @@ -2545,13 +2670,15 @@ frame_callback(void *data, struct wl_callback *wl_callback, uint32_t callback_da bool grid = term->render.pending.grid; bool csd = term->render.pending.csd; - bool search = term->render.pending.search; + bool search = term->is_searching && term->render.pending.search; bool title = term->render.pending.title; + bool urls = urls_mode_is_active(term) > 0 && term->render.pending.urls; term->render.pending.grid = false; term->render.pending.csd = false; term->render.pending.search = false; term->render.pending.title = false; + term->render.pending.urls = false; if (csd && term->window->use_csd == CSD_YES) { quirk_weston_csd_on(term); @@ -2562,15 +2689,19 @@ frame_callback(void *data, struct wl_callback *wl_callback, uint32_t callback_da if (title) render_update_title(term); - if (search && term->is_searching) + if (search) render_search_box(term); - tll_foreach(term->wl->seats, it) + if (urls) + render_urls(term); + + if (grid && (!term->delayed_render_timer.is_armed | csd | search | urls)) + grid_render(term); + + tll_foreach(term->wl->seats, it) { if (it->item.kbd_focus == term) ime_update_cursor_rect(&it->item, term); - - if (grid && (!term->delayed_render_timer.is_armed || csd || search)) - grid_render(term); + } } static void @@ -2755,6 +2886,9 @@ maybe_resize(struct terminal *term, int width, int height, bool force) /* Cancel an application initiated "Synchronized Update" */ term_disable_app_sync_updates(term); + /* Drop out of URL mode */ + urls_reset(term); + term->width = width; term->height = height; term->scale = scale; @@ -2981,27 +3115,21 @@ fdm_hook_refresh_pending_terminals(struct fdm *fdm, void *data) bool grid = term->render.refresh.grid; bool csd = term->render.refresh.csd; - bool search = term->render.refresh.search; + bool search = term->is_searching && term->render.refresh.search; bool title = term->render.refresh.title; + bool urls = urls_mode_is_active(term) && term->render.refresh.urls; - if (!term->is_searching) - search = false; - - if (!(grid | csd | search | title)) + if (!(grid | csd | search | title | urls)) continue; - if (term->render.app_sync_updates.enabled && !(csd | search | title)) + if (term->render.app_sync_updates.enabled && !(csd | search | title | urls)) continue; - if (csd | search) { - /* Force update of parent surface */ - grid = true; - } - term->render.refresh.grid = false; term->render.refresh.csd = false; term->render.refresh.search = false; term->render.refresh.title = false; + term->render.refresh.urls = false; if (term->window->frame_callback == NULL) { if (csd && term->window->use_csd == CSD_YES) { @@ -3013,17 +3141,22 @@ fdm_hook_refresh_pending_terminals(struct fdm *fdm, void *data) render_update_title(term); if (search) render_search_box(term); - tll_foreach(term->wl->seats, it) + if (urls) + render_urls(term); + if (grid | csd | search | urls) + grid_render(term); + + tll_foreach(term->wl->seats, it) { if (it->item.kbd_focus == term) ime_update_cursor_rect(&it->item, term); - if (grid) - grid_render(term); + } } else { /* Tells the frame callback to render again */ term->render.pending.grid |= grid; term->render.pending.csd |= csd; term->render.pending.search |= search; term->render.pending.title |= title; + term->render.pending.urls |= urls; } } @@ -3065,6 +3198,13 @@ render_refresh_search(struct terminal *term) term->render.refresh.search = true; } +void +render_refresh_urls(struct terminal *term) +{ + if (urls_mode_is_active(term)) + term->render.refresh.urls = true; +} + bool render_xcursor_set(struct seat *seat, struct terminal *term, const char *xcursor) { diff --git a/render.h b/render.h index 05c79322..1179a5dc 100644 --- a/render.h +++ b/render.h @@ -16,6 +16,7 @@ void render_refresh(struct terminal *term); void render_refresh_csd(struct terminal *term); void render_refresh_search(struct terminal *term); void render_refresh_title(struct terminal *term); +void render_refresh_urls(struct terminal *term); bool render_xcursor_set(struct seat *seat, struct terminal *term, const char *xcursor); struct render_worker_context { diff --git a/shm.h b/shm.h index fc48308e..2e819369 100644 --- a/shm.h +++ b/shm.h @@ -52,3 +52,6 @@ static inline unsigned long shm_cookie_search(const struct terminal *term) { ret static inline unsigned long shm_cookie_scrollback_indicator(const struct terminal *term) { return (unsigned long)(uintptr_t)term + 2; } static inline unsigned long shm_cookie_render_timer(const struct terminal *term) { return (unsigned long)(uintptr_t)term + 3; } static inline unsigned long shm_cookie_csd(const struct terminal *term, int n) { return (unsigned long)((uintptr_t)term + 4 + (n)); } + +struct url; +static inline unsigned long shm_cookie_url(const struct url *url) { return (unsigned long)(uintptr_t)url; } diff --git a/spawn.c b/spawn.c index fbcf0952..e9fb2f96 100644 --- a/spawn.c +++ b/spawn.c @@ -1,5 +1,6 @@ #include "spawn.h" +#include #include #include @@ -11,6 +12,7 @@ #define LOG_ENABLE_DBG 0 #include "log.h" #include "debug.h" +#include "xmalloc.h" bool spawn(struct reaper *reaper, const char *cwd, char *const argv[], @@ -74,3 +76,81 @@ err: close(pipe_fds[1]); return false; } + +bool +spawn_expand_template(const struct config_spawn_template *template, + size_t key_count, + const char *key_names[static key_count], + const char *key_values[static key_count], + size_t *argc, char ***argv) +{ + *argc = 0; + *argv = NULL; + + for (; template->argv[*argc] != NULL; (*argc)++) + ; + +#define append(s, n) \ + do { \ + expanded = xrealloc(expanded, len + (n) + 1); \ + memcpy(&expanded[len], s, n); \ + len += n; \ + expanded[len] = '\0'; \ + } while (0) + + *argv = malloc((*argc + 1) * sizeof((*argv)[0])); + + /* Expand the provided keys */ + for (size_t i = 0; i < *argc; i++) { + size_t len = 0; + char *expanded = NULL; + + char *start = NULL; + char *last_end = template->argv[i]; + + while ((start = strstr(last_end, "${")) != NULL) { + /* Append everything from the last template's end to this + * one's beginning */ + append(last_end, start - last_end); + + /* Find end of template */ + start += 2; + char *end = strstr(start, "}"); + + if (end == NULL) { + /* Ensure final append() copies the unclosed '${' */ + last_end = start - 2; + LOG_WARN("notify: unclosed template: %s", last_end); + break; + } + + /* Expand template */ + bool valid_key = false; + for (size_t j = 0; j < key_count; j++) { + if (strncmp(start, key_names[j], end - start) != 0) + continue; + + append(key_values[j], strlen(key_values[j])); + valid_key = true; + break; + } + + if (!valid_key) { + /* Unrecognized template - append it as-is */ + start -= 2; + append(start, end + 1 - start); + LOG_WARN("notify: unrecognized template: %.*s", + (int)(end + 1 - start), start); + } + + last_end = end + 1; + } + + append(last_end, template->argv[i] + strlen(template->argv[i]) - last_end); + (*argv)[i] = expanded; + } + (*argv)[*argc] = NULL; + +#undef append + return true; +} diff --git a/spawn.h b/spawn.h index 2ab645a8..c6f9582e 100644 --- a/spawn.h +++ b/spawn.h @@ -1,7 +1,13 @@ #pragma once #include +#include "config.h" #include "reaper.h" bool spawn(struct reaper *reaper, const char *cwd, char *const argv[], int stdin_fd, int stdout_fd, int stderr_fd); + +bool spawn_expand_template( + const struct config_spawn_template *template, + size_t key_count, const char *key_names[static key_count], + const char *key_values[static key_count], size_t *argc, char ***argv); diff --git a/terminal.c b/terminal.c index 701313cc..91a442ec 100644 --- a/terminal.c +++ b/terminal.c @@ -36,6 +36,7 @@ #include "sixel.h" #include "slave.h" #include "spawn.h" +#include "url-mode.h" #include "util.h" #include "vt.h" #include "xmalloc.h" @@ -227,6 +228,8 @@ fdm_ptmx(struct fdm *fdm, int fd, int events, void *data) cursor_blink_rearm_timer(term); } + urls_reset(term); + uint8_t buf[24 * 1024]; ssize_t count = sizeof(buf); @@ -1404,8 +1407,10 @@ term_destroy(struct terminal *term) fdm_del(term->fdm, term->flash.fd); fdm_del(term->fdm, term->ptmx); - if (term->window != NULL) + if (term->window != NULL) { wayl_win_destroy(term->window); + term->window = NULL; + } mtx_lock(&term->render.workers.lock); xassert(tll_length(term->render.workers.queue) == 0); @@ -1485,6 +1490,7 @@ term_destroy(struct terminal *term) tll_free(term->alt.sixel_images); sixel_fini(term); + urls_reset(term); term_ime_reset(term); free(term->foot_exe); @@ -2823,8 +2829,13 @@ term_surface_kind(const struct terminal *term, const struct wl_surface *surface) return TERM_SURF_BUTTON_MAXIMIZE; else if (surface == term->window->csd.surface[CSD_SURF_CLOSE]) return TERM_SURF_BUTTON_CLOSE; - else + else { + tll_foreach(term->window->urls, it) { + if (surface == it->item.surf) + return TERM_SURF_JUMP_LABEL; + } return TERM_SURF_NONE; + } } static bool @@ -2963,3 +2974,4 @@ term_ime_set_cursor_rect(struct terminal *term, int x, int y, int width, } #endif } + diff --git a/terminal.h b/terminal.h index 17873c40..43dd8e03 100644 --- a/terminal.h +++ b/terminal.h @@ -40,7 +40,8 @@ struct attributes { uint32_t have_fg:1; uint32_t have_bg:1; uint32_t selected:2; - uint32_t reserved:3; + uint32_t url:1; + uint32_t reserved:2; uint32_t bg:24; }; static_assert(sizeof(struct attributes) == 8, "bad size"); @@ -211,6 +212,7 @@ enum term_surface { TERM_SURF_SEARCH, TERM_SURF_SCROLLBACK_INDICATOR, TERM_SURF_RENDER_TIMER, + TERM_SURF_JUMP_LABEL, TERM_SURF_TITLE, TERM_SURF_BORDER_LEFT, TERM_SURF_BORDER_RIGHT, @@ -223,6 +225,17 @@ enum term_surface { typedef tll(struct ptmx_buffer) ptmx_buffer_list_t; +enum url_action { URL_ACTION_COPY, URL_ACTION_LAUNCH }; +struct url { + wchar_t *url; + wchar_t *text; + wchar_t *key; + struct coord start; + struct coord end; + enum url_action action; +}; +typedef tll(struct url) url_list_t; + struct terminal { struct fdm *fdm; struct reaper *reaper; @@ -421,6 +434,7 @@ struct terminal { bool csd; bool search; bool title; + bool urls; } refresh; /* Scheduled for rendering, in the next frame callback */ @@ -429,6 +443,7 @@ struct terminal { bool csd; bool search; bool title; + bool urls; } pending; bool margins; /* Someone explicitly requested a refresh of the margins */ @@ -499,6 +514,9 @@ struct terminal { unsigned max_height; /* Maximum image height, in pixels */ } sixel; + url_list_t urls; + wchar_t url_keys[5]; + #if defined(FOOT_IME_ENABLED) && FOOT_IME_ENABLED struct { bool enabled; @@ -646,3 +664,6 @@ void term_ime_disable(struct terminal *term); void term_ime_reset(struct terminal *term); void term_ime_set_cursor_rect( struct terminal *term, int x, int y, int width, int height); + +void term_urls_reset(struct terminal *term); +void term_collect_urls(struct terminal *term); diff --git a/url-mode.c b/url-mode.c new file mode 100644 index 00000000..40f3372f --- /dev/null +++ b/url-mode.c @@ -0,0 +1,555 @@ +#include "url-mode.h" + +#include +#include + +#define LOG_MODULE "url-mode" +#define LOG_ENABLE_DBG 1 +#include "log.h" +#include "grid.h" +#include "render.h" +#include "selection.h" +#include "spawn.h" +#include "terminal.h" +#include "util.h" +#include "xmalloc.h" + +static bool +execute_binding(struct seat *seat, struct terminal *term, + enum bind_action_url action, uint32_t serial) +{ + switch (action) { + case BIND_ACTION_URL_NONE: + return false; + + case BIND_ACTION_URL_CANCEL: + urls_reset(term); + return true; + + case BIND_ACTION_URL_COUNT: + return false; + + } + return true; +} + +static void +activate_url(struct seat *seat, struct terminal *term, const struct url *url) +{ + size_t chars = wcstombs(NULL, url->url, 0); + + if (chars != (size_t)-1) { + char *url_utf8 = xmalloc(chars + 1); + wcstombs(url_utf8, url->url, chars + 1); + + switch (url->action) { + case URL_ACTION_COPY: + if (text_to_clipboard(seat, term, url_utf8, seat->kbd.serial)) { + /* Now owned by our clipboard “manager” */ + url_utf8 = NULL; + } + break; + + case URL_ACTION_LAUNCH: { + size_t argc; + char **argv; + + if (spawn_expand_template( + &term->conf->url_launch, 1, + (const char *[]){"url"}, + (const char *[]){url_utf8}, + &argc, &argv)) + { + spawn(term->reaper, term->cwd, argv, -1, -1, -1); + + for (size_t i = 0; i < argc; i++) + free(argv[i]); + free(argv); + } + break; + } + } + + free(url_utf8); + } +} + +void +urls_input(struct seat *seat, struct terminal *term, uint32_t key, + xkb_keysym_t sym, xkb_mod_mask_t mods, uint32_t serial) +{ + /* Key bindings */ + tll_foreach(seat->kbd.bindings.url, it) { + if (it->item.bind.mods != mods) + continue; + + /* Match symbol */ + if (it->item.bind.sym == sym) { + execute_binding(seat, term, it->item.action, serial); + return; + } + + /* Match raw key code */ + tll_foreach(it->item.bind.key_codes, code) { + if (code->item == key) { + execute_binding(seat, term, it->item.action, serial); + return; + } + } + } + + size_t seq_len = wcslen(term->url_keys); + + if (sym == XKB_KEY_BackSpace) { + if (seq_len > 0) { + term->url_keys[seq_len - 1] = L'\0'; + render_refresh_urls(term); + } + + return; + } + + wchar_t wc = xkb_state_key_get_utf32(seat->kbd.xkb_state, key); + + /* + * Determine if this is a “valid” key. I.e. if there is an URL + * label with a key combo where this key is the next in + * sequence. + */ + + bool is_valid = false; + const struct url *match = NULL; + + tll_foreach(term->urls, it) { + const struct url *url = &it->item; + const size_t key_len = wcslen(it->item.key); + + if (key_len >= seq_len + 1 && + wcsncmp(url->key, term->url_keys, seq_len) == 0 && + url->key[seq_len] == wc) + { + is_valid = true; + if (key_len == seq_len + 1) { + match = url; + break; + } + } + } + + if (match) { + activate_url(seat, term, match); + urls_reset(term); + } + + else if (is_valid) { + xassert(seq_len + 1 <= ALEN(term->url_keys)); + term->url_keys[seq_len] = wc; + render_refresh_urls(term); + } +} + +IGNORE_WARNING("-Wpedantic") + +static void +auto_detected(const struct terminal *term, enum url_action action, url_list_t *urls) +{ + static const wchar_t *const prots[] = { + L"http://", + L"https://", + L"ftp://", + L"ftps://", + L"file://", + L"gemini://", + L"gopher://", + }; + + size_t max_prot_len = 0; + for (size_t i = 0; i < ALEN(prots); i++) { + size_t len = wcslen(prots[i]); + if (len > max_prot_len) + max_prot_len = len; + } + + wchar_t proto_chars[max_prot_len]; + struct coord proto_start[max_prot_len]; + size_t proto_char_count = 0; + + enum { + STATE_PROTOCOL, + STATE_URL, + } state = STATE_PROTOCOL; + + struct coord start = {-1, -1}; + wchar_t url[term->cols * term->rows + 1]; + size_t len = 0; + + ssize_t parenthesis = 0; + ssize_t brackets = 0; + + for (int r = 0; r < term->rows; r++) { + const struct row *row = grid_row_in_view(term->grid, r); + + for (int c = 0; c < term->cols; c++) { + const struct cell *cell = &row->cells[c]; + wchar_t wc = cell->wc; + + switch (state) { + case STATE_PROTOCOL: + for (size_t i = 0; i < max_prot_len - 1; i++) { + proto_chars[i] = proto_chars[i + 1]; + proto_start[i] = proto_start[i + 1]; + } + + if (proto_char_count == max_prot_len) + proto_char_count--; + + proto_chars[proto_char_count] = wc; + proto_start[proto_char_count] = (struct coord){c, r}; + proto_char_count++; + + for (size_t i = 0; i < ALEN(prots); i++) { + size_t prot_len = wcslen(prots[i]); + + if (proto_char_count < prot_len) + continue; + + const wchar_t *proto = &proto_chars[max_prot_len - prot_len]; + + if (wcsncasecmp(prots[i], proto, prot_len) == 0) { + state = STATE_URL; + start = proto_start[max_prot_len - prot_len]; + + wcsncpy(url, proto, prot_len); + len = prot_len; + + parenthesis = brackets = 0; + break; + } + } + break; + + case STATE_URL: { + // static const wchar_t allowed[] = + // L"abcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;="; + // static const wchar_t unwise[] = L"{}|\\^[]`"; + // static const wchar_t reserved[] = L";/?:@&=+$,"; + + bool emit_url = false; + switch (wc) { + case L'a'...L'z': + case L'A'...L'Z': + case L'0'...L'9': + case L'-': case L'.': case L'_': case L'~': case L':': + case L'/': case L'?': case L'#': case L'@': case L'!': + case L'$': case L'&': case L'\'': case L'*': case L'+': + case L',': case L';': case L'=': case L'"': + url[len++] = wc; + break; + + case L'(': + parenthesis++; + url[len++] = wc; + break; + + case L'[': + brackets++; + url[len++] = wc; + break; + + case L')': + if (--parenthesis < 0) + emit_url = true; + else + url[len++] = wc; + break; + + case L']': + if (--brackets < 0) + emit_url = true; + else + url[len++] = wc; + break; + + default: + emit_url = true; + break; + } + + if (c >= term->cols - 1 && row->linebreak) + emit_url = true; + + if (emit_url) { + /* Heuristic to remove trailing characters that + * are valid URL characters, but typically not at + * the end of the URL */ + bool done = false; + struct coord end = {c, r}; + + if (--end.col < 0) { + end.row--; + end.col = term->cols - 1; + } + + do { + switch (url[len - 1]) { + case L'.': case L',': case L':': case L';': case L'?': + case L'!': case L'"': case L'\'': + len--; + end.col--; + if (end.col < 0) { + end.row--; + end.col = term->cols - 1; + } + break; + + default: + done = true; + break; + } + } while (!done); + + url[len] = L'\0'; + + start.row += term->grid->view; + end.row += term->grid->view; + + tll_push_back( + *urls, + ((struct url){ + .url = xwcsdup(url), + .text = xwcsdup(L""), + .start = start, + .end = end, + .action = action})); + + state = STATE_PROTOCOL; + len = 0; + parenthesis = brackets = 0; + } + break; + } + } + } + } +} + +UNIGNORE_WARNINGS + +void +urls_collect(const struct terminal *term, enum url_action action, url_list_t *urls) +{ + xassert(tll_length(term->urls) == 0); + auto_detected(term, action, urls); +} + +static void url_destroy(struct url *url); + +static int +wcscmp_qsort_wrapper(const void *_a, const void *_b) +{ + const wchar_t *a = *(const wchar_t **)_a; + const wchar_t *b = *(const wchar_t **)_b; + return wcscmp(a, b); +} + +static void +generate_key_combos(size_t count, wchar_t *combos[static count]) +{ + /* vimium default */ + static const wchar_t alphabet[] = L"sadfjklewcmpgh"; + static const size_t alphabet_len = ALEN(alphabet) - 1; + + size_t hints_count = 1; + wchar_t **hints = xmalloc(hints_count * sizeof(hints[0])); + + hints[0] = xwcsdup(L""); + + size_t offset = 0; + do { + const wchar_t *prefix = hints[offset++]; + const size_t prefix_len = wcslen(prefix); + + hints = xrealloc(hints, (hints_count + alphabet_len) * sizeof(hints[0])); + + const wchar_t *wc = &alphabet[0]; + for (size_t i = 0; i < alphabet_len; i++, wc++) { + wchar_t *hint = xmalloc((prefix_len + 1 + 1) * sizeof(wchar_t)); + hints[hints_count + i] = hint; + + /* Will be reversed later */ + hint[0] = *wc; + wcscpy(&hint[1], prefix); + } + hints_count += alphabet_len; + } while (hints_count - offset < count); + + xassert(hints_count - offset >= count); + + /* Copy slice of ‘hints’ array to the caller provided array */ + for (size_t i = 0; i < hints_count; i++) { + if (i >= offset && i < offset + count) + combos[i - offset] = hints[i]; + else + free(hints[i]); + } + free(hints); + + /* Sorting is a kind of shuffle, since we’re sorting on the + * *reversed* strings */ + qsort(combos, count, sizeof(wchar_t *), &wcscmp_qsort_wrapper); + + /* Reverse all strings */ + for (size_t i = 0; i < count; i++) { + const size_t len = wcslen(combos[i]); + for (size_t j = 0; j < len / 2; j++) { + wchar_t tmp = combos[i][j]; + combos[i][j] = combos[i][len - j - 1]; + combos[i][len - j - 1] = tmp; + } + } +} + +void +urls_assign_key_combos(url_list_t *urls) +{ + const size_t count = tll_length(*urls); + if (count == 0) + return; + + wchar_t *combos[count]; + generate_key_combos(count, combos); + + size_t idx = 0; + tll_foreach(*urls, it) + it->item.key = combos[idx++]; + +#if defined(_DEBUG) && LOG_ENABLE_DBG + tll_foreach(*urls, it) { + char url[1024]; + wcstombs(url, it->item.url, sizeof(url) - 1); + + char key[32]; + wcstombs(key, it->item.key, sizeof(key) - 1); + + LOG_DBG("URL: %s (%s)", url, key); + } +#endif +} + +static void +tag_cells_for_url(struct terminal *term, const struct url *url, bool value) +{ + const struct coord *start = &url->start; + const struct coord *end = &url->end; + + size_t end_r = end->row & (term->grid->num_rows - 1); + + size_t r = start->row & (term->grid->num_rows - 1); + size_t c = start->col; + + struct row *row = term->grid->rows[r]; + row->dirty = true; + + while (true) { + struct cell *cell = &row->cells[c]; + cell->attrs.url = value; + cell->attrs.clean = 0; + + if (r == end_r && c == end->col) + break; + + if (++c >= term->cols) { + r = (r + 1) & (term->grid->num_rows - 1); + c = 0; + + row = term->grid->rows[r]; + row->dirty = true; + } + } +} + +void +urls_render(struct terminal *term) +{ + struct wl_window *win = term->window; + struct wayland *wayl = term->wl; + + if (tll_length(win->term->urls) == 0) + return; + + xassert(tll_length(win->urls) == 0); + tll_foreach(win->term->urls, it) { + struct wl_surface *surf = wl_compositor_create_surface(wayl->compositor); + wl_surface_set_user_data(surf, win); + + struct wl_subsurface *sub_surf = NULL; + + if (surf != NULL) { + sub_surf = wl_subcompositor_get_subsurface( + wayl->sub_compositor, surf, win->surface); + + if (sub_surf != NULL) + wl_subsurface_set_sync(sub_surf); + } + + if (surf == NULL || sub_surf == NULL) { + LOG_WARN("failed to create URL (sub)-surface"); + + if (surf != NULL) { + wl_surface_destroy(surf); + surf = NULL; + } + + if (sub_surf != NULL) { + wl_subsurface_destroy(sub_surf); + sub_surf = NULL; + } + } + + struct wl_url url = { + .url = &it->item, + .surf = surf, + .sub_surf = sub_surf, + }; + + tll_push_back(win->urls, url); + tag_cells_for_url(term, &it->item, true); + } + + render_refresh_urls(term); + render_refresh(term); +} + +static void +url_destroy(struct url *url) +{ + free(url->url); + free(url->text); + free(url->key); +} + +void +urls_reset(struct terminal *term) +{ + if (likely(tll_length(term->urls) == 0)) + return; + + if (term->window != NULL) { + tll_foreach(term->window->urls, it) { + if (it->item.sub_surf != NULL) + wl_subsurface_destroy(it->item.sub_surf); + if (it->item.surf != NULL) + wl_surface_destroy(it->item.surf); + } + tll_free(term->window->urls); + } + + tll_foreach(term->urls, it) { + tag_cells_for_url(term, &it->item, false); + url_destroy(&it->item); + } + tll_free(term->urls); + + memset(term->url_keys, 0, sizeof(term->url_keys)); + render_refresh(term); +} diff --git a/url-mode.h b/url-mode.h new file mode 100644 index 00000000..12d20192 --- /dev/null +++ b/url-mode.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +#include "terminal.h" + +static inline bool urls_mode_is_active(const struct terminal *term) +{ + return tll_length(term->urls) > 0; +} + +void urls_collect( + const struct terminal *term, enum url_action action, url_list_t *urls); +void urls_assign_key_combos(url_list_t *urls); + +void urls_render(struct terminal *term); +void urls_reset(struct terminal *term); + +void urls_input(struct seat *seat, struct terminal *term, uint32_t key, + xkb_keysym_t sym, xkb_mod_mask_t mods, uint32_t serial); diff --git a/wayland.c b/wayland.c index d3c560b7..2a79bb10 100644 --- a/wayland.c +++ b/wayland.c @@ -148,6 +148,10 @@ seat_destroy(struct seat *seat) tll_free(it->item.bind.key_codes); tll_free(seat->kbd.bindings.search); + tll_foreach(seat->kbd.bindings.url, it) + tll_free(it->item.bind.key_codes); + tll_free(seat->kbd.bindings.url); + tll_free(seat->mouse.bindings); if (seat->kbd.xkb_compose_state != NULL) @@ -1422,6 +1426,14 @@ wayl_win_destroy(struct wl_window *win) tll_free(win->on_outputs); + tll_foreach(win->urls, it) { + if (it->item.sub_surf != NULL) + wl_subsurface_destroy(it->item.sub_surf); + if (it->item.surf != NULL) + wl_surface_destroy(it->item.surf); + } + tll_free(win->urls); + csd_destroy(win); if (win->render_timer_sub_surface != NULL) wl_subsurface_destroy(win->render_timer_sub_surface); diff --git a/wayland.h b/wayland.h index bcd8dcab..3c160b7c 100644 --- a/wayland.h +++ b/wayland.h @@ -49,6 +49,8 @@ enum bind_action_normal { BIND_ACTION_PIPE_SCROLLBACK, BIND_ACTION_PIPE_VIEW, BIND_ACTION_PIPE_SELECTED, + BIND_ACTION_SHOW_URLS_COPY, + BIND_ACTION_SHOW_URLS_LAUNCH, /* Mouse specific actions - i.e. they require a mouse coordinate */ BIND_ACTION_SELECT_BEGIN, @@ -59,7 +61,7 @@ enum bind_action_normal { BIND_ACTION_SELECT_WORD_WS, BIND_ACTION_SELECT_ROW, - BIND_ACTION_KEY_COUNT = BIND_ACTION_PIPE_SELECTED + 1, + BIND_ACTION_KEY_COUNT = BIND_ACTION_SHOW_URLS_LAUNCH + 1, BIND_ACTION_COUNT = BIND_ACTION_SELECT_ROW + 1, }; @@ -106,6 +108,17 @@ struct key_binding_search { enum bind_action_search action; }; +enum bind_action_url { + BIND_ACTION_URL_NONE, + BIND_ACTION_URL_CANCEL, + BIND_ACTION_URL_COUNT, +}; + +struct key_binding_url { + struct key_binding bind; + enum bind_action_url action; +}; + /* Mime-types we support when dealing with data offers (e.g. copy-paste, or DnD) */ enum data_offer_mime_type { DATA_OFFER_MIME_UNSET, @@ -192,6 +205,7 @@ struct seat { struct { tll(struct key_binding_normal) key; tll(struct key_binding_search) search; + tll(struct key_binding_url) url; } bindings; } kbd; @@ -349,6 +363,12 @@ struct monitor { bool use_output_release; }; +struct wl_url { + const struct url *url; + struct wl_surface *surf; + struct wl_subsurface *sub_surf; +}; + struct wayland; struct wl_window { struct terminal *term; @@ -380,6 +400,7 @@ struct wl_window { struct wl_callback *frame_callback; tll(const struct monitor *) on_outputs; /* Outputs we're mapped on */ + tll(struct wl_url) urls; bool is_configured; bool is_fullscreen;