diff --git a/kitty/boss.py b/kitty/boss.py index 829832432..c3bcb828e 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -115,6 +115,8 @@ from .fast_data_types import ( send_data_to_peer, set_application_quit_request, set_background_image, + set_bg_image_paths, + change_bg_image, set_boss, set_options, set_os_window_chrome, @@ -1390,6 +1392,10 @@ class Boss: else: self.startup_first_child(first_os_window_id, startup_sessions=startup_sessions) + paths = getattr(get_options(), 'background_image_paths', ()) + if paths: + set_bg_image_paths(list(paths)) + if get_options().update_check_interval > 0 and not self.update_check_started and getattr(sys, 'frozen', False): from .update_check import run_update_check run_update_check(get_options().update_check_interval * 60 * 60) @@ -3139,6 +3145,9 @@ class Boss: set_background_image(opts.background_image, tuple(self.os_window_map), True, opts.background_image_layout) except Exception as e: log_error(f'Failed to set background image with error: {e}') + paths = getattr(opts, 'background_image_paths', ()) + if paths: + set_bg_image_paths(list(paths)) for tm in self.all_tab_managers: tm.apply_options() # Update colors @@ -3541,6 +3550,19 @@ class Boss: ) -> None: set_background_image(path, os_windows, configured, layout, png_data, linear_interpolation, tint, tint_gaps) + @ac('misc', 'Change the background image from the list of images matched by the :opt:`background_image` glob pattern') + def change_background_image(self, delta: str = '+1') -> None: + aw = self.active_window + if aw is None: + return + try: + if delta[0] in '+-': + change_bg_image(aw.os_window_id, int(delta), True) + else: + change_bg_image(aw.os_window_id, int(delta), False) + except (ValueError, IndexError): + log_error(f'Invalid argument to change_background_image: {delta}') + # Can be called with kitty -o "map f1 send_test_notification" def send_test_notification(self) -> None: self.notification_manager.send_test_notification() diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 4a598660c..f31d485e5 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -732,6 +732,14 @@ def set_background_image( pass +def set_bg_image_paths(paths: list[str]) -> None: + pass + + +def change_bg_image(os_window_id: int, value: int, is_delta: bool) -> bool: + pass + + def set_boss(boss: Boss) -> None: pass diff --git a/kitty/graphics.h b/kitty/graphics.h index 0d348abc1..27116659b 100644 --- a/kitty/graphics.h +++ b/kitty/graphics.h @@ -44,6 +44,12 @@ typedef struct { size_t mmap_size; } BackgroundImage; +typedef struct { + char **paths; + BackgroundImage **images; + unsigned int count; +} BackgroundImageList; + #ifdef GRAPHICS_INTERNAL_APIS typedef struct { diff --git a/kitty/options/definition.py b/kitty/options/definition.py index e2e46e988..351e3f562 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1957,7 +1957,9 @@ this option by reloading the config is not supported. opt('background_image', 'none', option_type='config_or_absolute_path', ctype='!background_image', - long_text='Path to a background image. Must be in PNG/JPEG/WEBP/TIFF/GIF/BMP format.' + long_text='Path to a background image or a glob pattern matching multiple images. Must be in PNG/JPEG/WEBP/TIFF/GIF/BMP format.' + ' When a glob pattern matches multiple images, they are sorted lexically and the first is used.' + ' Use the :ac:`change_background_image` action to cycle through them.' ' Note that when using :ref:`auto_color_scheme` this option is overridden by the color scheme file and must be set inside it to take effect.' ) diff --git a/kitty/options/parse.py b/kitty/options/parse.py index bdfe71f2f..f5b101a33 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -77,7 +77,22 @@ class Parser: ans['background_blur'] = int(val) def background_image(self, val: str, ans: dict[str, typing.Any]) -> None: - ans['background_image'] = config_or_absolute_path(val) + import glob as glob_mod + import os + path = config_or_absolute_path(val) + if path is None: + ans['background_image'] = None + ans['background_image_paths'] = () + return + matches = sorted(glob_mod.glob(path)) + image_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff'} + matches = [m for m in matches if os.path.isfile(m) and os.path.splitext(m)[1].lower() in image_exts] + if matches: + ans['background_image'] = matches[0] + ans['background_image_paths'] = tuple(matches) + else: + ans['background_image'] = path + ans['background_image_paths'] = (path,) if os.path.isfile(path) else () def background_image_layout(self, val: str, ans: dict[str, typing.Any]) -> None: val = val.lower() diff --git a/kitty/options/types.py b/kitty/options/types.py index 5faa60c2c..736d97cc7 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -61,6 +61,7 @@ option_names = ( 'background_opacity', 'background_tint', 'background_tint_gaps', + 'background_image_paths', 'bell_border_color', 'bell_on_tab', 'bell_path', @@ -534,6 +535,7 @@ class Options: background_opacity: float = 1.0 background_tint: float = 0 background_tint_gaps: float = 1.0 + background_image_paths: tuple[str, ...] = () bell_border_color: Color = Color(255, 90, 0) bell_on_tab: str = '🔔 ' bell_path: str | None = None diff --git a/kitty/options/utils.py b/kitty/options/utils.py index e72b5259b..6348060c2 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -177,6 +177,11 @@ def simple_parse(func: str, rest: str) -> FuncArgsType: return func, (rest,) +@func_with_args('change_background_image') +def parse_change_background_image(func: str, rest: str) -> FuncArgsType: + return func, (rest.strip() if rest.strip() else '+1',) + + @func_with_args('set_font_size') def float_parse(func: str, rest: str) -> FuncArgsType: return func, (float(rest),) diff --git a/kitty/rc/set_background_image.py b/kitty/rc/set_background_image.py index d09e2088d..3e8f59c24 100644 --- a/kitty/rc/set_background_image.py +++ b/kitty/rc/set_background_image.py @@ -79,8 +79,9 @@ failed, the command will exit with a success code. def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: if len(args) != 1: - self.fatal('Must specify path to exactly one PNG image') + self.fatal('Must specify path to exactly one PNG image, or an index (+N, -N, N)') path = os.path.expanduser(args[0]) + import re import secrets ret = { 'match': opts.match, @@ -89,6 +90,10 @@ failed, the command will exit with a success code. 'all': opts.all, 'stream_id': secrets.token_urlsafe(), } + # Handle index arguments (+N, -N, or N) + if re.match(r'^[+-]?\d+$', path): + ret['data'] = f'index:{path}' + return ret if path.lower() == 'none': ret['data'] = '-' return ret @@ -112,6 +117,14 @@ failed, the command will exit with a success code. windows = self.windows_for_payload(boss, window, payload_get, window_match_name='match') os_windows = tuple({w.os_window_id for w in windows if w}) layout = payload_get('layout') + if isinstance(data, str) and data.startswith('index:'): + from kitty.fast_data_types import change_bg_image + index_str = data[6:] + is_delta = index_str[0] in '+-' + value = int(index_str) + for osw_id in os_windows: + change_bg_image(osw_id, value, is_delta) + return None if data == '-': path = None tfile = BytesIO() diff --git a/kitty/state.c b/kitty/state.c index e065fb9ec..8ed5cdde8 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -1314,6 +1314,8 @@ PYWRAP1(update_tab_bar_edge_colors) { Py_RETURN_FALSE; } +static uint32_t bgimage_id_counter = 0; + static PyObject* pyset_background_image(PyObject *self UNUSED, PyObject *args, PyObject *kw) { const char *path; @@ -1340,7 +1342,6 @@ pyset_background_image(PyObject *self UNUSED, PyObject *args, PyObject *kw) { free(bgimage); return NULL; } - static uint32_t bgimage_id_counter = 0; bgimage->id = ++bgimage_id_counter; send_bgimage_to_gpu(layout, bgimage); bgimage->refcnt++; @@ -1368,6 +1369,143 @@ pyset_background_image(PyObject *self UNUSED, PyObject *args, PyObject *kw) { Py_RETURN_NONE; } +static void +free_bg_image_list(bool release_textures) { + BackgroundImageList *list = &global_state.bg_image_list; + for (unsigned i = 0; i < list->count; i++) { + if (list->paths[i]) free(list->paths[i]); + if (list->images[i]) { + list->images[i]->refcnt = 1; + free_bgimage(&list->images[i], release_textures); + } + } + free(list->paths); list->paths = NULL; + free(list->images); list->images = NULL; + list->count = 0; +} + +static PyObject* +pyset_bg_image_paths(PyObject *self UNUSED, PyObject *args) { + PyObject *paths_list; + if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &paths_list)) return NULL; + + free_bg_image_list(true); + + Py_ssize_t count = PyList_GET_SIZE(paths_list); + if (count == 0) Py_RETURN_NONE; + + BackgroundImageList *list = &global_state.bg_image_list; + list->paths = calloc(count, sizeof(char*)); + list->images = calloc(count, sizeof(BackgroundImage*)); + if (!list->paths || !list->images) return PyErr_NoMemory(); + + for (Py_ssize_t i = 0; i < count; i++) { + const char *path = PyUnicode_AsUTF8(PyList_GET_ITEM(paths_list, i)); + if (!path) continue; + list->paths[i] = strdup(path); + list->images[i] = NULL; // lazy loaded + } + list->count = (unsigned int)count; + + // Sync images[0] with the already-loaded global bgimage + if (global_state.bgimage && global_state.bgimage->texture_id) { + list->images[0] = global_state.bgimage; + global_state.bgimage->refcnt++; + } + + Py_RETURN_NONE; +} + +static BackgroundImage* +load_bg_image_at_index(unsigned int idx) { + BackgroundImageList *list = &global_state.bg_image_list; + if (idx >= list->count) return NULL; + + BackgroundImage *bgimage = calloc(1, sizeof(BackgroundImage)); + if (!bgimage) return NULL; + + if (!image_path_to_bitmap(list->paths[idx], &bgimage->bitmap, &bgimage->width, &bgimage->height, &bgimage->mmap_size)) { + free(bgimage); + // Remove failed entry from list + free(list->paths[idx]); + for (unsigned i = idx; i < list->count - 1; i++) { + list->paths[i] = list->paths[i + 1]; + list->images[i] = list->images[i + 1]; + } + list->count--; + return NULL; // caller should retry with same index + } + + bgimage->id = ++bgimage_id_counter; + send_bgimage_to_gpu(OPT(background_image_layout), bgimage); + bgimage->refcnt = 1; // owned by list + list->images[idx] = bgimage; + return bgimage; +} + +static PyObject* +pychange_bg_image(PyObject *self UNUSED, PyObject *args) { + id_type os_window_id; + int value; + int is_delta; + if (!PyArg_ParseTuple(args, "Kip", &os_window_id, &value, &is_delta)) return NULL; + + BackgroundImageList *list = &global_state.bg_image_list; + if (list->count == 0) Py_RETURN_FALSE; + + bool success = false; + WITH_OS_WINDOW(os_window_id) + make_os_window_context_current(os_window); + + int new_idx; + if (is_delta) { + int count = (int)list->count; + new_idx = ((os_window->bg_image_idx + value) % count + count) % count; + } else { + new_idx = value; + if (new_idx < 0) new_idx = 0; + if (new_idx >= (int)list->count) new_idx = (int)list->count - 1; + } + + // Lazy load with retry on failure + int attempts = 0; + while (list->count > 0 && attempts < (int)list->count) { + if (new_idx >= (int)list->count) new_idx = 0; + if (list->images[new_idx]) break; + if (load_bg_image_at_index((unsigned int)new_idx)) break; + attempts++; + } + + if (list->count > 0 && new_idx < (int)list->count && list->images[new_idx]) { + os_window->bg_image_idx = new_idx; + + // Release old bgimage + if (os_window->bgimage) { + os_window->bgimage->refcnt--; + if (os_window->bgimage->refcnt == 0) { + bool in_list = false; + for (unsigned i = 0; i < list->count; i++) { + if (list->images[i] == os_window->bgimage) { in_list = true; break; } + } + if (!in_list) { + free_bgimage_bitmap(os_window->bgimage); + free_texture(&os_window->bgimage->texture_id); + free(os_window->bgimage); + } + } + } + + os_window->bgimage = list->images[new_idx]; + os_window->bgimage->refcnt++; + os_window->render_calls = 0; + success = true; + } + END_WITH_OS_WINDOW + + if (success) Py_RETURN_TRUE; + Py_RETURN_FALSE; +} + PYWRAP0(destroy_global_data) { Py_CLEAR(global_state.boss); free(global_state.os_windows); global_state.os_windows = NULL; @@ -1676,6 +1814,8 @@ static PyMethodDef module_methods[] = { MW(set_os_window_pos, METH_VARARGS), MW(global_font_size, METH_VARARGS), {"set_background_image", (PyCFunction)(void (*) (void))pyset_background_image, METH_VARARGS | METH_KEYWORDS, ""}, + {"set_bg_image_paths", (PyCFunction)pyset_bg_image_paths, METH_VARARGS, ""}, + {"change_bg_image", (PyCFunction)pychange_bg_image, METH_VARARGS, ""}, MW(os_window_font_size, METH_VARARGS), MW(set_os_window_size, METH_VARARGS), MW(get_os_window_size, METH_VARARGS), @@ -1710,6 +1850,7 @@ finalize(void) { // the GPU driver should take care of it when the OpenGL context is // destroyed. free_bgimage(&global_state.bgimage, false); + free_bg_image_list(false); free_window_logo_table(&global_state.all_window_logos); global_state.bgimage = NULL; free_drag_source(); diff --git a/kitty/state.h b/kitty/state.h index 8e737f1c4..2e036840a 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -394,6 +394,7 @@ typedef struct OSWindow { double viewport_x_ratio, viewport_y_ratio; Tab *tabs; BackgroundImage *bgimage; + int bg_image_idx; struct { uint32_t framebuffer_id, attached_texture_generation; } indirect_output; @@ -445,6 +446,7 @@ typedef struct GlobalState { id_type os_window_id_counter, tab_id_counter, window_id_counter; PyObject *boss; BackgroundImage *bgimage; + BackgroundImageList bg_image_list; OSWindow *os_windows; size_t num_os_windows, capacity; OSWindow *callback_os_window; diff --git a/tools/cmd/at/cmd_set_background_image_generated.go b/tools/cmd/at/cmd_set_background_image_generated.go new file mode 100644 index 000000000..07eb37908 --- /dev/null +++ b/tools/cmd/at/cmd_set_background_image_generated.go @@ -0,0 +1,167 @@ +// Code generated by go_code.py; DO NOT EDIT. + + + + +// License: GPLv3 Copyright: 2022, Kovid Goyal, + +// Code generated by gen-go-code.py; DO NOT EDIT. + +package at + +import ( + "fmt" + "strings" + "time" + + "github.com/kovidgoyal/kitty/tools/cli" + "github.com/kovidgoyal/kitty/tools/utils" +) + +var _ = fmt.Print +var _ = strings.Join + +type options_set_background_image_type struct { + All bool +Configured bool +Layout string +Match string +} + +var options_set_background_image options_set_background_image_type + +type set_background_image_json_type struct { + Data escaped_string`json:"data,omitempty"` +Match escaped_string`json:"match,omitempty"` +Layout string`json:"layout,omitempty"` +All bool`json:"all,omitempty"` +Configured bool`json:"configured,omitempty"` +} + +func create_payload_set_background_image(io_data *rc_io_data, cmd *cli.Command, args []string) (err error) { + payload := set_background_image_json_type{} + if len(args) != 1 { return fmt.Errorf("%s", "Must specify exactly 1 argument(s) for set_background_image") } +io_data.multiple_payload_generator, err = read_window_logo(io_data, args[0]) +if err != nil { return err } +payload.Match = escaped_string(options_set_background_image.Match) +payload.Layout = options_set_background_image.Layout +payload.All = options_set_background_image.All +payload.Configured = options_set_background_image.Configured + io_data.rc.Payload = payload + return +} + +func create_rc_set_background_image(args []string) (*utils.RemoteControlCmd, error) { + rc := utils.RemoteControlCmd{ + Cmd: "set-background-image", + Version: ProtocolVersion, + NoResponse: false, + Stream: true, + } + if rc.Stream { + stream_id, err := utils.HumanRandomId(128) + if err != nil { + return nil, err + } + rc.StreamId = stream_id + } + if false { + async_id, err := utils.HumanRandomId(128) + if err != nil { + return nil, err + } + rc.Async = async_id + } + return &rc, nil +} + +func run_set_background_image(cmd *cli.Command, args []string) (return_code int, err error) { + err = cmd.GetOptionValues(&options_set_background_image) + if err != nil { + return + } + + rc, err := create_rc_set_background_image(args) + if err != nil { + return + } + nrv, err := cli.GetOptionValue[bool](cmd, "NoResponse") + if err == nil { + rc.NoResponse = nrv + } + var timeout float64 = 10.0 + rt, err := cli.GetOptionValue[float64](cmd, "ResponseTimeout") + if err == nil { + timeout = rt + } + io_data := rc_io_data{ + cmd: cmd, + rc: rc, + timeout: time.Duration(timeout * float64(time.Second)), + string_response_is_err: false, + } + err = create_payload_set_background_image(&io_data, cmd, args) + if err != nil { + return + } + + err = send_rc_command(&io_data) + if ee, ok := err.(*exit_error); ok && !running_shell { + return ee.exit_code, nil + } + return +} + +func setup_set_background_image(parent *cli.Command) *cli.Command { + ans := parent.AddSubCommand(&cli.Command{ + Name: "set-background-image", + Usage: " PATH_TO_PNG_IMAGE", + ShortDescription: "Set the background image", + HelpText: "Set the background image for the specified OS windows. You must specify the path to an image that will be used as the background. If you specify the special value :code:`none` then any existing image will be removed. Supported image formats are: PNG, JPEG, WEBP, GIF, BMP, TIFF", + Run: run_set_background_image, + }) + ans.StopCompletingAtArg = 1 +ans.ArgCompleter = cli.FnmatchCompleter("Images", cli.CWD, "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif", "*.bmp", "*.tiff") +ans.Add(cli.OptionSpec{ + Name: "--all -a", + Type: "bool-set", + Dest: "All", + Help: "By default, background image is only changed for the currently active OS window. This option will cause the image to be changed in all windows.", + }) +ans.Add(cli.OptionSpec{ + Name: "--configured -c", + Type: "bool-set", + Dest: "Configured", + Help: "Change the configured background image which is used for new OS windows.", + }) +ans.Add(cli.OptionSpec{ + Name: "--layout", + Type: "choices", + Dest: "Layout", + Help: "How the image should be displayed. A value of :code:`configured` will use the configured value.", + +Choices: "configured, centered, clamped, cscaled, mirror-tiled, scaled, tiled", + +Completer: cli.NamesCompleter("Choices for layout", "configured", "centered", "clamped", "cscaled", "mirror-tiled", "scaled", "tiled"), + Default: "configured", +}) +ans.Add(cli.OptionSpec{ + Name: "--no-response", + Type: "bool-set", + Dest: "NoResponse", + Help: "Don't wait for a response from kitty. This means that even if setting the background image failed, the command will exit with a success code.", + + Default: "false", +}) +ans.Add(cli.OptionSpec{ + Name: "--match -m", + Type: "", + Dest: "Match", + Help: "The window to match. Match specifications are of the form: :italic:`field:query`. Where :italic:`field` can be one of: :code:`id`, :code:`title`, :code:`pid`, :code:`cwd`, :code:`cmdline`, :code:`num`, :code:`env`, :code:`var`, :code:`state`, :code:`neighbor`, :code:`session` and :code:`recent`. :italic:`query` is the expression to match. Expressions can be either a number or a regular expression, and can be :ref:`combined using Boolean operators `.\n\nThe special value :code:`all` matches all windows.\n\nFor numeric fields: :code:`id`, :code:`pid`, :code:`num` and :code:`recent`, the expression is interpreted as a number, not a regular expression. Negative values for :code:`id` match from the highest id number down, in particular, -1 is the most recently created window.\n\nThe field :code:`num` refers to the window position in the current tab, starting from zero and counting clockwise (this is the same as the order in which the windows are reported by the :ref:`kitten @ ls ` command).\n\nThe window id of the current window is available as the :envvar:`KITTY_WINDOW_ID` environment variable.\n\nThe field :code:`recent` refers to recently active windows in the currently active tab, with zero being the currently active window, one being the previously active window and so on.\n\nThe field :code:`neighbor` refers to a neighbor of the active window in the specified direction, which can be: :code:`left`, :code:`right`, :code:`top` or :code:`bottom`.\n\nThe field :code:`session` matches windows that were created in the specified session. Use the expression :code:`^$` to match windows that were not created in a session and :code:`.` to match the currently active session and :code:`~` to match either the currently active session or the last active session when no session is active.\n\nWhen using the :code:`env` field to match on environment variables, you can specify only the environment variable name or a name and value, for example, :code:`env:MY_ENV_VAR=2`.\n\nSimilarly, the :code:`var` field matches on user variables set on the window. You can specify name or name and value as with the :code:`env` field.\n\nThe field :code:`state` matches on the state of the window. Supported states are: :code:`active`, :code:`focused`, :code:`needs_attention`, :code:`parent_active`, :code:`parent_focused`, :code:`focused_os_window`, :code:`self`, :code:`overlay_parent`. Active windows are the windows that are active in their parent tab. There is only one focused window and it is the window to which keyboard events are delivered. If no window is focused, the last focused window is matched. The value :code:`focused_os_window` matches all windows in the currently focused OS window. The value :code:`self` matches the window in which the remote control command is run. The value :code:`overlay_parent` matches the window that is under the :code:`self` window, when the self window is an overlay.\n\nNote that you can use the :ref:`kitten @ ls ` command to get a list of windows.", + }) + return ans +} + +func init() { + register_at_cmd(setup_set_background_image) +} diff --git a/tools/cmd/at/set_window_logo.go b/tools/cmd/at/set_window_logo.go index 59066fc91..f920b0991 100644 --- a/tools/cmd/at/set_window_logo.go +++ b/tools/cmd/at/set_window_logo.go @@ -18,6 +18,25 @@ func set_payload_data(io_data *rc_io_data, data string) { set_payload_string_field(io_data, "Data", data) } +func isIndexArg(s string) bool { + if len(s) == 0 { + return false + } + start := 0 + if s[0] == '+' || s[0] == '-' { + start = 1 + } + if start >= len(s) { + return false + } + for _, c := range s[start:] { + if c < '0' || c > '9' { + return false + } + } + return true +} + func read_window_logo(io_data *rc_io_data, path string) (func(io_data *rc_io_data) (bool, error), error) { if strings.ToLower(path) == "none" { io_data.rc.Stream = false @@ -27,6 +46,14 @@ func read_window_logo(io_data *rc_io_data, path string) (func(io_data *rc_io_dat }, nil } + if isIndexArg(path) { + io_data.rc.Stream = false + return func(io_data *rc_io_data) (bool, error) { + set_payload_data(io_data, "index:"+path) + return true, nil + }, nil + } + f, err := os.Open(path) if err != nil { return nil, err