Compare commits

...

72 Commits
v0.43.1 ... gr

Author SHA1 Message Date
Kovid Goyal
436ab9a95a Add a convenient entry point to test and work on the image serialization code 2025-10-09 19:26:05 +05:30
Kovid Goyal
35093d2105 Fix frame origins not be de-serialized 2025-10-09 19:22:22 +05:30
Kovid Goyal
9212c08638 Faster conversion of plaette to NRGB for opaque colors 2025-10-09 16:57:28 +05:30
Kovid Goyal
2dea3087b3 Faster is_opaque implementation for paletted images 2025-10-09 16:52:13 +05:30
Kovid Goyal
c48ed15007 ... 2025-10-09 16:12:39 +05:30
Kovid Goyal
a0f6152aee Ensure num_cols is never zero 2025-10-09 16:05:13 +05:30
Kovid Goyal
ef8079eb27 Clear python error when using read_from_disk_cache_simple 2025-10-09 15:45:03 +05:30
Kovid Goyal
ed33e64101 Ensure placement is transmitted after data 2025-10-09 15:35:57 +05:30
Kovid Goyal
167b254d97 DRYer 2025-10-09 15:23:52 +05:30
Kovid Goyal
dc90771780 Add a small top margin above the image preview 2025-10-09 15:16:17 +05:30
Kovid Goyal
4cdedc422e Add modified date to image preview 2025-10-09 15:13:45 +05:30
Kovid Goyal
127459012a Fix incorrect alpha values returned by NRGB color model 2025-10-09 11:39:39 +05:30
Kovid Goyal
298daa4e83 Utility method to save frame as uncompressed PNG 2025-10-09 10:56:33 +05:30
Kovid Goyal
9f2b22c4d6 Forgot to include metadata key in resized cache 2025-10-09 10:44:41 +05:30
Kovid Goyal
a2255e979f Only retransmit placements when actually needed 2025-10-09 10:12:32 +05:30
Kovid Goyal
9be66bfe4a When rendering with Go image libraries fails fallback to ImageMagick 2025-10-09 09:40:46 +05:30
Kovid Goyal
2ac2c17929 Fix transmission by file 2025-10-09 09:35:06 +05:30
Kovid Goyal
c6582e9f51 ... 2025-10-09 09:25:47 +05:30
Kovid Goyal
4ec94c786d Fix previews being right aligned 2025-10-09 09:03:09 +05:30
Kovid Goyal
ceb712f791 DRYer 2025-10-09 09:00:37 +05:30
Kovid Goyal
aecf13302a Fix off-by-one in NRGB 2025-10-09 08:30:20 +05:30
Kovid Goyal
d598157590 Make goroutines in choose_files panic safe 2025-10-09 08:11:47 +05:30
Kovid Goyal
e61e95da3a ... 2025-10-09 08:04:58 +05:30
Kovid Goyal
37bd77f4a8 Bump version of imaging
imaging is now panic safe. Also format nested panics a bit better.
2025-10-09 08:00:31 +05:30
Kovid Goyal
f067e9cd92 Make various goroutines panic-safe 2025-10-09 07:17:53 +05:30
Kovid Goyal
49d8b1a9d0 More work on image preview rendering 2025-10-08 22:00:12 +05:30
Kovid Goyal
811b4fa127 Fix #9083 2025-10-08 10:02:51 +05:30
Kovid Goyal
c2e75ba466 Fix disk cache not reading inode
Also use a faster atomic update mechanism
2025-10-08 08:35:35 +05:30
Kovid Goyal
16cdcf8cf8 Use inode number and size for more robust entries change tracking 2025-10-08 07:28:02 +05:30
Kovid Goyal
a6335777d9 disk cache: add a path based API
This allows maintaining only a single cache entry per path when the
path's contents change.
2025-10-08 06:41:12 +05:30
Kovid Goyal
3d5200e1ce On second thoughts dont inject PATH as it is already handled by which() and exe_search_paths 2025-10-08 05:26:27 +05:30
Kovid Goyal
67ca9f5b7d Rename read_from_login_shell -> read_from_shell 2025-10-08 05:21:48 +05:30
Kovid Goyal
6879432704 When using read_from_login_shell inject PATH into os.environ
This allows kitty to use that PATH to launch child executables
2025-10-08 05:19:47 +05:30
Kovid Goyal
726c693edf Avoid reading shell env twice to get editor
Its cached, but still...
2025-10-07 22:27:57 +05:30
Kovid Goyal
a9f80fe05b Allow easily injecting env vars from the login shell config into the env in which kitty runs child processes
Fixes #9042
2025-10-07 22:23:31 +05:30
Kovid Goyal
fcccadc8f3 Make reading resolved shell env more robust
We pass -0 to env so that it works even for env vars that have newlines
in them.
2025-10-07 21:44:01 +05:30
Kovid Goyal
8d0fc86bb6 Update some docs 2025-10-07 20:20:19 +05:30
Kovid Goyal
2babfa7ebf macOS: Further restrict the live resize callback to only be used when live resize is actually in progress 2025-10-07 18:23:08 +05:30
Kovid Goyal
a76f32df2d Code to serialize/unserialize loaded images 2025-10-07 17:25:47 +05:30
Kovid Goyal
8f91fcefbe Update changelog 2025-10-07 14:38:58 +05:30
Kovid Goyal
fa808c3b10 Fix tab bar sometimes showing incorrect tabs when it is filtered to show only tabs from the current session
Fixes #9079
2025-10-07 14:35:13 +05:30
Kovid Goyal
9f9216457e Only showing metadata needs to be in the interface 2025-10-07 14:12:40 +05:30
Kovid Goyal
f0040edff2 More work on image previews 2025-10-07 13:30:28 +05:30
Kovid Goyal
1f37f065ab Improve API of disk cache
Allow getting all keys and also return get result for added keys
2025-10-07 13:01:09 +05:30
Kovid Goyal
4af95b3c51 choose_files: start work on image previews 2025-10-07 11:11:42 +05:30
Kovid Goyal
224ccb170a Micro optimisation 2025-10-07 09:23:22 +05:30
Kovid Goyal
328745cad9 removing a non-existent item from disk cache is not an error 2025-10-07 09:16:30 +05:30
Kovid Goyal
5d1ce327e0 Ensure adds to disk cache are atomic 2025-10-07 09:14:14 +05:30
Kovid Goyal
e8cfedee07 Log a warning when falling back to 8bit textures 2025-10-07 09:04:46 +05:30
Kovid Goyal
d3c5cb12c4 macOS: Dont do live resizing when window is fullscreen
The live resize causes crashes on some Tahoe machines due to macOS bugs.
It is not needed anyway when the window is fullscreen, so ignore it.
2025-10-07 08:57:57 +05:30
Kovid Goyal
25e1b052b8 Merge branch 'fix-unicode-input' of https://github.com/alex-huff/kitty 2025-10-07 08:05:16 +05:30
alex-huff
86698e0b17 unicode-input: fix race condition causing incorrect results 2025-10-06 19:14:06 -05:00
Kovid Goyal
77074d627d ... 2025-10-06 21:36:36 +05:30
Kovid Goyal
e9fc486473 Fix #9075 2025-10-06 21:04:30 +05:30
Kovid Goyal
a0699f5c9e Remove the dropping of the first resize event since it did not fix the issue
Add a check to only callback if the thread is the main thread
2025-10-06 18:39:53 +05:30
Kovid Goyal
88ec2d9793 Add a more visible note that the payloads for remote control commands are documented in the protocol docs 2025-10-06 10:42:11 +05:30
Kovid Goyal
5af47b4881 ... 2025-10-06 10:37:51 +05:30
Kovid Goyal
8d855a7eb4 Remove the docs on using kitty config infrastructure in custom kittens
That config infrastructure isnt really maintained anymore since
builtin kittens have now been almost all ported to Go. So in future
people should just use any of python's stdlib config modules such as
tomllib to store and retrieve their kitten configs.
2025-10-06 10:19:27 +05:30
Kovid Goyal
e46a75ca57 Fix rendering broken on ancient GPU drivers that dont support rendering to 16 bit textures
Fixes #9068
2025-10-06 08:54:53 +05:30
Kovid Goyal
fdf2c0725c Help the dispatcher 2025-10-05 22:18:53 +05:30
Kovid Goyal
da39257020 Use the fact that GLSL supports multiple dispatch based on argument types 2025-10-05 22:14:01 +05:30
Kovid Goyal
e21d2f5191 Bump bundled OpenSSL for CVE 2025-10-05 21:59:02 +05:30
Kovid Goyal
aa814748a1 Use uints for partial workaround for #9072 2025-10-05 21:56:22 +05:30
Kovid Goyal
4545aab5f6 Link directly to diff/main.py in the docs to avoid confusion with Go code 2025-10-05 21:34:07 +05:30
Kovid Goyal
9192f35132 Fix #9070 2025-10-04 08:45:45 +05:30
Kovid Goyal
270c598f2c macOS: Only live resize for resize events that occur in quick succession
Apparently on some systems Tahoe sends a resize event on wake from
sleep/lid open for obscure reasons and then proceeds to crash if one
redraws during that event. Sigh.
2025-10-02 19:39:13 +05:30
Kovid Goyal
2665a871c0 Fix a regression in the previous release that broke goto_session -1 2025-10-02 18:52:31 +05:30
Kovid Goyal
ccdc50007e Fix a regression in 0.43.0 that caused a black flicker when closing a tab in the presence of a background image
Fixes #9060
2025-10-02 14:48:37 +05:30
Kovid Goyal
9740861ec5 Splits layout: Fix corrupted layout in some circs
Basically one function was adding a window id instead of a group id
to the pairs. Fixes #9059
2025-10-02 10:43:36 +05:30
Kovid Goyal
80a617a9ec ... 2025-10-02 10:06:07 +05:30
Kovid Goyal
56f26ed919 Fix interaction of focus and focus_matching_spec in session files
The last specified one wins. If nether are specified, first window is
focused.
2025-10-02 09:46:23 +05:30
Kovid Goyal
9a4b52f8b9 diff kitten: Fix wheel_scroll_multiplier not being respected
Fixes #9054
2025-10-01 17:07:47 +05:30
58 changed files with 1653 additions and 403 deletions

View File

@@ -1,5 +1,7 @@
= kitty - the fast, feature-rich, cross-platform, GPU based terminal
If you live in the terminal, *kitty* is made for **you**!
See https://sw.kovidgoyal.net/kitty/[the kitty website].
image:https://github.com/kovidgoyal/kitty/workflows/CI/badge.svg["Build status", link="https://github.com/kovidgoyal/kitty/actions?query=workflow%3ACI"]

View File

@@ -30,10 +30,10 @@
},
{
"name": "openssl 3.5.2",
"name": "openssl 3.5.4",
"unix": {
"file_extension": "tar.gz",
"hash": "sha256:c53a47e5e441c930c3928cf7bf6fb00e5d129b630e0aa873b08258656e7345ec",
"hash": "sha256:967311f84955316969bdb1d8d4b983718ef42338639c621ec4c34fddef355e99",
"urls": ["https://www.openssl.org/source/{filename}"]
}
},

View File

@@ -134,6 +134,29 @@ consumption to do the same tasks.
Detailed list of changes
-------------------------------------
0.43.2 [future]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Allow kitty to read a specified set of environment variables from your
login shell at startup using the :opt:`env` directive in kitty.conf
(:iss:`9042`)
- Fix a regression in 0.43.0 that caused a black flicker when closing a tab in
the presence of a background image (:iss:`9060`)
- Splits layout: Fix a bug that could cause a corrupted layout in some
circumstances (:iss:`9059`)
- Fix a regression in the previous release that broke ``goto_session -1``
- Fix rendering broken on ancient GPU drivers that do not support rendering to 16 bit textures (:iss:`9068`)
- Fix tab bar sometimes showing incorrect tabs when it is filtered to show only
tabs from the current session (:iss:`9079`)
- macOS: Workaround for bug in macOS Tahoe that caused OS Windows that are
fullscreen to crash kitty when returning from sleep on some machines (:iss:`8983`)
0.43.1 [2025-10-01]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -480,6 +480,16 @@ related to localization, such as :envvar:`LANG`, ``LC_*`` and loading of
configuration files such as ``XDG_*``, :envvar:`KITTY_CONFIG_DIRECTORY` and,
most importantly, ``PATH`` to locate binaries.
The simplest way to fix this is to have kitty load the environment variables
from your shell configuration at startup using the :opt:`env` directive,
adding the following to :file:`kitty.conf`::
env read_from_shell=PATH LANG LC_* XDG_* EDITOR VISUAL
This works for POSIX compliant shells and the fish shell. Note that it
does add significantly to kitty startup time, so use only if really necessary.
This feature was added in version ``0.43.2``.
To see the environment variables that kitty sees, you can add the following
mapping to :file:`kitty.conf`::

View File

@@ -1,7 +1,9 @@
kitty
==========================================================
*The fast, feature-rich, GPU based terminal emulator*
*If you live in the terminal, kitty is made for YOU!*
The fast, feature-rich, GPU based terminal emulator.
.. toctree::
:hidden:
@@ -20,7 +22,7 @@ kitty
.. tab:: Fast
* Uses GPU and SIMD vector CPU instructions for :doc:`best in class <performance>`
* Uses GPU and SIMD vector CPU instructions for :doc:`best in class performance <performance>`
* Uses threaded rendering for :iss:`absolutely minimal latency <2701#issuecomment-636497270>`
* Performance tradeoffs can be :ref:`tuned <conf-kitty-performance>`

View File

@@ -122,9 +122,9 @@ the table below:
.. table:: Types of input to kittens
:align: left
=========================== =======================================================================================================
=========================== ========================================================================================
Keyword Type of :file:`STDIN` input
=========================== =======================================================================================================
=========================== ========================================================================================
``text`` Plain text of active window
``ansi`` Formatted text of active window
``screen`` Plain text of active window with line wrap markers
@@ -141,7 +141,7 @@ the table below:
``output-screen-ansi`` Formatted text of the output from the last run command with wrap markers
``selection`` The text currently selected with the mouse
=========================== =======================================================================================================
=========================== ========================================================================================
In addition to ``output``, that gets the output of the last run command,
``last_visited_output`` gives the output of the command last jumped to
@@ -298,107 +298,6 @@ So if you run kitty from another kitty instance, the output will be visible
in the first kitty instance.
Adding options to kittens
----------------------------
If you would like to use kitty's config framework to make your kittens
configurable, you will need some boilerplate. Put the following files in the
directory of your kitten.
:file:`kitten_options_definition.py`
.. code-block:: python
from kitty.conf.types import Action, Definition
definition = Definition(
'!kitten_options_utils',
Action(
'map', 'parse_map',
{'key_definitions': 'kitty.conf.utils.KittensKeyMap'},
['kitty.types.ParsedShortcut', 'kitty.conf.utils.KeyAction']
),
)
agr = definition.add_group
egr = definition.end_group
opt = definition.add_option
map = definition.add_map
# main options {{{
agr('main', 'Main')
opt('some_option', '33',
option_type='some_option_parser',
long_text='''
Help text for this option
'''
)
egr() # }}}
# shortcuts {{{
agr('shortcuts', 'Keyboard shortcuts')
map('Quit', 'quit q quit')
egr() # }}}
:file:`kitten_options_utils.py`
.. code-block:: python
from kitty.conf.utils import KittensKeyDefinition, key_func, parse_kittens_key
func_with_args, args_funcs = key_func()
FuncArgsType = Tuple[str, Sequence[Any]]
def some_option_parser(val: str) -> int:
return int(val) + 3000
def parse_map(val: str) -> Iterable[KittensKeyDefinition]:
x = parse_kittens_key(val, args_funcs)
if x is not None:
yield x
Then run::
kitty +runpy 'from kitty.conf.generate import main; main()' /path/to/kitten_options_definition.py
You can parse and read the options in your kitten using the following code:
.. code-block:: python
from .kitten_options_types import Options, defaults
from kitty.conf.utils import load_config as _load_config, parse_config_base
from typing import Optional, Iterable, Dict, Any
def load_config(*paths: str, overrides: Optional[Iterable[str]] = None) -> Options:
from .kitten_options_parse import (
create_result_dict, merge_result_dicts, parse_conf_item
)
def parse_config(lines: Iterable[str]) -> Dict[str, Any]:
ans: Dict[str, Any] = create_result_dict()
parse_config_base(
lines,
parse_conf_item,
ans,
)
return ans
overrides = tuple(overrides) if overrides is not None else ()
opts_dict, found_paths = _load_config(defaults, parse_config, merge_result_dicts, *paths, overrides=overrides)
opts = Options(opts_dict)
opts.config_paths = found_paths
opts.all_config_paths = paths
opts.config_overrides = overrides
return opts
See `the code <https://github.com/kovidgoyal/kitty/tree/master/kittens/diff>`__
for the builtin :doc:`diff kitten </kittens/diff>` for examples of creating more
options and keyboard shortcuts.
Developing builtin kittens for inclusion with kitty
----------------------------------------------------------

View File

@@ -230,6 +230,15 @@ copying to clipboard. In the editor, you can map it to copy to the clipboard,
thereby allowing use of a common shortcut both inside and outside the editor
for copying to clipboard.
.. note::
When using multi-key mappings, of the form :kbd:`k1>k2` or similar, the
condition applies to the first key and you can have only one condition per
key, the last in kitty.conf wins. In particular, this means you cannot have
multiple conditions applying to multi-key mappings with the same first key
and you cannot have mappings with and without conditions applying to multi-keys
with the same first key.
Sending arbitrary text or keys to the program running in kitty
--------------------------------------------------------------------------------

View File

@@ -262,6 +262,12 @@ as shown below:
return True
.. note::
The payloads for the different remote control commands are documented in the
:doc:`remote control protocol specification <rc_protocol>`.
.. _rc_mapping:
Mapping key presses to remote control commands

View File

@@ -8,6 +8,9 @@ import (
var _ = fmt.Print
//go:embed logo/kitty.png
var KittyLogoAsPNGData []byte
//go:embed kitty_tests/GraphemeBreakTest.json
var grapheme_break_test_data []byte

View File

@@ -661,7 +661,7 @@ var QueryNames = []string{{ {query_names} }}
var CommentedOutDefaultConfig = "{serialize_as_go_string(commented_out_default_config())}"
var KittyConfigDefaults = struct {{
Term, Shell_integration, Select_by_word_characters, Url_excluded_characters, Shell string
Wheel_scroll_multiplier int
Wheel_scroll_multiplier float64
Url_prefixes []string
}}{{
Term: "{Options.term}", Shell_integration: "{' '.join(Options.shell_integration)}", Url_prefixes: []string{{ {url_prefixes} }},

View File

@@ -138,6 +138,7 @@ typedef struct _GLFWwindowNS
int fbWidth, fbHeight;
float xscale, yscale;
int blur_radius;
bool live_resize_in_progress;
// The total sum of the distances the cursor has been warped
// since the last cursor motion event was processed

View File

@@ -537,6 +537,7 @@ static const NSRange kEmptyRange = { NSNotFound, 0 };
if (self != nil) {
window = initWindow;
_lastScreenStates = [self captureScreenStates];
window->ns.live_resize_in_progress = false;
}
return self;
}
@@ -566,7 +567,12 @@ static const NSRange kEmptyRange = { NSNotFound, 0 };
(void)notification;
NSArray<NSDictionary *> *currentScreenStates = [self captureScreenStates];
const bool is_screen_change = ![_lastScreenStates isEqualToArray:currentScreenStates];
debug_rendering("windowDidResize() called, is_screen_change: %d\n", is_screen_change);
NSWindowStyleMask sm = [window->ns.object styleMask];
const bool is_fullscreen = (sm & NSWindowStyleMaskFullScreen) != 0;
NSRect frame = [window->ns.object frame];
debug_rendering(
"windowDidResize() called, is_screen_change: %d is_fullscreen: %d live_resize_in_progress: %d frame: %.1fx%.1f@(%.1f, %.1f)\n",
is_screen_change, is_fullscreen, window->ns.live_resize_in_progress, frame.size.width, frame.size.height, frame.origin.x, frame.origin.y);
if (is_screen_change) {
// This resize likely happened because a screen was added, removed, or changed resolution.
[_lastScreenStates release];
@@ -608,7 +614,8 @@ static const NSRange kEmptyRange = { NSNotFound, 0 };
// Because of a bug in macOS Tahoe we cannot redraw the window in response
// to a resize event that was caused by a screen change as the OpenGL
// context is not ready yet. See: https://github.com/kovidgoyal/kitty/issues/8983
if (window->ns.resizeCallback && !is_screen_change) window->ns.resizeCallback((GLFWwindow*)window);
if (window->ns.resizeCallback && !is_screen_change && !is_fullscreen && window->ns.live_resize_in_progress)
window->ns.resizeCallback((GLFWwindow*)window);
}
- (void)windowDidMove:(NSNotification *)notification
@@ -824,12 +831,14 @@ static const NSRange kEmptyRange = { NSNotFound, 0 };
- (void) viewWillStartLiveResize
{
if (!window) return;
window->ns.live_resize_in_progress = true;
_glfwInputLiveResize(window, true);
}
- (void)viewDidEndLiveResize
{
if (!window) return;
window->ns.live_resize_in_progress = false;
_glfwInputLiveResize(window, false);
}

2
go.mod
View File

@@ -13,7 +13,7 @@ require (
github.com/google/uuid v1.6.0
github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1
github.com/kovidgoyal/exiffix v0.0.0-20250919160812-dbef770c2032
github.com/kovidgoyal/imaging v1.6.4
github.com/kovidgoyal/imaging v1.6.5
github.com/seancfoley/ipaddress-go v1.7.1
github.com/shirou/gopsutil/v3 v3.24.5
github.com/zeebo/xxh3 v1.0.2

4
go.sum
View File

@@ -28,8 +28,8 @@ github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1 h1:rMY/hWfcVzBm6BL
github.com/kovidgoyal/dbus v0.0.0-20250519011319-e811c41c0bc1/go.mod h1:RbNG3Q1g6GUy1/WzWVx+S24m7VKyvl57vV+cr2hpt50=
github.com/kovidgoyal/exiffix v0.0.0-20250919160812-dbef770c2032 h1:TEV9lpo2a6fP1byiDsoJe2fXpvrj2itae41xMM+bEAg=
github.com/kovidgoyal/exiffix v0.0.0-20250919160812-dbef770c2032/go.mod h1:VU38Nlbvb0lbyS5YkopCZMS5HuJ5QLVJBxRWyzq79q4=
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
github.com/kovidgoyal/imaging v1.6.5 h1:Id9DKlz/ydl5Vxt9QG5IjGSiIcHcszSKXxDubdO49PQ=
github.com/kovidgoyal/imaging v1.6.5/go.mod h1:mBprO214rATK/6OaPAUXmHbSMelPSFEmoBAt/IJdmno=
github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik=
github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View File

@@ -0,0 +1,322 @@
package choose_files
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"github.com/kovidgoyal/kitty/tools/tui"
"github.com/kovidgoyal/kitty/tools/tui/graphics"
"github.com/kovidgoyal/kitty/tools/tui/loop"
"github.com/kovidgoyal/kitty/tools/utils"
"github.com/kovidgoyal/kitty/tools/utils/images"
)
var _ = fmt.Print
type placement struct {
gc *graphics.GraphicsCommand
x, y, x_offset int
}
func (p placement) equal(o placement) bool {
return p.x == o.x && p.x_offset == o.x_offset && p.y == o.y
}
type GraphicsHandler struct {
running_in_tmux bool
image_id_counter, detection_file_id uint32
files_to_delete []string
files_supported atomic.Bool
last_rendered_image struct {
p *ImagePreview
width, height int
image_width, image_height int
}
image_transmitted uint32
current_placement, last_transmitted_placement placement
}
func (self *GraphicsHandler) Cleanup() {
for _, f := range self.files_to_delete {
_ = os.Remove(f)
}
}
func (self *GraphicsHandler) new_graphics_command() *graphics.GraphicsCommand {
gc := graphics.GraphicsCommand{}
if self.running_in_tmux {
gc.WrapPrefix = "\033Ptmux;"
gc.WrapSuffix = "\033\\"
gc.EncodeSerializedDataFunc = func(x string) string { return strings.ReplaceAll(x, "\033", "\033\033") }
}
return &gc
}
func (self *GraphicsHandler) Initialize(lp *loop.Loop) error {
tmux := tui.TmuxSocketAddress()
if tmux != "" && tui.TmuxAllowPassthrough() == nil {
self.running_in_tmux = true
}
if !self.running_in_tmux {
g := func(t graphics.GRT_t, payload string) uint32 {
self.image_id_counter++
g1 := self.new_graphics_command()
g1.SetTransmission(t).SetAction(graphics.GRT_action_query).SetImageId(self.image_id_counter).SetDataWidth(1).SetDataHeight(1).SetFormat(
graphics.GRT_format_rgb).SetDataSize(uint64(len(payload)))
_ = g1.WriteWithPayloadToLoop(lp, utils.UnsafeStringToBytes(payload))
return self.image_id_counter
}
tf, err := images.CreateTempInRAM()
if err == nil {
if _, err = tf.Write([]byte{1, 2, 3}); err == nil {
self.detection_file_id = g(graphics.GRT_transmission_tempfile, tf.Name())
self.files_to_delete = append(self.files_to_delete, tf.Name())
}
tf.Close()
}
}
self.image_id_counter++
return nil
}
func (self *GraphicsHandler) free_image_from_terminal(lp *loop.Loop) {
if self.image_transmitted > 0 {
self.new_graphics_command().SetAction(graphics.GRT_action_delete).SetDelete(graphics.GRT_free_by_id).SetImageId(self.image_transmitted).WriteWithPayloadToLoop(lp, nil)
self.image_transmitted = 0
}
}
func (self *GraphicsHandler) Finalize(lp *loop.Loop) {
self.free_image_from_terminal(lp)
}
func (self *GraphicsHandler) ClearPlacements(lp *loop.Loop) {
self.current_placement.gc = nil
}
func (self *GraphicsHandler) ApplyPlacements(lp *loop.Loop) {
if self.current_placement.gc == nil {
g := self.new_graphics_command()
g.SetAction(graphics.GRT_action_delete).SetDelete(graphics.GRT_delete_by_id).SetImageId(self.image_transmitted)
_ = g.WriteWithPayloadToLoop(lp, nil)
self.last_transmitted_placement.gc = nil
} else {
if self.last_transmitted_placement.gc == nil || !self.current_placement.equal(self.last_transmitted_placement) {
lp.MoveCursorTo(self.current_placement.x, self.current_placement.y)
_ = self.current_placement.gc.WriteWithPayloadToLoop(lp, nil)
self.last_transmitted_placement = self.current_placement
}
}
}
func (self *GraphicsHandler) HandleGraphicsCommand(gc *graphics.GraphicsCommand) error {
switch gc.ImageId() {
case self.detection_file_id:
if gc.ResponseMessage() == "OK" {
self.files_supported.Store(true)
}
}
return nil
}
func (self *GraphicsHandler) cache_resized_image(cdir, cache_key string, img *images.ImageData) (m *images.SerializableImageMetadata, cached_data map[string]string, err error) {
s, frames := img.Serialize()
sd, err := json.Marshal(s)
if err != nil {
return nil, nil, err
}
path := filepath.Join(cdir, fmt.Sprintf("rsz-%s-metadata.json", cache_key))
if err = os.WriteFile(path, sd, 0o600); err != nil {
return nil, nil, fmt.Errorf("failed to write resized frame metadata to cache: %w", err)
}
cached_data = make(map[string]string, len(frames)+1)
cached_data[IMAGE_METADATA_KEY] = path
for i, f := range frames {
path := filepath.Join(cdir, fmt.Sprintf("rsz-%s-%d", cache_key, i))
key := IMAGE_DATA_PREFIX + strconv.Itoa(i)
if err = os.WriteFile(path, f, 0o600); err != nil {
return nil, nil, fmt.Errorf("failed to write resized frame %d data to cache: %w", i, err)
}
cached_data[key] = path
}
m = &s
return
}
func (self *GraphicsHandler) cached_resized_image(cdir, cache_key string) (m *images.SerializableImageMetadata, cached_data map[string]string) {
path := filepath.Join(cdir, fmt.Sprintf("rsz-%s-metadata.json", cache_key))
b, err := os.ReadFile(path)
if err != nil {
return
}
var s images.SerializableImageMetadata
if err = json.Unmarshal(b, &s); err != nil {
return
}
m = &s
cached_data = make(map[string]string, len(s.Frames)+1)
cached_data[IMAGE_METADATA_KEY] = path
for i := range len(s.Frames) {
path := filepath.Join(cdir, fmt.Sprintf("rsz-%s-%d", cache_key, i))
key := IMAGE_DATA_PREFIX + strconv.Itoa(i)
cached_data[key] = path
}
return
}
func transmit_by_escape_code(lp *loop.Loop, frame []byte, gc *graphics.GraphicsCommand) {
atomic := lp.IsAtomicUpdateActive()
lp.EndAtomicUpdate()
gc.SetTransmission(graphics.GRT_transmission_direct)
_ = gc.WriteWithPayloadToLoop(lp, frame)
if atomic {
lp.StartAtomicUpdate()
}
}
func transmit_by_file(lp *loop.Loop, frame_path []byte, gc *graphics.GraphicsCommand) {
gc.SetTransmission(graphics.GRT_transmission_file)
_ = gc.WriteWithPayloadToLoop(lp, frame_path)
}
func (self *GraphicsHandler) transmit(lp *loop.Loop, img *images.ImageData, m *images.SerializableImageMetadata, cached_data map[string]string) {
if m == nil {
s := img.SerializeOnlyMetadata()
m = &s
}
self.image_transmitted = self.image_id_counter
self.last_transmitted_placement.gc = nil
self.last_rendered_image.image_width = m.Width
self.last_rendered_image.image_height = m.Height
is_animated := len(m.Frames) > 0
frame_control_cmd := self.new_graphics_command()
frame_control_cmd.SetAction(graphics.GRT_action_animate).SetImageId(self.image_transmitted)
for frame_num, frame := range m.Frames {
gc := self.new_graphics_command()
gc.SetImageId(self.image_transmitted)
gc.SetDataWidth(uint64(frame.Width)).SetDataHeight(uint64(frame.Height))
gc.SetFormat(utils.IfElse(frame.Is_opaque, graphics.GRT_format_rgb, graphics.GRT_format_rgba))
switch frame_num {
case 0:
gc.SetAction(graphics.GRT_action_transmit)
gc.SetCursorMovement(graphics.GRT_cursor_static)
default:
gc.SetAction(graphics.GRT_action_frame)
gc.SetGap(int32(frame.Delay_ms))
if frame.Compose_onto > 0 {
gc.SetOverlaidFrame(uint64(frame.Compose_onto))
}
gc.SetLeftEdge(uint64(frame.Left)).SetTopEdge(uint64(frame.Top))
}
if cached_data == nil {
transmit_by_escape_code(lp, img.Frames[frame_num].Data(), gc)
} else {
path := cached_data[IMAGE_DATA_PREFIX+strconv.Itoa(frame_num)]
transmit_by_file(lp, utils.UnsafeStringToBytes(path), gc)
}
if is_animated {
switch frame_num {
case 0:
// set gap for the first frame and number of loops for the animation
c := frame_control_cmd
c.SetTargetFrame(uint64(frame.Number))
c.SetGap(int32(frame.Delay_ms))
c.SetNumberOfLoops(1)
_ = c.WriteWithPayloadToLoop(lp, nil)
case 1:
c := frame_control_cmd
c.SetAnimationControl(2) // set animation to loading mode
_ = c.WriteWithPayloadToLoop(lp, nil)
}
}
}
if is_animated {
c := frame_control_cmd
c.SetAnimationControl(3) // set animation to normal mode
_ = c.WriteWithPayloadToLoop(lp, nil)
}
}
func (self *GraphicsHandler) place_image(x, y, px_width, y_offset int, sz ScreenSize) {
gc := self.new_graphics_command()
gc.SetAction(graphics.GRT_action_display).SetImageId(self.image_transmitted).SetPlacementId(1).SetCursorMovement(graphics.GRT_cursor_static)
if extra := px_width - self.last_rendered_image.image_width; extra > 1 {
extra /= 2
x += extra / sz.cell_width
self.current_placement.x_offset = extra % sz.cell_width
gc.SetXOffset(uint64(self.current_placement.x_offset))
}
gc.SetYOffset(uint64(y_offset))
self.current_placement.x, self.current_placement.y = x, y
self.current_placement.gc = gc
}
func (self *GraphicsHandler) RenderImagePreview(h *Handler, p *ImagePreview, x, y, width, height int) {
sz := h.screen_size
px_width, px_height := width*sz.cell_width, height*sz.cell_height
y_offset := sz.cell_height / 2
px_height -= y_offset
var err error
defer func() {
self.last_rendered_image.p = p
self.last_rendered_image.width, self.last_rendered_image.height = width, height
if err != nil {
NewErrorPreview(fmt.Errorf("Failed to render image: %w", err)).Render(h, x, y, width, height)
} else if self.image_transmitted > 0 {
self.place_image(x, y, px_width, y_offset, sz)
}
}()
if self.last_rendered_image.p == p && self.last_rendered_image.width == width && self.last_rendered_image.height == height {
return
}
files_supported := self.files_supported.Load()
if p.img_metadata.Width <= px_width && p.img_metadata.Height <= px_height {
if files_supported {
self.transmit(h.lp, nil, p.img_metadata, p.cached_data)
} else {
if err = p.ensure_source_image(); err != nil {
return
}
self.transmit(h.lp, p.source_img, p.img_metadata, nil)
}
return
}
cache_key := fmt.Sprintf("%d-%d-%p", width, height, p)
img_metadata, cached_data := self.cached_resized_image(p.disk_cache.ResultsDir(), cache_key)
var img *images.ImageData
if len(cached_data) == 0 {
if err = p.ensure_source_image(); err != nil {
return
}
img = p.source_img
final_width, final_height := images.FitImage(img.Width, img.Height, px_width, px_height)
if final_width != img.Width || final_height != img.Height {
x_frac, y_frac := float64(final_width)/float64(img.Width), float64(final_height)/float64(img.Height)
img = img.Resize(x_frac, y_frac)
}
if img_metadata, cached_data, err = self.cache_resized_image(p.disk_cache.ResultsDir(), cache_key, img); err != nil {
err = fmt.Errorf("failed to cache resized image: %w", err)
return
}
}
if files_supported {
self.transmit(h.lp, img, img_metadata, cached_data)
} else {
if img == nil {
if img, err = load_image(cached_data); err != nil {
err = fmt.Errorf("failed to load resized image from cache: %w", err)
return
}
}
self.transmit(h.lp, img, nil, nil)
}
}

View File

@@ -0,0 +1,247 @@
package choose_files
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"sync"
"sync/atomic"
"github.com/kovidgoyal/kitty/tools/disk_cache"
"github.com/kovidgoyal/kitty/tools/icons"
"github.com/kovidgoyal/kitty/tools/utils"
"github.com/kovidgoyal/kitty/tools/utils/humanize"
"github.com/kovidgoyal/kitty/tools/utils/images"
)
const IMAGE_METADATA_KEY = "image-metadata.json"
const IMAGE_DATA_PREFIX = "image-data-"
var dc_size atomic.Int64
var _ = fmt.Print
var preview_cache = sync.OnceValues(func() (*disk_cache.DiskCache, error) {
cdir := utils.CacheDir()
cdir = filepath.Join(cdir, "choose-files")
return disk_cache.NewDiskCache(cdir, dc_size.Load())
})
type ShowData struct {
abspath string
metadata fs.FileInfo
x, y, width, height int
cached_data map[string]string
img_metadata *images.SerializableImageMetadata
}
type PreviewRenderer interface {
Render(string) (map[string][]byte, *images.ImageData, error)
ShowMetadata(h *Handler, s ShowData) int
}
type render_data struct {
cached_data map[string]string
img *images.ImageData
img_metadata *images.SerializableImageMetadata
err error
}
type ImagePreview struct {
abspath string
metadata fs.FileInfo
disk_cache *disk_cache.DiskCache
cached_data map[string]string
render_err Preview
render_channel chan render_data
source_img *images.ImageData
img_metadata *images.SerializableImageMetadata
renderer PreviewRenderer
file_metadata_preview Preview
WakeupMainThread func() bool
}
func (p *ImagePreview) IsValidForColorScheme(bool) bool { return true }
func (p *ImagePreview) Unload() {
p.source_img = nil
}
func load_image(cached_data map[string]string) (img *images.ImageData, err error) {
fp := cached_data[IMAGE_METADATA_KEY]
if fp == "" {
return nil, fmt.Errorf("missing cached image metadata")
}
b, err := os.ReadFile(fp)
if err != nil {
return nil, fmt.Errorf("failed to read cached image metadata: %w", err)
}
var m images.SerializableImageMetadata
if err = json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("failed to decode cached image metadata: %w", err)
}
frames := make([][]byte, len(m.Frames))
for i := range m.Frames {
path := cached_data[IMAGE_DATA_PREFIX+strconv.Itoa(i)]
if path == "" {
return nil, fmt.Errorf("missing cached data for frame: %d", i)
}
d, e := os.ReadFile(path)
if e != nil {
return nil, fmt.Errorf("failed to read cached image frame %d data: %w", i, e)
}
m.Frames[i].Size = len(d)
frames[i] = d
}
return images.ImageFromSerialized(m, frames)
}
func (p *ImagePreview) ensure_source_image() (err error) {
if p.source_img != nil {
return
}
defer func() {
if err != nil {
p.render_err = NewErrorPreview(err)
}
}()
p.source_img, err = load_image(p.cached_data)
return
}
func (p *ImagePreview) render_image(h *Handler, x, y, width, height int) {
defer func() {
if r := recover(); r != nil {
text, _ := utils.Format_stacktrace_on_panic(r)
h.err_chan <- fmt.Errorf("%s", text)
p.WakeupMainThread()
}
}()
offset := p.renderer.ShowMetadata(h, ShowData{
abspath: p.abspath, metadata: p.metadata, x: x, y: y, width: width, height: height, cached_data: p.cached_data,
img_metadata: p.img_metadata,
})
h.graphics_handler.RenderImagePreview(h, p, x, y+offset, width, height-offset)
}
func (p *ImagePreview) Render(h *Handler, x, y, width, height int) {
if p.render_channel == nil {
if p.render_err == nil {
p.render_image(h, x, y, width, height)
} else {
p.render_err.Render(h, x, y, width, height)
}
return
}
select {
case hd := <-p.render_channel:
p.render_channel = nil
p.cached_data = hd.cached_data
p.source_img = hd.img
p.img_metadata = hd.img_metadata
if hd.err != nil {
p.render_err = NewErrorPreview(fmt.Errorf("Failed to render the preview with error: %w", hd.err))
}
p.Render(h, x, y, width, height)
return
default:
}
if p.file_metadata_preview == nil {
p.file_metadata_preview = NewFileMetadataPreview(p.abspath, p.metadata)
m := p.file_metadata_preview.(*MessagePreview)
m.trailers = append(m.trailers, "", "Rendering image preview, please wait…")
}
p.file_metadata_preview.Render(h, x, y, width, height)
}
func (p *ImagePreview) start_rendering() {
defer func() {
if r := recover(); r != nil {
text, _ := utils.Format_stacktrace_on_panic(r)
p.render_channel <- render_data{err: fmt.Errorf("%s", text)}
}
close(p.render_channel)
p.WakeupMainThread()
}()
key, ans, err := p.disk_cache.GetPath(p.abspath)
if err != nil {
p.render_channel <- render_data{err: err}
return
}
if len(ans) > 0 {
if d := ans[IMAGE_METADATA_KEY]; d != "" {
if b, err := os.ReadFile(d); err == nil {
var m images.SerializableImageMetadata
if err = json.Unmarshal(b, &m); err == nil {
p.render_channel <- render_data{cached_data: ans, img_metadata: &m}
return
}
}
}
}
rdata, img, err := p.renderer.Render(p.abspath)
if err != nil {
p.render_channel <- render_data{err: err}
} else {
ans, err = p.disk_cache.AddPath(p.abspath, key, rdata)
if err == nil {
m := img.SerializeOnlyMetadata()
p.render_channel <- render_data{cached_data: ans, img_metadata: &m, img: img}
} else {
p.render_channel <- render_data{err: err}
}
}
}
type ImagePreviewRenderer uint
func (p ImagePreviewRenderer) Render(abspath string) (ans map[string][]byte, img *images.ImageData, err error) {
if img, err = images.OpenImageFromPath(abspath); err != nil {
return nil, nil, err
}
m, data := img.Serialize()
ans = make(map[string][]byte, len(data)+1)
metadata, err := json.Marshal(m)
if err != nil {
return nil, nil, err
}
ans[IMAGE_METADATA_KEY] = metadata
for i, d := range data {
key := IMAGE_DATA_PREFIX + strconv.Itoa(i)
ans[key] = d
}
return
}
func (p ImagePreviewRenderer) ShowMetadata(h *Handler, s ShowData) int {
text := ""
offset := 0
if s.img_metadata != nil {
text = fmt.Sprintf("%s: %dx%d %s", s.img_metadata.Format_uppercase, s.img_metadata.Width, s.img_metadata.Height, humanize.Bytes(uint64(s.metadata.Size())))
icon := icons.IconForPath("/a.gif")
text = icon + " " + text
offset += h.render_wrapped_text_in_region(text, s.x, s.y, s.width, s.height, true)
}
offset += h.render_wrapped_text_in_region(humanize.Time(s.metadata.ModTime()), s.x, s.y+offset, s.width, s.height-offset, true)
return offset
}
func NewImagePreview(
abspath string, metadata fs.FileInfo, opts Settings, WakeupMainThread func() bool, r PreviewRenderer,
) (Preview, error) {
dc_size.Store(opts.DiskCacheSize())
ans := &ImagePreview{
abspath: abspath, metadata: metadata, render_channel: make(chan render_data, 1),
WakeupMainThread: WakeupMainThread, renderer: r,
}
if dc, err := preview_cache(); err != nil {
return nil, err
} else {
ans.disk_cache = dc
}
go ans.start_rendering()
return ans, nil
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/kovidgoyal/kitty/tools/ignorefiles"
"github.com/kovidgoyal/kitty/tools/tty"
"github.com/kovidgoyal/kitty/tools/tui"
"github.com/kovidgoyal/kitty/tools/tui/graphics"
"github.com/kovidgoyal/kitty/tools/tui/loop"
"github.com/kovidgoyal/kitty/tools/tui/readline"
"github.com/kovidgoyal/kitty/tools/utils"
@@ -123,6 +124,7 @@ type State struct {
display_title bool
pygments_style, dark_pygments_style string
syntax_aliases map[string]string
max_disk_cache_size int64
selections []string
current_idx CollectionIndex
@@ -131,6 +133,7 @@ type State struct {
redraw_needed bool
}
func (s State) DiskCacheSize() int64 { return s.max_disk_cache_size }
func (s State) HighlightStyles() (string, string) { return s.pygments_style, s.dark_pygments_style }
func (s State) SyntaxAliases() map[string]string { return s.syntax_aliases }
func (s State) DisplayTitle() bool { return s.display_title }
@@ -204,16 +207,29 @@ type ScreenSize struct {
}
type Handler struct {
state State
screen_size ScreenSize
result_manager *ResultManager
lp *loop.Loop
rl *readline.Readline
err_chan chan error
shortcut_tracker config.ShortcutTracker
msg_printer *message.Printer
spinner *tui.Spinner
preview_manager *PreviewManager
state State
screen_size ScreenSize
result_manager *ResultManager
lp *loop.Loop
rl *readline.Readline
err_chan chan error
shortcut_tracker config.ShortcutTracker
msg_printer *message.Printer
spinner *tui.Spinner
preview_manager *PreviewManager
last_rendered_preview Preview
graphics_handler GraphicsHandler
}
func (self *Handler) on_escape_code(etype loop.EscapeCodeType, payload []byte) error {
switch etype {
case loop.APC:
gc := graphics.GraphicsCommandFromAPC(payload)
if gc != nil {
return self.graphics_handler.HandleGraphicsCommand(gc)
}
}
return nil
}
func (h *Handler) draw_screen() (err error) {
@@ -222,15 +238,8 @@ func (h *Handler) draw_screen() (err error) {
defer func() {
h.state.mouse_state.UpdateHoveredIds()
h.state.mouse_state.ApplyHoverStyles(h.lp)
h.lp.EndAtomicUpdate()
}()
h.lp.ClearScreen()
h.state.mouse_state.ClearCellRegions()
switch h.state.screen {
case NORMAL:
matches, is_complete := h.get_results()
h.lp.SetWindowTitle(h.state.WindowTitle())
defer func() { // so that the cursor ends up in the right place
h.graphics_handler.ApplyPlacements(h.lp)
if h.state.screen == NORMAL { // so that the cursor ends up in the right place
h.lp.MoveCursorTo(1, 1)
if h.state.DisplayTitle() {
h.lp.Println(h.state.WindowTitle())
@@ -238,7 +247,16 @@ func (h *Handler) draw_screen() (err error) {
} else {
h.draw_search_bar(0)
}
}()
}
h.lp.EndAtomicUpdate()
}()
h.lp.ClearScreenButNotGraphics()
h.graphics_handler.ClearPlacements(h.lp)
h.state.mouse_state.ClearCellRegions()
switch h.state.screen {
case NORMAL:
matches, is_complete := h.get_results()
h.lp.SetWindowTitle(h.state.WindowTitle())
y := SEARCH_BAR_HEIGHT + utils.IfElse(h.state.DisplayTitle(), 1, 0)
footer_height, err := h.draw_footer()
if err != nil {
@@ -292,6 +310,7 @@ func (h *Handler) OnInitialize() (ans string, err error) {
}
}
}
err = h.graphics_handler.Initialize(h.lp)
h.result_manager.set_root_dir()
h.draw_screen()
return
@@ -718,6 +737,7 @@ func (h *Handler) set_state_from_config(conf *Config, opts *Options) (err error)
h.state.pygments_style = conf.Pygments_style
h.state.dark_pygments_style = conf.Dark_pygments_style
h.state.syntax_aliases = conf.Syntax_aliases
h.state.max_disk_cache_size = int64(conf.Cache_size * (1024 * 1024 * 1024))
return
}
@@ -775,6 +795,7 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
lp.MouseTrackingMode(loop.FULL_MOUSE_TRACKING)
lp.ColorSchemeChangeNotifications()
handler := Handler{lp: lp, err_chan: make(chan error, 8), msg_printer: message.NewPrinter(utils.LanguageTag()), spinner: tui.NewSpinner("dots")}
defer handler.graphics_handler.Cleanup()
handler.rl = readline.New(lp, readline.RlInit{
Prompt: "> ", ContinuationPrompt: ". ", Completer: FilePromptCompleter(handler.state.CurrentDir),
})
@@ -810,6 +831,10 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
lp.RequestCurrentColorScheme()
return handler.OnInitialize()
}
lp.OnFinalize = func() string {
handler.graphics_handler.Finalize(lp)
return ""
}
lp.OnResize = func(old, new_size loop.ScreenSize) (err error) {
handler.init_sizes(new_size)
return handler.draw_screen()
@@ -826,6 +851,7 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
lp.OnKeyEvent = handler.OnKeyEvent
lp.OnText = handler.OnText
lp.OnMouseEvent = handler.OnMouseEvent
lp.OnEscapeCode = handler.on_escape_code
lp.OnWakeup = func() (err error) {
select {
case err = <-handler.err_chan:

View File

@@ -61,6 +61,11 @@ builtin styles <https://pygments.org/styles/>` for a list of schemes.
This sets the colors used for dark color schemes, use :opt:`pygments_style` to change the
colors for light color schemes.''')
opt('cache_size', '0.5', option_type='positive_float', long_text='''
The maximum size of the disk cache, in gigabytes, used for previews. Zero or negative values
mean no limit.
''')
opt('syntax_aliases', 'pyj:py pyi:py recipe:py', ctype='strdict_ _:', option_type='syntax_aliases',
long_text='''
File extension aliases for syntax highlight. For example, to syntax highlight

View File

@@ -25,6 +25,7 @@ var _ = fmt.Print
type Preview interface {
Render(h *Handler, x, y, width, height int)
IsValidForColorScheme(light bool) bool
Unload()
}
type PreviewManager struct {
@@ -80,6 +81,7 @@ type MessagePreview struct {
func (p MessagePreview) IsValidForColorScheme(bool) bool { return true }
func (p MessagePreview) Unload() {}
func (p MessagePreview) Render(h *Handler, x, y, width, height int) {
offset := 0
if p.title != "" {
@@ -189,6 +191,8 @@ type TextFilePreview struct {
func (p TextFilePreview) IsValidForColorScheme(light bool) bool { return p.light == light }
func (p *TextFilePreview) Unload() {}
func (p *TextFilePreview) Render(h *Handler, x, y, width, height int) {
if p.highlighted_chan != nil {
select {
@@ -255,13 +259,19 @@ func (pm *PreviewManager) highlight_file_async(path string, output chan highligh
s := style_resolver{light: use_light_colors, syntax_aliases: pm.settings.SyntaxAliases()}
s.light_style, s.dark_style = pm.settings.HighlightStyles()
go func() {
defer func() {
if r := recover(); r != nil {
text, _ := utils.Format_stacktrace_on_panic(r)
debugprintln(fmt.Sprintf("Failed to highlight: %s with panic: %s", path, text))
}
close(output)
pm.WakeupMainThread()
}()
highlighted, err := pm.highlighter.HighlightFile(path, &s)
if err != nil {
debugprintln(fmt.Sprintf("Failed to highlight: %s with error: %s", path, err))
}
output <- highlighed_data{text: highlighted, err: err, light: s.light}
close(output)
pm.WakeupMainThread()
}()
}
@@ -297,6 +307,14 @@ func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Pr
pm.highlight_file_async(abspath, ch)
return NewTextFilePreview(abspath, s, ch, pm.highlighter.Sanitize)
}
if strings.HasPrefix(mt, "image/") {
var r ImagePreviewRenderer
if ans, err := NewImagePreview(abspath, s, pm.settings, pm.WakeupMainThread, r); err == nil {
return ans
} else {
return NewErrorPreview(err)
}
}
return NewFileMetadataPreview(abspath, s)
}
@@ -308,9 +326,14 @@ func (h *Handler) draw_preview_content(x, y, width, height int) {
return
}
abspath := filepath.Join(h.state.CurrentDir(), r.text)
if h.last_rendered_preview != nil {
h.last_rendered_preview.Unload()
h.last_rendered_preview = nil
}
if p := h.preview_manager.preview_for(abspath, r.ftype); p == nil {
h.render_wrapped_text_in_region("No preview available", x, y, width, height, false)
} else {
h.last_rendered_preview = p
p.Render(h, x, y, width, height)
}
}

View File

@@ -235,8 +235,8 @@ func (h *Handler) draw_list_of_results(matches *SortedResults, y, height int) (n
}
if num_matches > height {
col_width = BASE_COL_WIDTH
num_cols = available_width / col_width
for num_cols > 0 && height*(num_cols-1) >= num_matches {
num_cols = max(1, available_width/col_width)
for num_cols > 1 && height*(num_cols-1) >= num_matches {
num_cols--
}
col_width = available_width / num_cols

View File

@@ -634,6 +634,7 @@ type Settings interface {
GlobalIgnores() ignorefiles.IgnoreFile
HighlightStyles() (string, string)
SyntaxAliases() map[string]string
DiskCacheSize() int64
}
type ResultManager struct {

View File

@@ -32,7 +32,7 @@ var highlighter = sync.OnceValue(func() highlight.Highlighter {
func highlight_all(paths []string, light bool) {
ctx := images.Context{}
srd := prefer_light_colors(light)
ctx.Parallel(0, len(paths), func(nums <-chan int) {
if err := ctx.SafeParallel(0, len(paths), func(nums <-chan int) {
for i := range nums {
path := paths[i]
raw, err := highlighter().HighlightFile(path, &srd)
@@ -45,5 +45,7 @@ func highlight_all(paths []string, light bool) {
dark_highlighted_lines_cache.Set(path, text_to_lines(raw))
}
}
})
}); err != nil {
panic(err)
}
}

View File

@@ -4,6 +4,7 @@ package diff
import (
"fmt"
"math"
"strconv"
"strings"
"sync"
@@ -20,7 +21,7 @@ import (
var _ = fmt.Print
type KittyOpts struct {
Wheel_scroll_multiplier int
Wheel_scroll_multiplier float64
Copy_on_select bool
}
@@ -29,7 +30,7 @@ func read_relevant_kitty_opts() KittyOpts {
handle_line := func(key, val string) error {
switch key {
case "wheel_scroll_multiplier":
v, err := strconv.Atoi(val)
v, err := strconv.ParseFloat(val, 64)
if err == nil {
ans.Wheel_scroll_multiplier = v
}
@@ -47,7 +48,10 @@ var RelevantKittyOpts = sync.OnceValue(func() KittyOpts {
})
func (self *Handler) handle_wheel_event(up bool) {
amt := RelevantKittyOpts().Wheel_scroll_multiplier
amt := int(math.Round(RelevantKittyOpts().Wheel_scroll_multiplier))
if amt == 0 {
amt = 1
}
if up {
amt *= -1
}

View File

@@ -358,14 +358,16 @@ func diff(jobs []diff_job, context_count int) (ans map[string]*Patch, err error)
patch *Patch
}
results := make(chan result, len(jobs))
ctx.Parallel(0, len(jobs), func(nums <-chan int) {
if err := ctx.SafeParallel(0, len(jobs), func(nums <-chan int) {
for i := range nums {
job := jobs[i]
r := result{file1: job.file1, file2: job.file2}
r.patch, r.err = do_diff(job.file1, job.file2, context_count)
results <- r
}
})
}); err != nil {
panic(err)
}
close(results)
for r := range results {
if r.err != nil {

View File

@@ -106,7 +106,7 @@ func (self *Search) search(logical_lines *LogicalLines) {
self.matches = make(map[ScrollPos][]Span)
ctx := images.Context{}
mutex := sync.Mutex{}
ctx.Parallel(0, logical_lines.Len(), func(nums <-chan int) {
if err := ctx.SafeParallel(0, logical_lines.Len(), func(nums <-chan int) {
for i := range nums {
line := logical_lines.At(i)
if line.line_type == EMPTY_LINE || line.line_type == IMAGE_LINE {
@@ -121,7 +121,9 @@ func (self *Search) search(logical_lines *LogicalLines) {
}
})
}
})
}); err != nil {
panic(err)
}
for _, spans := range self.matches {
slices.SortFunc(spans, func(a, b Span) int { return a.start - b.start })
}

View File

@@ -299,8 +299,12 @@ func process_arg(arg input_arg) {
}
err = render_image_with_go(&imgd, &f)
if err != nil {
report_error(arg.value, "Could not render image to RGB", err)
return
merr := render_image_with_magick(&imgd, &f)
if merr != nil {
report_error(arg.value, "Could not render image to RGB", err)
return
}
err = nil
}
} else {
err = render_image_with_magick(&imgd, &f)

View File

@@ -76,14 +76,18 @@ vec3 color_to_vec(uint c) {
return vec3(gamma_lut[r], gamma_lut[g], gamma_lut[b]);
}
#define one_if_equal_zero_otherwise(a, b) (1.0f - zero_or_one(abs(float(a) - float(b))))
float one_if_equal_zero_otherwise(float a, float b) { return (1.0f - zero_or_one(abs(float(a) - float(b)))); }
// Wee need an integer variant to accommodate GPU driver bugs, see
// https://github.com/kovidgoyal/kitty/issues/9072
uint one_if_equal_zero_otherwise(int a, int b) { return (1u - uint(zero_or_one(abs(float(a) - float(b))))); }
uint one_if_equal_zero_otherwise(uint a, uint b) { return (1u - uint(zero_or_one(abs(float(a) - float(b))))); }
uint resolve_color(uint c, uint defval) {
// Convert a cell color to an actual color based on the color table
int t = int(c & BYTE_MASK);
uint is_one = uint(one_if_equal_zero_otherwise(t, 1));
uint is_two = uint(one_if_equal_zero_otherwise(t, 2));
uint is_one = one_if_equal_zero_otherwise(t, 1);
uint is_two = one_if_equal_zero_otherwise(t, 2);
uint is_neither_one_nor_two = 1u - is_one - is_two;
return is_one * color_table[(c >> 8) & BYTE_MASK] + is_two * (c >> 8) + is_neither_one_nor_two * defval;
}
@@ -94,7 +98,7 @@ vec3 to_color(uint c, uint defval) {
vec3 resolve_dynamic_color(uint c, vec3 special_val, vec3 defval) {
float type = float((c >> 24) & BYTE_MASK);
#define q(which, val) one_if_equal_zero_otherwise(type, which) * val
#define q(which, val) one_if_equal_zero_otherwise(type, float(which)) * val
return (
q(COLOR_IS_RGB, color_to_vec(c)) + q(COLOR_IS_INDEX, color_to_vec(color_table[c & BYTE_MASK])) +
q(COLOR_IS_SPECIAL, special_val) + q(COLOR_NOT_SET, defval)
@@ -185,7 +189,7 @@ uvec2 get_decorations_indices(uint in_url /* [0, 1] */, uint text_attrs) {
return uvec2(strike_idx, has_underline * (decorations_idx + underline_style));
}
float is_cursor(uint x, uint y) {
uint is_cursor(uint x, uint y) {
uint clamped_x = clamp(x, cursor_x1, cursor_x2);
uint clamped_y = clamp(y, cursor_y1, cursor_y2);
return one_if_equal_zero_otherwise(x, clamped_x) * one_if_equal_zero_otherwise(y, clamped_y);
@@ -217,7 +221,7 @@ CellData set_vertex_position(vec3 cell_fg, vec3 cell_bg) {
colored_sprite = float((sprite_idx[0] & SPRITE_COLORED_MASK) >> SPRITE_COLORED_SHIFT);
#endif
// Cursor shape and colors
float has_main_cursor = is_cursor(column, row);
float has_main_cursor = float(is_cursor(column, row));
float multicursor_shape = float((is_selected >> 2) & 3u);
float multicursor_uses_main_cursor_shape = float((is_selected >> 4) & BIT_MASK);
multicursor_shape = if_one_then(multicursor_uses_main_cursor_shape, cursor_shape, multicursor_shape);

View File

@@ -491,7 +491,7 @@ def go_type_data(parser_func: ParserFuncType, ctype: str, is_multiple: bool = Fa
if p == 'positive_int':
return 'uint64', 'strconv.ParseUint(val, 10, 64)'
if p == 'positive_float':
return 'float64', 'config.PositiveFloat(val, 10, 64)'
return 'float64', 'config.PositiveFloat(val)'
if p == 'unit_float':
return 'float64', 'config.UnitFloat(val)'
if p == 'python_string':

View File

@@ -28,5 +28,9 @@ static inline void* disk_cache_malloc_allocator(void *x, size_t sz) {
static inline bool
read_from_disk_cache_simple(PyObject *self_, const void *key, size_t key_sz, void **data, size_t *data_sz, bool store_in_ram) {
*data = read_from_disk_cache(self_, key, key_sz, disk_cache_malloc_allocator, data_sz, store_in_ram);
return PyErr_Occurred() == NULL;
if (PyErr_Occurred()) {
PyErr_Clear();
return false;
}
return *data != NULL;
}

View File

@@ -84,7 +84,7 @@ gl_init(void) {
}
}
static const char*
const char*
check_framebuffer_status(void) {
GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
switch (status) {
@@ -100,12 +100,6 @@ check_framebuffer_status(void) {
}
}
void
check_framebuffer_status_or_die(void) {
const char *err = check_framebuffer_status();
if (err != NULL) fatal("Framebuffer not complete with error: %s", err);
}
void
free_texture(GLuint *tex_id) {
glDeleteTextures(1, tex_id);

View File

@@ -63,7 +63,7 @@ void unbind_program(void);
GLuint compile_shaders(GLenum shader_type, GLsizei count, const GLchar * const * string);
void save_viewport_using_top_left_origin(GLsizei x, GLsizei y, GLsizei width, GLsizei height, GLsizei full_framebuffer_height);
void save_viewport_using_bottom_left_origin(GLsizei x, GLsizei y, GLsizei width, GLsizei height);
void check_framebuffer_status_or_die(void);
const char* check_framebuffer_status(void);
void restore_viewport(void);
void bind_framebuffer_for_output(unsigned fbid);
void set_framebuffer_to_use_for_output(unsigned fbid);

View File

@@ -511,24 +511,21 @@ class Splits(Layout):
def do_layout(self, all_windows: WindowList) -> None:
groups = tuple(all_windows.iter_all_layoutable_groups())
window_count = len(groups)
root = self.pairs_root
all_present_window_ids = frozenset(w.id for w in groups)
already_placed_window_ids = frozenset(root.all_window_ids())
windows_to_remove = already_placed_window_ids - all_present_window_ids
if windows_to_remove:
self.remove_windows(*windows_to_remove)
id_window_map = {w.id: w for w in groups}
id_idx_map = {w.id: i for i, w in enumerate(groups)}
windows_to_add = all_present_window_ids - already_placed_window_ids
if windows_to_add:
for wid in sorted(windows_to_add, key=id_idx_map.__getitem__):
root.balanced_add(wid)
all_present_group_ids = {g.id for g in groups}
already_placed_group_ids = frozenset(root.all_window_ids())
if groups_to_remove := already_placed_group_ids - all_present_group_ids:
self.remove_windows(*groups_to_remove)
if groups_to_add := all_present_group_ids - already_placed_group_ids:
id_idx_map = {g.id: i for i, g in enumerate(groups)}
for gid in sorted(groups_to_add, key=id_idx_map.__getitem__):
root.balanced_add(gid)
if window_count == 1:
if len(groups) == 1:
self.layout_single_window_group(groups[0])
else:
root.layout_pair(lgd.central.left, lgd.central.top, lgd.central.width, lgd.central.height, id_window_map, self)
id_group_map = {g.id: g for g in groups}
root.layout_pair(lgd.central.left, lgd.central.top, lgd.central.width, lgd.central.height, id_group_map, self)
def add_non_overlay_window(
self,
@@ -563,7 +560,9 @@ class Splits(Layout):
parent_pair.bias = bias if parent_pair.one == target_group.id else (1 - bias)
return
all_windows.add_window(window)
p = self.pairs_root.balanced_add(window.id)
g = all_windows.group_for_window(window)
assert g is not None
p = self.pairs_root.balanced_add(g.id)
if bias is not None:
p.bias = bias

View File

@@ -67,6 +67,7 @@ from .utils import (
get_custom_window_icon,
log_error,
parse_os_window_state,
read_shell_environment,
safe_mtime,
startup_notification_handler,
)
@@ -478,6 +479,17 @@ def setup_environment(opts: Options, cli_opts: CLIOptions) -> None:
from_config_file = True
if cli_opts.listen_on:
cli_opts.listen_on = expand_listen_on(cli_opts.listen_on, from_config_file)
if vars := opts.env.pop('read_from_shell', ''):
import fnmatch
import re
senv = read_shell_environment(opts)
patterns = tuple(re.compile(fnmatch.translate(x.strip())) for x in vars.split() if x.strip())
if patterns:
for k, v in senv.items():
for pat in patterns:
if pat.match(k) is not None:
opts.env[k] = v
break
env = opts.env.copy()
ensure_kitty_in_path()
ensure_kitten_in_path()

View File

@@ -3254,7 +3254,9 @@ Changing this option by reloading the config is not supported.
'''
)
opt('+env', '',
opt(
'+env',
'',
option_type='env',
add_to_default=False,
long_text='''
@@ -3268,8 +3270,17 @@ recursively, for example::
env VAR2=${HOME}/${VAR1}/b
The value of :code:`VAR2` will be :code:`<path to home directory>/a/b`.
'''
)
Use the special
value :code:`read_from_shell` to have kitty read the specified variables from
your :opt:`login shell <shell>` configuration.
Useful if your shell startup files setup a bunch of environment variables that you want available to kitty and
in kitty session files. Each variable name is treated as a glob pattern to match. For example:
:code:`env read_from_shell=PATH LANG LC_* XDG_* EDITOR VISUAL`. Note that these variables are only
read after the configuration is fully processed, thus they are not available for recursive expansion and
they will override any variables set by other :opt:`env` directives.
''',
)
opt('+filter_notification', '', option_type='filter_notification', add_to_default=False, long_text='''
Specify rules to filter out notifications sent by applications running in kitty.

View File

@@ -82,8 +82,7 @@ CmdGenerator = Iterator[CmdReturnType]
PayloadType = Optional[Union[CmdReturnType, CmdGenerator]]
PayloadGetType = PayloadGetter
ArgsType = list[str]
ImageCompletion = CompletionSpec.from_string('type:file group:"Images"')._replace(
extensions=('png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'tiff'))
ImageCompletion = CompletionSpec.from_string('type:file group:"Images" ext:png,jpg,jpeg,webp,gif,bmp,tiff')
SUPPORTED_IMAGE_FORMATS = tuple(x.upper() for x in ImageCompletion.extensions if x != 'jpg')

View File

@@ -3,6 +3,7 @@
import json
import os
import re
import shlex
import sys
from collections.abc import Callable, Generator, Iterator, Mapping
@@ -64,7 +65,7 @@ class Tab:
self.pending_resize_spec: ResizeSpec | None = None
self.pending_focus_matching_window: str = ''
self.name = name.strip()
self.active_window_idx = 0
self.active_window_idx = -1
self.enabled_layouts = opts.enabled_layouts
self.layout = (self.enabled_layouts or ['tall'])[0]
self.layout_state: dict[str, Any] | None = None
@@ -518,7 +519,6 @@ def parse_goto_session_cmdline(args: list[str]) -> tuple[GotoSessionOptions, lis
return ans, leftover_args
def goto_session_options() -> str:
return '''
--sort-by
@@ -528,7 +528,23 @@ When interactively choosing sessions from a list, how to sort the list.
'''
def goto_previous_session(boss: BossType, idx: int) -> None:
if boss.active_session:
nidx = max(0, len(goto_session_history) - 1 + idx)
if nidx < len(goto_session_history):
switch_to_session(boss, goto_session_history[nidx])
return
else:
if goto_session_history:
switch_to_session(boss, goto_session_history[-1])
return
boss.ring_bell_if_allowed()
def goto_session(boss: BossType, cmdline: Sequence[str]) -> None:
if len(cmdline) == 1 and re.match(r'-\d+', cmdline[0]) is not None:
# special case for backwards compat goto_session -1
return goto_previous_session(boss, int(cmdline[0]))
try:
opts, cmdline = parse_goto_session_cmdline(list(cmdline))
except Exception as e:
@@ -539,28 +555,13 @@ def goto_session(boss: BossType, cmdline: Sequence[str]) -> None:
choose_session(boss, opts)
return
path = cmdline[0]
if len(cmdline) == 1:
if len(cmdline) == 1: # goto_session -- -1
try:
idx = int(path)
except Exception:
idx = 0
if idx < 0:
if boss.active_session:
nidx = max(0, len(goto_session_history) - 1 + idx)
if nidx < len(goto_session_history):
switch_to_session(boss, goto_session_history[nidx])
return
else:
if goto_session_history:
switch_to_session(boss, goto_session_history[-1])
return
boss.ring_bell_if_allowed()
return
else:
for x in cmdline:
if not x.startswith('-'):
path = x
break
return goto_previous_session(boss, idx)
path, session_name = resolve_session_path_and_name(path)
if not session_name:
boss.show_error(_('Invalid session'), _('{} is not a valid path for a session').format(path))

View File

@@ -656,9 +656,29 @@ setup_texture_as_render_target(unsigned width, unsigned height, GLuint *texture_
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
// We use GL_RGBA16 to avoid incorrect colors due to quantization loss when
// blending, see https://github.com/kovidgoyal/kitty/issues/8953
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
static struct { bool ok; int fmt; } status = { false, GL_RGBA16};
glTexImage2D(GL_TEXTURE_2D, 0, status.fmt, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
bind_framebuffer_for_output(*framebuffer_id);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, *texture_id, 0);
if (!status.ok) {
if (check_framebuffer_status() == NULL) {
status.ok = true;
} else {
if (status.fmt == GL_RGBA16) {
// Driver does not support 16 bit FBO so let it choose the
// format. It will probably end up choosing 8 bit but
// inaccurate colors are better than completely broken rendering.
// See https://github.com/kovidgoyal/kitty/issues/9068
status.fmt = GL_RGBA;
free_framebuffer(framebuffer_id);
free_texture(texture_id);
setup_texture_as_render_target(width, height, texture_id, framebuffer_id);
log_error("WARNING: Your GPU driver does not support 16bit textures as framebuffer targets, some colors may be slightly inaccurate.");
} else {
fatal("Your GPU driver does not support indirect rendering to a GL_RGBA texture via a framebuffer");
}
}
}
}
static void
@@ -1241,14 +1261,13 @@ draw_cursor_trail(CursorTrail *trail, Window *active_window) {
// OSWindow {{{
static void
draw_bg_image(OSWindow *os_window) {
draw_bg_image(OSWindow *os_window, Tab *tab) {
if (!has_bgimage(os_window)) return;
BackgroundImageRenderSettings s = {
.os_window.width = os_window->viewport_width, .os_window.height = os_window->viewport_height,
.instance_id = os_window->bgimage->id, .layout=OPT(background_image_layout),
.linear=OPT(background_image_linear), .bgcolor=OPT(background), .opacity=effective_os_window_alpha(os_window),
};
bind_program(BGIMAGE_PROGRAM);
GLfloat iwidth = os_window->bgimage->width, iheight = os_window->bgimage->height;
GLfloat vwidth = s.os_window.width, vheight = s.os_window.height;
if (CENTER_SCALED == OPT(background_image_layout)) {
@@ -1278,6 +1297,9 @@ draw_bg_image(OSWindow *os_window) {
bottom += hfrac;
} break;
}
bind_program(BGIMAGE_PROGRAM);
// altough we dont use this VO we need to ensure *some* VAO is bound at this point.
bind_vertex_array(tab->border_rects.vao_idx);
glUniform4f(bgimage_program_layout.uniforms.sizes, vwidth, vheight, iwidth, iheight);
glUniform1f(bgimage_program_layout.uniforms.tiled, tiled);
glUniform4f(bgimage_program_layout.uniforms.positions, left, top, right, bottom);
@@ -1339,7 +1361,7 @@ blank_os_window(OSWindow *osw) {
}
static void
start_os_window_rendering(OSWindow *os_window) {
start_os_window_rendering(OSWindow *os_window, Tab *tab) {
if (os_window->live_resize.in_progress) {
blank_os_window(os_window);
save_viewport_using_bottom_left_origin(0, 0, os_window->viewport_width, os_window->viewport_height);
@@ -1357,7 +1379,7 @@ start_os_window_rendering(OSWindow *os_window) {
set_framebuffer_to_use_for_output(os_window->indirect_output.framebuffer_id);
bind_framebuffer_for_output(0);
clear_current_framebuffer();
draw_bg_image(os_window);
draw_bg_image(os_window, tab);
}
}
@@ -1382,7 +1404,7 @@ stop_os_window_rendering(OSWindow *os_window, Tab *tab, Window *active_window) {
void
setup_os_window_for_rendering(OSWindow *os_window, Tab *tab, Window *active_window, bool start) {
if (start) start_os_window_rendering(os_window);
if (start) start_os_window_rendering(os_window, tab);
else stop_os_window_rendering(os_window, tab, active_window);
}
// }}}

View File

@@ -251,10 +251,14 @@ class Tab: # {{{
def _startup(self, session_tab: SessionTab) -> None:
target_tab = self
boss = get_boss()
for window in session_tab.windows:
active_window_id = 0
did_focus_matching_spec = False
first_window_id = 0
for i, window in enumerate(session_tab.windows):
spec = window.launch_spec
launched_window: Window | None = None
if isinstance(spec, SpecialWindowInstance):
self.new_special_window(spec)
launched_window = self.new_special_window(spec)
else:
from .launch import launch
spec.opts.add_to_session = self.created_in_session_name
@@ -263,6 +267,12 @@ class Tab: # {{{
startup_command_via_shell_integration=window.run_command_at_shell_startup)
if launched_window is not None:
launched_window.serialized_id = window.serialized_id
if launched_window is not None:
if not first_window_id:
first_window_id = launched_window.id
if session_tab.active_window_idx == i:
active_window_id = launched_window.id
did_focus_matching_spec = False
if window.resize_spec is not None:
self.resize_window(*window.resize_spec)
if window.focus_matching_window_spec:
@@ -275,6 +285,8 @@ class Tab: # {{{
):
tab = w.tabref()
if tab:
did_focus_matching_spec = True
active_window_id = 0
target_tab = tab or self
tm = tab.tab_manager_ref()
if tm and boss.active_tab is not target_tab:
@@ -283,8 +295,10 @@ class Tab: # {{{
target_tab.set_active_window(w)
boss.focus_os_window(w.os_window_id)
with suppress(IndexError):
self.windows.set_active_window_group_for(self.windows.all_windows[session_tab.active_window_idx])
if not did_focus_matching_spec and not active_window_id:
active_window_id = first_window_id
if active_window_id and not did_focus_matching_spec:
self.windows.set_active_window_group_for(active_window_id)
if session_tab.layout_state:
self.current_layout.unserialize(session_tab.layout_state, self.windows)
@@ -1374,12 +1388,21 @@ class TabManager: # {{{
while self.active_tab_history and self.active_tab_history[-1] == tab.id:
self.active_tab_history.pop()
def previous_active_tab() -> Tab | None:
while self.active_tab_history:
tab_id = self.active_tab_history.pop()
if tab_id != removed_tab.id:
if (ans := self.tab_for_id(tab_id)) is not None:
return ans
return self.tabs[0] if self.tabs else None
if active_tab_before_removal is removed_tab:
if len(self.tabs) == 0:
self._active_tab_idx = 0
elif len(self.tabs) == 1:
remove_from_end_of_active_history(self.tabs[0])
self._set_active_tab(0, store_in_history=False)
if len(tabs) == 0 or (len(tabs) == 1 and removed_tab is tabs[0]):
tab = previous_active_tab()
if tab is None:
self._active_tab_idx = 0
else:
self._set_active_tab(self.tabs.index(tab), store_in_history=False)
else:
next_active_tab: Tab | None = None
match get_options().tab_switch_strategy:
@@ -1387,6 +1410,8 @@ class TabManager: # {{{
while self.active_tab_history and next_active_tab is None:
tab_id = self.active_tab_history.pop()
next_active_tab = self.tab_for_id(tab_id)
if next_active_tab not in tabs:
next_active_tab = None
case 'left':
next_active_tab = tabs[(tabs.index(active_tab_before_removal) - 1 + len(tabs)) % len(tabs)]
remove_from_end_of_active_history(next_active_tab)

View File

@@ -11,11 +11,13 @@ from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Se
from contextlib import contextmanager, suppress
from functools import lru_cache
from re import Match, Pattern
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
Any,
BinaryIO,
NamedTuple,
NoReturn,
Optional,
cast,
)
@@ -540,7 +542,8 @@ def get_editor_from_env(env: Mapping[str, str]) -> str | None:
def get_editor_from_env_vars(opts: Options | None = None) -> list[str]:
editor = get_editor_from_env(os.environ)
from .child import default_env
editor = get_editor_from_env(default_env())
if not editor:
shell_env = read_shell_environment(opts)
editor = get_editor_from_env(shell_env)
@@ -578,6 +581,15 @@ def get_editor(opts: Options | None = None, path_to_edit: str = '', line_number:
return ans
def edit_file(path: str = '') -> NoReturn:
' This exists for: map whatever launch kitty +runpy "from kitty.utils import *; edit_file()" to edit kitty config '
from .config import prepare_config_file_for_editing
editor = get_editor()
path = path or prepare_config_file_for_editing()
editor.append(path)
os.execlp(editor[0], *editor)
def is_path_in_temp_dir(path: str) -> bool:
if not path:
return False
@@ -743,62 +755,72 @@ def which(name: str, only_system: bool = False) -> str | None:
return None
def read_shell_environment(opts: Options | None = None) -> dict[str, str]:
ans: dict[str, str] | None = getattr(read_shell_environment, 'ans', None)
if ans is None:
from .child import openpty
ans = {}
setattr(read_shell_environment, 'ans', ans)
import subprocess
shell = resolved_shell(opts)
master, slave = openpty()
os.set_blocking(master, False)
if '-l' not in shell and '--login' not in shell:
shell += ['-l']
if '-i' not in shell and '--interactive' not in shell:
shell += ['-i']
try:
p = subprocess.Popen(
shell + ['-c', 'env'], stdout=slave, stdin=slave, stderr=slave, start_new_session=True, close_fds=True,
preexec_fn=clear_handled_signals)
except FileNotFoundError:
log_error('Could not find shell to read environment')
return ans
with os.fdopen(master, 'rb') as stdout, os.fdopen(slave, 'wb'):
raw = b''
from time import monotonic
start_time = monotonic()
while monotonic() - start_time < 1.5:
@lru_cache(4)
def read_resolved_shell_environment(shell: tuple[str, ...]) -> MappingProxyType[str, str]:
import subprocess
cmdline = list(shell)
if '-l' not in cmdline and '--login' not in cmdline:
cmdline += ['-l']
if '-i' not in cmdline and '--interactive' not in cmdline:
cmdline += ['-i']
q = os.path.basename(cmdline[0]).lower()
has_builtin = q in ('bash', 'zsh')
cmd = 'builtin command env -0' if has_builtin else 'command env -0'
ans: MappingProxyType[str, str] = MappingProxyType({})
from .child import openpty
master, slave = openpty()
os.set_blocking(master, False)
try:
p = subprocess.Popen(
cmdline + ['-c', cmd], stdout=slave, stdin=slave, stderr=slave, start_new_session=True, close_fds=True,
preexec_fn=clear_handled_signals)
except FileNotFoundError:
log_error(f'Could not find shell {cmdline[0]} to read environment')
return ans
with os.fdopen(master, 'rb') as stdout, os.fdopen(slave, 'wb'):
raw = b''
from time import monotonic
start_time = monotonic()
ret: int | None = None
while monotonic() - start_time < 1.5:
try:
ret = p.wait(0.01)
except subprocess.TimeoutExpired:
ret = None
with suppress(Exception):
raw += stdout.read()
if ret is not None:
break
if ret is None:
log_error(f'Timed out waiting for shell {cmdline} to quit while reading shell environment')
p.kill()
elif ret == 0:
while True:
try:
ret: int | None = p.wait(0.01)
except subprocess.TimeoutExpired:
ret = None
with suppress(Exception):
raw += stdout.read()
if ret is not None:
x = stdout.read()
except Exception:
break
if cast(Optional[int], p.returncode) is None:
log_error('Timed out waiting for shell to quit while reading shell environment')
p.kill()
elif p.returncode == 0:
while True:
try:
x = stdout.read()
except Exception:
break
if not x:
break
raw += x
draw = raw.decode('utf-8', 'replace')
for line in draw.splitlines():
k, v = line.partition('=')[::2]
if k and v:
ans[k] = v
else:
log_error('Failed to run shell to read its environment')
if not x:
break
raw += x
draw = raw.decode('utf-8', 'replace')
env = {}
for line in draw.split('\0'):
k, sep, v = line.partition('=')
if k and v and sep:
env[k] = v
ans = MappingProxyType(env)
else:
log_error(f'Failed to run shell {cmdline} to read its environment')
return ans
def read_shell_environment(opts: Options | None = None) -> MappingProxyType[str, str]:
shell = resolved_shell(opts)
return read_resolved_shell_environment(tuple(shell))
def parse_uri_list(text: str) -> Generator[str, None, None]:
' Get paths from file:// URLs '
from urllib.parse import unquote, urlparse

View File

@@ -718,12 +718,12 @@ class Window:
self.title_stack: Deque[str] = deque(maxlen=10)
self.user_vars: dict[str, str] = {}
self.id: int = add_window(tab.os_window_id, tab.id, self.title)
if not self.id:
raise Exception(f'No tab with id: {tab.id} in OS Window: {tab.os_window_id} was found, or the window counter wrapped')
self.clipboard_request_manager = ClipboardRequestManager(self.id)
self.margin = EdgeWidths()
self.padding = EdgeWidths()
self.kitten_result: dict[str, Any] | None = None
if not self.id:
raise Exception(f'No tab with id: {tab.id} in OS Window: {tab.os_window_id} was found, or the window counter wrapped')
self.tab_id = tab.id
self.os_window_id = tab.os_window_id
self.tabref: Callable[[], TabType | None] = weakref.ref(tab)

View File

@@ -33,6 +33,9 @@ class WindowGroup:
self.windows: list[WindowType] = []
self.id = next(group_id_counter)
def __repr__(self) -> str:
return f'WindowGroup(id={self.id}, windows={", ".join(str(w.id) for w in self.windows)})'
def __len__(self) -> int:
return len(self.windows)

View File

@@ -17,6 +17,7 @@ type Entry struct {
type Metadata struct {
TotalSize int64
PathMap map[string]string
SortedEntries []*Entry
}
@@ -24,35 +25,55 @@ type DiskCache struct {
Path string
MaxSize int64
lock_file *os.File
lock_mutex sync.Mutex
entries Metadata
entry_map map[string]*Entry
entries_mod_time time.Time
get_dir string
lock_file *os.File
lock_mutex sync.Mutex
entries Metadata
entry_map map[string]*Entry
entries_last_read_state *file_state
entries_dirty bool
get_dir string
read_count int
}
func NewDiskCache(path string, max_size int64) (dc *DiskCache, err error) {
return new_disk_cache(path, max_size)
}
func KeyForPath(path string) (key string, err error) {
return key_for_path(path)
func (dc *DiskCache) ResultsDir() string {
return dc.get_dir
}
func (dc *DiskCache) Get(key string, items ...string) map[string]string {
func (dc *DiskCache) Get(key string, items ...string) (map[string]string, error) {
dc.lock()
defer dc.unlock()
return dc.get(key, items)
}
func (dc *DiskCache) GetPath(path string, items ...string) (string, map[string]string, error) {
dc.lock()
defer dc.unlock()
key, err := key_for_path(path)
if err != nil {
return "", nil, err
}
ans, err := dc.get(key, items)
return key, ans, err
}
func (dc *DiskCache) Remove(key string) (err error) {
dc.lock()
defer dc.unlock()
return dc.remove(key)
}
func (dc *DiskCache) Add(key string, items map[string][]byte) (err error) {
func (dc *DiskCache) AddPath(path, key string, items map[string][]byte) (ans map[string]string, err error) {
dc.lock()
defer dc.unlock()
ans, err = dc.add_path(path, key, items)
return
}
func (dc *DiskCache) Add(key string, items map[string][]byte) (ans map[string]string, err error) {
dc.lock()
defer dc.unlock()
return dc.add(key, items)

View File

@@ -5,9 +5,12 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
"syscall"
"time"
"github.com/kovidgoyal/kitty/tools/utils"
@@ -15,6 +18,48 @@ import (
var _ = fmt.Print
type file_state struct {
Size int64
ModTime time.Time
Inode uint64
}
func (s file_state) String() string {
return fmt.Sprintf("fs{Size: %d, Inode: %d, ModTime: %s}", s.Size, s.Inode, s.ModTime)
}
func (s *file_state) equal(o *file_state) bool {
return o != nil && s.Size == o.Size && s.ModTime.Equal(o.ModTime) && s.Inode == o.Inode
}
func get_file_state(fi fs.FileInfo) *file_state {
// The Sys() method returns the underlying data source (can be nil).
// For Unix-like systems, it's a *syscall.Stat_t.
stat, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
// For non-Unix systems, you might not have an inode.
// In that case, you can fall back to using only size and mod time.
return &file_state{
Size: fi.Size(),
ModTime: fi.ModTime(),
Inode: 0, // Inode not available
}
}
return &file_state{
Size: fi.Size(),
ModTime: fi.ModTime(),
Inode: stat.Ino,
}
}
func get_file_state_from_path(path string) (*file_state, error) {
if s, err := os.Stat(path); err != nil {
return nil, err
} else {
return get_file_state(s), nil
}
}
func new_disk_cache(path string, max_size int64) (dc *DiskCache, err error) {
if path, err = filepath.Abs(path); err != nil {
return
@@ -28,12 +73,13 @@ func new_disk_cache(path string, max_size int64) (dc *DiskCache, err error) {
if err = ans.ensure_entries(); err != nil {
return
}
if pruned, err := ans.prune(); err != nil {
return nil, err
} else if pruned {
if err = ans.write_entries(); err != nil {
return nil, err
defer func() {
if we := ans.write_entries_if_dirty(); we != nil && err == nil {
err = we
}
}()
if _, err := ans.prune(); err != nil {
return nil, err
}
if ans.get_dir, err = os.MkdirTemp(ans.Path, "getdir-*"); err != nil {
return
@@ -85,19 +131,50 @@ func (dc *DiskCache) unlock() {
func (dc *DiskCache) entries_path() string { return filepath.Join(dc.Path, "entries.json") }
func (dc *DiskCache) write_entries() (err error) {
func (dc *DiskCache) write_entries_if_dirty() (err error) {
if !dc.entries_dirty {
return
}
path := dc.entries_path()
defer func() {
if err == nil {
dc.entries_dirty = false
if s, serr := get_file_state_from_path(path); serr == nil {
dc.entries_last_read_state = s
}
}
}()
if d, err := json.Marshal(dc.entries); err != nil {
return err
} else {
return os.WriteFile(dc.entries_path(), d, 0o600)
// use a rename so that the inode number changes
// dont bother with full utils.AtomicWriteFile() as it is slower
temp := path + ".temp"
removed := false
defer func() {
if !removed {
_ = os.Remove(temp)
removed = true
}
}()
if err = os.WriteFile(temp, d, 0o600); err == nil {
if err = os.Rename(temp, path); err == nil {
removed = true
}
}
return err
}
}
func (dc *DiskCache) rebuild_entries() error {
func (e Entry) String() string {
return fmt.Sprintf("Entry{Key: %s, Size: %d, LastUsed: %s}", e.Key, e.Size, e.LastUsed)
}
func (dc *DiskCache) entries_from_folders() (total_size int64, ans map[string]*Entry, sorted []*Entry, err error) {
if entries, err := os.ReadDir(dc.Path); err != nil {
return err
return 0, nil, nil, err
} else {
ans := make(map[string]*Entry)
ans = make(map[string]*Entry)
var total int64
for _, x := range entries {
if x.IsDir() {
@@ -105,7 +182,7 @@ func (dc *DiskCache) rebuild_entries() error {
key := sub_entries[0].Name()
path := dc.folder_for_key(key)
if file_entries, err := os.ReadDir(path); err == nil {
e := Entry{}
e := Entry{Key: key}
for _, f := range file_entries {
if fi, err := f.Info(); err == nil {
e.Size += fi.Size()
@@ -120,37 +197,58 @@ func (dc *DiskCache) rebuild_entries() error {
}
}
}
sorted := utils.Values(ans)
sorted = utils.Values(ans)
slices.SortFunc(sorted, func(a, b *Entry) int {
return a.LastUsed.Compare(b.LastUsed)
})
dc.entries = Metadata{TotalSize: total, SortedEntries: sorted}
dc.entry_map = ans
return total, ans, sorted, nil
}
return nil
}
func (dc *DiskCache) rebuild_entries() error {
total, ans, sorted, err := dc.entries_from_folders()
if err != nil {
return err
}
dc.entries = Metadata{TotalSize: total, SortedEntries: sorted, PathMap: make(map[string]string)}
dc.entry_map = ans
dc.entries_dirty = true
return dc.write_entries_if_dirty()
}
func (dc *DiskCache) ensure_entries() error {
needed := dc.entry_map == nil
needed := dc.entry_map == nil || dc.entries_last_read_state == nil
path := dc.entries_path()
var fstate *file_state
if !needed {
if s, err := os.Stat(path); err == nil && s.ModTime().After(dc.entries_mod_time) {
needed = true
if s, err := get_file_state_from_path(path); err == nil {
fstate = s
if !s.equal(dc.entries_last_read_state) {
needed = true
}
}
}
if needed {
if data, err := os.ReadFile(path); err != nil {
if os.IsNotExist(err) {
dc.entry_map = make(map[string]*Entry)
dc.entries = Metadata{SortedEntries: make([]*Entry, 0)}
dc.entries = Metadata{SortedEntries: make([]*Entry, 0), PathMap: make(map[string]string)}
} else {
return err
}
} else {
dc.entries = Metadata{SortedEntries: make([]*Entry, 0)}
dc.read_count += 1
dc.entries = Metadata{SortedEntries: make([]*Entry, 0), PathMap: make(map[string]string)}
if err := json.Unmarshal(data, &dc.entries); err != nil {
// corrupted data
dc.rebuild_entries()
} else {
if fstate == nil {
if s, err := get_file_state_from_path(path); err == nil {
fstate = s
}
}
dc.entries_last_read_state = fstate
}
dc.entry_map = make(map[string]*Entry)
for _, e := range dc.entries.SortedEntries {
@@ -170,43 +268,68 @@ func (dc *DiskCache) folder_for_key(key string) (ans string) {
return filepath.Join(dc.Path, ans)
}
func (dc *DiskCache) update_last_used(key string) {
if dc.ensure_entries() == nil {
dc.update_timestamp(key)
func (dc *DiskCache) export_to_get_dir(key, path string) (string, error) {
dest := filepath.Join(dc.get_dir, key+"-"+filepath.Base(path))
if err := os.Link(path, dest); err != nil {
os.Remove(dest)
if err := os.Link(path, dest); err != nil {
return "", err
}
}
return dest, nil
}
func (dc *DiskCache) get(key string, items []string) map[string]string {
ans := make(map[string]string, len(items))
base := dc.folder_for_key(key)
if s, err := os.Stat(base); err != nil || !s.IsDir() {
return ans
func (dc *DiskCache) get(key string, items []string) (map[string]string, error) {
if err := dc.ensure_entries(); err != nil {
return nil, err
}
base := dc.folder_for_key(key)
if len(items) == 0 {
if entries, err := os.ReadDir(base); err != nil {
if os.IsNotExist(err) {
err = nil
}
return nil, err
} else {
for _, e := range entries {
items = append(items, e.Name())
}
}
} else {
if s, err := os.Stat(base); err != nil || !s.IsDir() {
if os.IsNotExist(err) {
err = nil
}
return nil, err
}
}
ans := make(map[string]string, len(items))
for _, x := range items {
p := filepath.Join(base, x)
if s, err := os.Stat(p); err != nil || s.IsDir() {
continue
}
dest := filepath.Join(dc.get_dir, key+"-"+x)
if err := os.Link(p, dest); err != nil {
os.Remove(dest)
if err := os.Link(p, dest); err != nil {
dest = ""
}
}
dest, _ := dc.export_to_get_dir(key, p)
if dest != "" {
ans[x] = dest
}
}
dc.update_last_used(key)
return ans
if len(items) > 0 {
dc.update_timestamp(key)
}
return ans, dc.write_entries_if_dirty()
}
func (dc *DiskCache) remove(key string) (err error) {
if err = dc.ensure_entries(); err != nil {
return
}
defer func() {
if we := dc.write_entries_if_dirty(); we != nil && err == nil {
err = we
}
}()
base := dc.folder_for_key(key)
if err = os.RemoveAll(base); err == nil {
t := dc.entry_map[key]
@@ -214,7 +337,7 @@ func (dc *DiskCache) remove(key string) (err error) {
delete(dc.entry_map, key)
dc.entries.TotalSize = max(0, dc.entries.TotalSize-t.Size)
dc.entries.SortedEntries = utils.Filter(dc.entries.SortedEntries, func(x *Entry) bool { return x.Key != key })
return dc.write_entries()
dc.entries_dirty = true
}
}
return
@@ -229,8 +352,10 @@ func (dc *DiskCache) prune() (bool, error) {
if err := os.RemoveAll(base); err == nil {
t := dc.entries.SortedEntries[0]
delete(dc.entry_map, t.Key)
maps.DeleteFunc(dc.entries.PathMap, func(path, key string) bool { return key == t.Key })
dc.entries.TotalSize = max(0, dc.entries.TotalSize-t.Size)
dc.entries.SortedEntries = dc.entries.SortedEntries[1:]
dc.entries_dirty = true
} else {
return false, err
}
@@ -244,6 +369,7 @@ func (dc *DiskCache) update_timestamp(key string) {
idx := slices.Index(dc.entries.SortedEntries, t)
copy(dc.entries.SortedEntries[idx:], dc.entries.SortedEntries[idx+1:])
dc.entries.SortedEntries[len(dc.entries.SortedEntries)-1] = t
dc.entries_dirty = true
}
func (dc *DiskCache) update_accounting(key string, changed int64) (err error) {
@@ -257,25 +383,52 @@ func (dc *DiskCache) update_accounting(key string, changed int64) (err error) {
t.Size += changed
t.Size = max(0, t.Size)
dc.entries.TotalSize += t.Size - old_size
dc.entries_dirty = true
dc.update_timestamp(key)
dc.prune()
return dc.write_entries()
return dc.write_entries_if_dirty()
}
func (dc *DiskCache) keys() (ans []string, err error) {
if err = dc.ensure_entries(); err != nil {
return
}
return utils.Keys(dc.entry_map), nil
ans = make([]string, len(dc.entries.SortedEntries))
for i, e := range dc.entries.SortedEntries {
ans[i] = e.Key
}
return
}
func (dc *DiskCache) add(key string, items map[string][]byte) (err error) {
func (dc *DiskCache) add_path(path, key string, items map[string][]byte) (ans map[string]string, err error) {
if err = dc.ensure_entries(); err != nil {
return
}
defer func() {
if we := dc.write_entries_if_dirty(); we != nil && err == nil {
err = we
}
}()
if existing := dc.entries.PathMap[path]; existing != "" && existing != key {
delete(dc.entries.PathMap, path)
dc.entries_dirty = true
if err = dc.remove(existing); err != nil {
return
}
}
dc.entries.PathMap[path] = key
dc.entries_dirty = true
return dc.add(key, items)
}
func (dc *DiskCache) add(key string, items map[string][]byte) (ans map[string]string, err error) {
if err = dc.ensure_entries(); err != nil {
return
}
base := dc.folder_for_key(key)
if err = os.MkdirAll(base, 0o700); err != nil {
return err
return
}
var changed int64
defer func() {
@@ -284,22 +437,40 @@ func (dc *DiskCache) add(key string, items map[string][]byte) (err error) {
err = e
}
}()
ans = make(map[string]string, len(items))
for x, data := range items {
p := filepath.Join(base, x)
var before int64
exists := false
if s, err := os.Stat(p); err == nil {
before = s.Size()
exists = true
}
if len(data) == 0 {
if err = os.Remove(p); err != nil {
return
if exists {
if err = os.Remove(p); err != nil {
if !os.IsNotExist(err) {
return
}
err = nil
}
changed -= before
}
changed -= before
} else {
// unlink the file so that writing to it does not change a
// previously linked copy created by get()
if exists {
_ = os.Remove(p)
}
if err = os.WriteFile(p, data, 0o700); err != nil {
return
}
changed += int64(len(data)) - before
if dest, err := dc.export_to_get_dir(key, p); err != nil {
return ans, err
} else {
ans[x] = dest
}
}
}
return

View File

@@ -3,7 +3,8 @@ package disk_cache
import (
"fmt"
"os"
"slices"
"path/filepath"
"runtime/debug"
"strings"
"testing"
@@ -23,16 +24,45 @@ func TestDiskCache(t *testing.T) {
if err != nil {
t.Fatal(err)
}
ensure_entries := func() {
for _, x := range []*DiskCache{dc, dc2} {
if err = x.ensure_entries(); err != nil {
t.Fatal(err)
}
}
m := dc.Get("missing", "one", "two")
if diff := cmp.Diff(m, make(map[string]string)); diff != "" {
t.Fatalf("Unexpected return from missing: %s", diff)
}
dc.Add("k1", map[string][]byte{"1": []byte("abcd"), "2": []byte("efgh")})
arc := func(counts ...int) {
ensure_entries()
if diff := cmp.Diff(counts, []int{dc.read_count, dc2.read_count}); diff != "" {
t.Fatalf("disk cache has unexpected read count\n%s\n%s", diff, debug.Stack())
}
}
add := func(dc *DiskCache, key string, data map[string]string) {
d := make(map[string][]byte, len(data))
for k, v := range data {
d[k] = []byte(v)
}
if _, err := dc.Add(key, d); err != nil {
t.Fatal(err)
}
ensure_entries()
}
m, err := dc.Get("missing", "one", "two")
if err != nil {
t.Fatal(err)
}
if len(m) > 0 {
t.Fatalf("Unexpected return from missing: %s", m)
}
ad := func(key string, expected map[string]string) {
for _, x := range []*DiskCache{dc, dc2} {
actual := x.Get(key, utils.Keys(expected)...)
actual, err := x.Get(key, utils.Keys(expected)...)
if err != nil {
t.Fatal(err)
}
for k, path := range actual {
d, err := os.ReadFile(path)
@@ -44,35 +74,78 @@ func TestDiskCache(t *testing.T) {
if diff := cmp.Diff(expected, actual); diff != "" {
t.Fatalf("Data for %s not equal: %s", key, diff)
}
ensure_entries()
}
}
ak := func(keys ...string) {
for _, x := range []*DiskCache{dc, dc2} {
for i, x := range []*DiskCache{dc, dc2} {
kk, err := x.keys()
if err != nil {
t.Fatal(err)
}
slices.Sort(kk)
slices.Sort(keys)
if diff := cmp.Diff(keys, kk); diff != "" {
t.Fatalf("Unexpected keys: %s", diff)
t.Fatalf("wrong keys in %d: %s", i+1, diff)
}
}
ensure_entries()
}
add(dc, "k1", map[string]string{"1": "abcd", "2": "efgh"})
arc(0, 1)
ad("k1", map[string]string{"1": "abcd", "2": "efgh"})
dc.Add("k1", map[string][]byte{"3": []byte("ijk"), "4": []byte("lmo")})
dc2.Add("k2", map[string][]byte{"1": []byte("123456789")})
arc(1, 2) // the two gets cause two updates
add(dc, "k1", map[string]string{"3": "ijk", "4": "lmo"})
arc(1, 3) // dc.Add() causes re-read in dc2
ak("k1")
arc(1, 3)
add(dc2, "k2", map[string]string{"1": "123456789"})
arc(2, 3) // dc2.Add() causes re-read in dc
ak("k1", "k2")
arc(2, 3)
ad("k1", map[string]string{"1": "abcd", "2": "efgh", "3": "ijk"})
if dc.entries.TotalSize != 14+9 {
t.Fatalf("TotalSize: %d != %d", dc.entries.TotalSize, 14+9)
}
arc(3, 4) // the two gets cause two updates
ak("k2", "k1")
dc.Get("k2")
arc(3, 5) // dc.Get() causes dc2 to read
ak("k1", "k2")
dc.Add("k3", map[string][]byte{"1": []byte(strings.Repeat("a", int(dc.MaxSize)-10))})
ak("k3", "k2")
add(dc, "k3", map[string]string{"1": strings.Repeat("a", int(dc.MaxSize)-10)})
arc(3, 6) // dc.Add() causes dc2 to read
ak("k2", "k3")
// check that creating a new disk cache prunes
_, err = NewDiskCache(tdir, dc.MaxSize-8)
if err != nil {
t.Fatal(err)
}
ak("k3")
arc(4, 7) // NewDiskCache()
// test the path api
path := filepath.Join(tdir, "source")
if err = os.WriteFile(path, []byte("abcdfjrof"), 0o600); err != nil {
t.Fatal(err)
}
key, _, err := dc.GetPath(path)
if _, err = dc.AddPath(path, key, map[string][]byte{"1": []byte("1")}); err != nil {
t.Fatal(err)
}
_, _, entries_before, _ := dc.entries_from_folders()
if diff := cmp.Diff(1, len(dc.entries.PathMap)); diff != "" {
t.Fatalf("unexpected pathmap count: %s", diff)
}
if err = os.WriteFile(path, []byte("changed contents"), 0o600); err != nil {
t.Fatal(err)
}
if _, err = dc.AddPath(path, key, map[string][]byte{"1": []byte("2")}); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(1, len(dc.entries.PathMap)); diff != "" {
t.Fatalf("unexpected pathmap count: %s", diff)
}
_, _, entries_after, _ := dc.entries_from_folders()
if len(entries_before) != len(entries_after) {
t.Fatalf("unexpected entries: %s", entries_after)
}
arc(4, 8) // dc.AddPath() causes dc2 to read
}

View File

@@ -101,6 +101,7 @@ func DownloadFileWithProgress(destpath, url string, kill_if_signaled bool) (err
}
do_download := func() {
lp.RecoverFromPanicInGoRoutine()
dl_data.mutex.Lock()
dl_data.download_started = true
dl_data.mutex.Unlock()

View File

@@ -136,14 +136,16 @@ func (self *ImageCollection) ResizeForPageSize(width, height int) {
ctx := images.Context{}
keys := utils.Keys(self.images)
ctx.Parallel(0, len(keys), func(nums <-chan int) {
if err := ctx.SafeParallel(0, len(keys), func(nums <-chan int) {
for i := range nums {
img := self.images[keys[i]]
if img.src.loaded && img.err == nil {
img.ResizeForPageSize(width, height)
}
}
})
}); err != nil {
panic(err)
}
}
func (self *ImageCollection) DeleteAllVisiblePlacements(lp *loop.Loop) {
@@ -294,7 +296,7 @@ func (self *ImageCollection) LoadAll() {
defer self.mutex.Unlock()
ctx := images.Context{}
all := utils.Values(self.images)
ctx.Parallel(0, len(self.images), func(nums <-chan int) {
if err := ctx.SafeParallel(0, len(self.images), func(nums <-chan int) {
for i := range nums {
img := all[i]
if !img.src.loaded {
@@ -305,7 +307,9 @@ func (self *ImageCollection) LoadAll() {
img.src.loaded = true
}
}
})
}); err != nil {
panic(err)
}
}
func NewImageCollection(paths ...string) *ImageCollection {

View File

@@ -44,6 +44,12 @@ func read_ignoring_temporary_errors(f *tty.Term, buf []byte) (int, error) {
}
func read_from_tty(pipe_r *os.File, term *tty.Term, results_channel chan<- []byte, err_channel chan<- error, quit_channel <-chan byte, leftover_channel chan<- []byte) {
defer func() {
if r := recover(); r != nil {
text, _ := utils.Format_stacktrace_on_panic(r)
err_channel <- fmt.Errorf("%s", text)
}
}()
keep_going := true
pipe_fd := int(pipe_r.Fd())
tty_fd := term.Fd()
@@ -115,6 +121,12 @@ func read_until_primary_device_attributes_response(term *tty.Term, initial_bytes
}
received := make(chan error)
go func() {
defer func() {
if r := recover(); r != nil {
text, _ := utils.Format_stacktrace_on_panic(r)
received <- fmt.Errorf("%s", text)
}
}()
buf := make([]byte, 1024)
n, err := read_ignoring_temporary_errors(term, buf)
if n > 0 {

View File

@@ -168,6 +168,12 @@ func write_to_tty(
pipe_r *os.File, term *tty.Term,
job_channel <-chan write_msg, err_channel chan<- error, write_done_channel chan<- IdType,
) {
defer func() {
if r := recover(); r != nil {
text, _ := utils.Format_stacktrace_on_panic(r)
err_channel <- fmt.Errorf("%s", text)
}
}()
keep_going := true
defer func() {
pipe_r.Close()

View File

@@ -99,6 +99,9 @@ func find_matching_codepoints(prefix string) (ans mark_set) {
ans.AddItems(marks...)
}
}
if ans == nil {
ans = utils.NewSet[uint16](0)
}
return ans
}
@@ -107,11 +110,13 @@ func marks_for_query(query string) (ans mark_set) {
prefixes := strings.Split(strings.ToLower(query), " ")
results := make(chan mark_set, len(prefixes))
ctx := images.Context{}
ctx.Parallel(0, len(prefixes), func(nums <-chan int) {
if err := ctx.SafeParallel(0, len(prefixes), func(nums <-chan int) {
for i := range nums {
results <- find_matching_codepoints(prefixes[i])
}
})
}); err != nil {
panic(err)
}
close(results)
for x := range results {
if ans == nil {

View File

@@ -5,8 +5,10 @@ import (
"encoding/binary"
"fmt"
"image"
"image/color"
"io"
"os"
"slices"
"strings"
"github.com/kovidgoyal/kitty/tools/cli"
@@ -17,9 +19,9 @@ var _ = fmt.Print
func encode_rgba(output io.Writer, img image.Image) (err error) {
var final_img *image.NRGBA
switch img.(type) {
switch ti := img.(type) {
case *image.NRGBA:
final_img = img.(*image.NRGBA)
final_img = ti
default:
b := img.Bounds()
final_img = image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
@@ -68,6 +70,48 @@ func convert_image(input io.ReadSeeker, output io.Writer, format string) (err er
return Encode(output, img, mt)
}
func PalettedToNRGBA(paletted *image.Paletted) *image.NRGBA {
bounds := paletted.Bounds()
nrgba := image.NewNRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
c := color.NRGBAModel.Convert(paletted.At(x, y))
nrgba.Set(x, y, c)
}
}
return nrgba
}
func develop_serialize(input_data []byte) (err error) {
img, err := OpenNativeImageFromReader(bytes.NewReader(input_data))
if err != nil {
return err
}
m, b := img.Serialize()
rimg, err := ImageFromSerialized(m, b)
if err != nil {
return err
}
for i := range img.Frames {
a, b := img.Frames[i], rimg.Frames[i]
if a.Img.Bounds() != b.Img.Bounds() {
return fmt.Errorf("bounds of frame %d not equal: %v != %v", i, a.Img.Bounds(), b.Img.Bounds())
}
for y := a.Img.Bounds().Min.Y; y < a.Img.Bounds().Max.Y; y++ {
for x := a.Img.Bounds().Min.X; x < a.Img.Bounds().Max.X; x++ {
or, og, ob, oa := a.Img.At(x, y).RGBA()
nr, ng, nb, na := b.Img.At(x, y).RGBA()
a, b := []uint32{or, og, ob, oa}, []uint32{nr, ng, nb, na}
if !slices.Equal(a, b) {
return fmt.Errorf("pixel at %dx%d differs: %v != %v", x, y, a, b)
}
}
}
}
return
}
func ConvertEntryPoint(root *cli.Command) {
root.AddSubCommand(&cli.Command{
Name: "__convert_image__",
@@ -82,6 +126,11 @@ func ConvertEntryPoint(root *cli.Command) {
if _, err = io.Copy(buf, os.Stdin); err != nil {
return 1, err
}
if format == "develop-serialize" {
err = develop_serialize(buf.Bytes())
rc = utils.IfElse(err == nil, 0, 1)
return
}
if err = convert_image(bytes.NewReader(buf.Bytes()), os.Stdout, format); err != nil {
rc = 1
}

View File

@@ -10,6 +10,7 @@ import (
"image"
"image/color"
"image/gif"
"image/png"
"io"
"os"
"os/exec"
@@ -53,6 +54,27 @@ type ImageFrame struct {
Img image.Image
}
type SerializableImageFrame struct {
Width, Height, Left, Top int
Number int // 1-based number
Compose_onto int // number of frame to compose onto
Delay_ms int // negative for gapless frame, zero ignored, positive is number of ms
Is_opaque bool
Size int
}
func (s SerializableImageFrame) NeededSize() int {
return utils.IfElse(s.Is_opaque, 3, 4) * s.Width * s.Height
}
func (s *ImageFrame) Serialize() SerializableImageFrame {
return SerializableImageFrame{
Width: s.Width, Height: s.Height, Left: s.Left, Top: s.Top,
Number: s.Number, Compose_onto: s.Compose_onto, Delay_ms: int(s.Delay_ms),
Is_opaque: s.Is_opaque,
}
}
func (self *ImageFrame) DataAsSHM(pattern string) (ans shm.MMap, err error) {
bytes_per_pixel := 4
if self.Is_opaque {
@@ -122,12 +144,82 @@ func (self *ImageFrame) Data() (ans []byte) {
return
}
func ImageFrameFromSerialized(s SerializableImageFrame, data []byte) (aa *ImageFrame, err error) {
ans := ImageFrame{
Width: s.Width, Height: s.Height, Left: s.Left, Top: s.Top,
Number: s.Number, Compose_onto: s.Compose_onto, Delay_ms: int32(s.Delay_ms),
Is_opaque: s.Is_opaque,
}
bytes_per_pixel := utils.IfElse(s.Is_opaque, 3, 4)
if expected := bytes_per_pixel * s.Width * s.Height; len(data) != expected {
return nil, fmt.Errorf("serialized image data has size: %d != %d", len(data), expected)
}
if s.Is_opaque {
ans.Img, err = NewNRGBWithContiguousRGBPixels(data, s.Left, s.Top, s.Width, s.Height)
} else {
ans.Img, err = NewNRGBAWithContiguousRGBAPixels(data, s.Left, s.Top, s.Width, s.Height)
}
return &ans, err
}
type ImageData struct {
Width, Height int
Format_uppercase string
Frames []*ImageFrame
}
type SerializableImageMetadata struct {
Version int
Width, Height int
Format_uppercase string
Frames []SerializableImageFrame
}
const SERIALIZE_VERSION = 1
func (self *ImageFrame) SaveAsUncompressedPNG(output io.Writer) error {
encoder := png.Encoder{CompressionLevel: png.NoCompression}
return encoder.Encode(output, self.Img)
}
func (self *ImageData) SerializeOnlyMetadata() SerializableImageMetadata {
f := make([]SerializableImageFrame, len(self.Frames))
for i, s := range self.Frames {
f[i] = s.Serialize()
}
return SerializableImageMetadata{Version: SERIALIZE_VERSION, Width: self.Width, Height: self.Height, Format_uppercase: self.Format_uppercase, Frames: f}
}
func (self *ImageData) Serialize() (SerializableImageMetadata, [][]byte) {
m := self.SerializeOnlyMetadata()
data := make([][]byte, len(self.Frames))
for i, f := range self.Frames {
data[i] = f.Data()
m.Frames[i].Size = len(data[i])
}
return m, data
}
func ImageFromSerialized(m SerializableImageMetadata, data [][]byte) (*ImageData, error) {
if m.Version > SERIALIZE_VERSION {
return nil, fmt.Errorf("serialized image data has unsupported version: %d", m.Version)
}
if len(m.Frames) != len(data) {
return nil, fmt.Errorf("serialized image data has %d frames in metadata but have data for: %d", len(m.Frames), len(data))
}
ans := ImageData{
Width: m.Width, Height: m.Height, Format_uppercase: m.Format_uppercase,
}
for i, f := range m.Frames {
if ff, err := ImageFrameFromSerialized(f, data[i]); err != nil {
return nil, err
} else {
ans.Frames = append(ans.Frames, ff)
}
}
return &ans, nil
}
func (self *ImageFrame) Resize(x_frac, y_frac float64) *ImageFrame {
b := self.Img.Bounds()
left, top, width, height := b.Min.X, b.Min.Y, b.Dx(), b.Dy()
@@ -194,29 +286,7 @@ func MakeTempDir(template string) (ans string, err error) {
return os.MkdirTemp("", template)
}
func check_resize(frame *ImageFrame, filename string) error {
// ImageMagick sometimes generates RGBA images smaller than the specified
// size. See https://github.com/kovidgoyal/kitty/issues/276 for examples
s, err := os.Stat(filename)
if err != nil {
return err
}
sz := int(s.Size())
bytes_per_pixel := 4
if frame.Is_opaque {
bytes_per_pixel = 3
}
expected_size := bytes_per_pixel * frame.Width * frame.Height
if sz < expected_size {
missing := expected_size - sz
if missing%(bytes_per_pixel*frame.Width) != 0 {
return fmt.Errorf("ImageMagick failed to resize correctly. It generated %d < %d of data (w=%d h=%d bpp=%d)", sz, expected_size, frame.Width, frame.Height, bytes_per_pixel)
}
frame.Height -= missing / (bytes_per_pixel * frame.Width)
}
return nil
}
// Native {{{
func (frame *ImageFrame) set_delay(min_gap, delay int) {
frame.Delay_ms = int32(max(min_gap, delay) * 10)
if frame.Delay_ms == 0 {
@@ -266,10 +336,36 @@ func OpenNativeImageFromReader(f io.ReadSeeker) (ans *ImageData, err error) {
return
}
// }}}
// ImageMagick {{{
var MagickExe = sync.OnceValue(func() string {
return utils.FindExe("magick")
})
func check_resize(frame *ImageFrame, filename string) error {
// ImageMagick sometimes generates RGBA images smaller than the specified
// size. See https://github.com/kovidgoyal/kitty/issues/276 for examples
s, err := os.Stat(filename)
if err != nil {
return err
}
sz := int(s.Size())
bytes_per_pixel := 4
if frame.Is_opaque {
bytes_per_pixel = 3
}
expected_size := bytes_per_pixel * frame.Width * frame.Height
if sz < expected_size {
missing := expected_size - sz
if missing%(bytes_per_pixel*frame.Width) != 0 {
return fmt.Errorf("ImageMagick failed to resize correctly. It generated %d < %d of data (w=%d h=%d bpp=%d)", sz, expected_size, frame.Width, frame.Height, bytes_per_pixel)
}
frame.Height -= missing / (bytes_per_pixel * frame.Width)
}
return nil
}
func RunMagick(path string, cmd []string) ([]byte, error) {
if MagickExe() != "magick" {
cmd = append([]string{MagickExe()}, cmd...)
@@ -610,6 +706,8 @@ func OpenImageFromPathWithMagick(path string) (ans *ImageData, err error) {
return ans, nil
}
// }}}
func OpenImageFromPath(path string) (ans *ImageData, err error) {
mt := utils.GuessMimeType(path)
if DecodableImageTypes[mt] {
@@ -620,7 +718,7 @@ func OpenImageFromPath(path string) (ans *ImageData, err error) {
defer f.Close()
ans, err = OpenNativeImageFromReader(f)
if err != nil {
return nil, fmt.Errorf("Failed to load image at %#v with error: %w", path, err)
return OpenImageFromPathWithMagick(path)
}
} else {
return OpenImageFromPathWithMagick(path)

View File

@@ -9,6 +9,35 @@ import (
var _ = fmt.Print
func paletted_is_opaque(p *image.Paletted) bool {
if len(p.Palette) > 256 {
return p.Opaque()
}
var is_alpha [256]bool
has_alpha := false
for i, c := range p.Palette {
_, _, _, a := c.RGBA()
if a != 0xffff {
is_alpha[i] = true
has_alpha = true
}
}
if !has_alpha {
return true
}
i0, i1 := 0, p.Rect.Dx()
for y := p.Rect.Min.Y; y < p.Rect.Max.Y; y++ {
for _, c := range p.Pix[i0:i1] {
if is_alpha[c] {
return false
}
}
i0 += p.Stride
i1 += p.Stride
}
return true
}
func IsOpaque(img image.Image) bool {
switch i := img.(type) {
case *image.RGBA:
@@ -30,7 +59,7 @@ func IsOpaque(img image.Image) bool {
case *image.CMYK:
return i.Opaque()
case *image.Paletted:
return i.Opaque()
return paletted_is_opaque(i)
case *image.Uniform:
return i.Opaque()
case *image.YCbCr:

View File

@@ -0,0 +1,31 @@
package images
import (
"bytes"
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/kovidgoyal/kitty"
)
var _ = fmt.Print
func TestImageSerialize(t *testing.T) {
img, err := OpenNativeImageFromReader(bytes.NewReader(kitty.KittyLogoAsPNGData))
if err != nil {
t.Fatal(err)
}
m, data := img.Serialize()
img2, err := ImageFromSerialized(m, data)
if err != nil {
t.Fatal(err)
}
m2, data2 := img2.Serialize()
if diff := cmp.Diff(m, m2); diff != "" {
t.Fatalf("Image metadata failed to roundtrip:\n%s", diff)
}
if diff := cmp.Diff(data, data2); diff != "" {
t.Fatalf("Image data failed to roundtrip:\n%s", diff)
}
}

View File

@@ -25,14 +25,14 @@ func (c NRGBColor) RGBA() (r, g, b, a uint32) {
g |= g << 8
b = uint32(c.B)
b |= b << 8
a = 65280 // ( 255 << 8 )
a = 65535 // (255 << 8 | 255)
return
}
// NRGB is an in-memory image whose At method returns NRGBColor values.
type NRGB struct {
// Pix holds the image's pixels, in R, G, B, A order. The pixel at
// (x, y) starts at Pix[(y-Rect.Min.Y)*Stride + (x-Rect.Min.X)*4].
// Pix holds the image's pixels, in R, G, B order. The pixel at
// (x, y) starts at Pix[(y-Rect.Min.Y)*Stride + (x-Rect.Min.X)*3].
Pix []uint8
// Stride is the Pix stride (in bytes) between vertically adjacent pixels.
Stride int
@@ -45,17 +45,18 @@ func nrgbModel(c color.Color) color.Color {
return c
}
r, g, b, a := c.RGBA()
if a == 0xffff {
switch a {
case 0xffff:
return NRGBColor{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}
case 0:
return NRGBColor{0, 0, 0}
default:
// Since Color.RGBA returns an alpha-premultiplied color, we should have r <= a && g <= a && b <= a.
r = (r * 0xffff) / a
g = (g * 0xffff) / a
b = (b * 0xffff) / a
return NRGBColor{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}
}
if a == 0 {
return NRGBColor{0, 0, 0}
}
// Since Color.RGBA returns an alpha-premultiplied color, we should have r <= a && g <= a && b <= a.
r = (r * 0xffff) / a
g = (g * 0xffff) / a
b = (b * 0xffff) / a
return NRGBColor{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}
}
var NRGBModel color.Model = color.ModelFunc(nrgbModel)
@@ -73,14 +74,14 @@ func (p *NRGB) NRGBAt(x, y int) NRGBColor {
return NRGBColor{}
}
i := p.PixOffset(x, y)
s := p.Pix[i : i+4 : i+4] // Small cap improves performance, see https://golang.org/issue/27857
s := p.Pix[i : i+3 : i+3] // Small cap improves performance, see https://golang.org/issue/27857
return NRGBColor{s[0], s[1], s[2]}
}
// PixOffset returns the index of the first element of Pix that corresponds to
// the pixel at (x, y).
func (p *NRGB) PixOffset(x, y int) int {
return (y-p.Rect.Min.Y)*p.Stride + (x-p.Rect.Min.X)*4
return (y-p.Rect.Min.Y)*p.Stride + (x-p.Rect.Min.X)*3
}
func (p *NRGB) Set(x, y int, c color.Color) {
@@ -170,14 +171,17 @@ func newScannerRGB(img image.Image, opaque_base NRGBColor) *scanner_rgb {
}
if img, ok := img.(*image.Paletted); ok {
s.palette = make([]NRGBColor, max(256, len(img.Palette)))
d := make([]uint8, 3)
d := [3]uint8{0, 0, 0}
ds := d[:]
for i := 0; i < len(img.Palette); i++ {
r, g, b, a := img.Palette[i].RGBA()
switch a {
case 0:
s.palette[i] = opaque_base
case 0xffff:
s.palette[i] = NRGBColor{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}
default:
blend(d, s.opaque_base, uint8((r*0xffff/a)>>8), uint8((g*0xffff/a)>>8), uint8((b*0xffff/a)>>8), uint8(a>>8))
blend(ds, s.opaque_base, uint8((r*0xffff/a)>>8), uint8((g*0xffff/a)>>8), uint8((b*0xffff/a)>>8), uint8(a>>8))
s.palette[i] = NRGBColor{d[0], d[1], d[2]}
}
}
@@ -425,3 +429,15 @@ func NewNRGB(r image.Rectangle) *NRGB {
Rect: r,
}
}
func NewNRGBWithContiguousRGBPixels(p []byte, left, top, width, height int) (*NRGB, error) {
const bpp = 3
if expected := bpp * width * height; expected != len(p) {
return nil, fmt.Errorf("the image width and height dont match the size of the specified pixel data: width=%d height=%d sz=%d != %d", width, height, len(p), expected)
}
return &NRGB{
Pix: p,
Stride: bpp * width,
Rect: image.Rectangle{image.Point{left, top}, image.Point{left + width, top + height}},
}, nil
}

View File

@@ -321,7 +321,7 @@ func (self *Context) run_paste(src Scanner, background image.Image, pos image.Po
default:
panic(fmt.Sprintf("Unsupported image type: %v", v))
}
self.Parallel(interRect.Min.Y, interRect.Max.Y, func(ys <-chan int) {
if err := self.SafeParallel(interRect.Min.Y, interRect.Max.Y, func(ys <-chan int) {
for y := range ys {
x1 := interRect.Min.X - pasteRect.Min.X
x2 := interRect.Max.X - pasteRect.Min.X
@@ -333,7 +333,9 @@ func (self *Context) run_paste(src Scanner, background image.Image, pos image.Po
src.scan(x1, y1, x2, y2, dst)
postprocess(dst)
}
})
}); err != nil {
panic(err)
}
}
@@ -403,3 +405,15 @@ func FitImage(width, height, pwidth, pheight int) (final_width int, final_height
return width, height
}
func NewNRGBAWithContiguousRGBAPixels(p []byte, left, top, width, height int) (*image.NRGBA, error) {
const bpp = 4
if expected := bpp * width * height; expected != len(p) {
return nil, fmt.Errorf("the image width and height dont match the size of the specified pixel data: width=%d height=%d sz=%d != %d", width, height, len(p), expected)
}
return &image.NRGBA{
Pix: p,
Stride: bpp * width,
Rect: image.Rectangle{image.Point{left, top}, image.Point{left + width, left + height}},
}, nil
}

View File

@@ -27,18 +27,20 @@ func reverse_row(bytes_per_pixel int, pix []uint8) {
func (self *Context) FlipPixelsH(bytes_per_pixel, width, height int, pix []uint8) {
stride := bytes_per_pixel * width
self.Parallel(0, height, func(ys <-chan int) {
if err := self.SafeParallel(0, height, func(ys <-chan int) {
for y := range ys {
i := y * stride
reverse_row(bytes_per_pixel, pix[i:i+stride])
}
})
}); err != nil {
panic(err)
}
}
func (self *Context) FlipPixelsV(bytes_per_pixel, width, height int, pix []uint8) {
stride := bytes_per_pixel * width
num := height / 2
self.Parallel(0, num, func(ys <-chan int) {
if err := self.SafeParallel(0, num, func(ys <-chan int) {
for y := range ys {
upper := y
lower := height - 1 - y
@@ -50,6 +52,7 @@ func (self *Context) FlipPixelsV(bytes_per_pixel, width, height int, pix []uint8
as[i], bs[i] = bs[i], as[i]
}
}
})
}); err != nil {
panic(err)
}
}

View File

@@ -7,6 +7,8 @@ import (
"runtime"
"sync"
"sync/atomic"
"github.com/kovidgoyal/kitty/tools/utils"
)
var _ = fmt.Print
@@ -31,8 +33,10 @@ func (self *Context) EffectiveNumberOfThreads() int {
return ans
}
// parallel processes the data in separate goroutines.
func (self *Context) Parallel(start, stop int, fn func(<-chan int)) {
// parallel processes the data in separate goroutines. If any of them panics,
// returns an error. Note that if multiple goroutines panic, only one error is
// returned.
func (self *Context) SafeParallel(start, stop int, fn func(<-chan int)) (err error) {
count := stop - start
if count < 1 {
return
@@ -49,9 +53,16 @@ func (self *Context) Parallel(start, stop int, fn func(<-chan int)) {
for range procs {
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
text, _ := utils.Format_stacktrace_on_panic(r)
err = fmt.Errorf("%s", text)
}
wg.Done()
}()
fn(c)
}()
}
wg.Wait()
return
}

View File

@@ -73,8 +73,16 @@ func Format_stacktrace_on_panic(r any) (text string, err error) {
n := runtime.Callers(3, pcs)
lines := []string{}
frames := runtime.CallersFrames(pcs[:n])
err = fmt.Errorf("Panicked: %s", r)
lines = append(lines, fmt.Sprintf("\r\nPanicked with error: %s\r\nStacktrace (most recent call first):\r\n", r))
rt := fmt.Sprint(r)
if strings.HasPrefix(rt, "Panicked with error:") {
err = fmt.Errorf("%s", rt)
lines = append(lines, "Panic caused by previous panic (probably in a gouroutine). Previous panic:\r\n")
lines = append(lines, rt)
lines = append(lines, "\r\n\r\nStacktrace of current panic (most recent call first):\r\n")
} else {
err = fmt.Errorf("Panicked: %s", r)
lines = append(lines, fmt.Sprintf("\r\nPanicked with error: %s\r\nStacktrace (most recent call first):\r\n", r))
}
found_first_frame := false
for frame, more := frames.Next(); more; frame, more = frames.Next() {
if !found_first_frame {