Rework: background_image glob + change_background_image action + lazy loading

Reworked based on maintainer feedback:

- background_image now accepts glob patterns (e.g., ~/backgrounds/*.png)
  that resolve to a sorted list of image paths
- New change_background_image action: no arg = +1, +N/-N = delta with
  wraparound, plain N = absolute index clamped to list size
- Lazy loading: images loaded on first access, cached in VRAM after
- Failed image loads are silently removed from the list
- kitten @ set-background-image extended: number or +/-N arguments cycle
  through the background image list for all matching OS windows
- Per-window index tracking into shared image path list
This commit is contained in:
Omar Musayev 2026-04-09 11:31:39 -04:00
parent 019158c168
commit 99cc202aec
12 changed files with 414 additions and 4 deletions

View file

@ -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()

View file

@ -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

View file

@ -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 {

View file

@ -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.'
)

17
kitty/options/parse.py generated
View file

@ -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()

View file

@ -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

View file

@ -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),)

View file

@ -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()

View file

@ -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();

View file

@ -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;

View file

@ -0,0 +1,167 @@
// Code generated by go_code.py; DO NOT EDIT.
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
// 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 <search_syntax>`.\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 <at-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 <at-ls>` command to get a list of windows.",
})
return ans
}
func init() {
register_at_cmd(setup_set_background_image)
}

View file

@ -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