feat(dashboard): introducing support for full resolution dashboard images in terminals that support Kitty graphics protocol
(as of April 7, all suggestions and changes have been implemented and the following text has been updated accordingly)
Current dashboard implementation does not allow "real" images on snacks dashboard. All that you can get is chafa-like images that are just plain ugly (unless you are happy with character blocks colored with some almost random color). The real image is almost unrecognizable. In Kitty and several other terminals it is possible to render image natively. And all support for it is already there, built into snacks. I have just connected some simple dots.
Isn't it nice? :)
Here is my dashboard configuration:
return {
{
"folke/snacks.nvim",
enabled = true,
priority = 1000,
lazy = false,
---@type snacks.Config
opts = {
...
dashboard = {
enabled = true,
sections = {
{
section = "header",
padding = 1
},
{
align = "center",
text = {
{ nvimInfo, hl = "SnacksDashboardHeader" },
},
},
{ icon = " ", title = "Keymaps", section = "keys", indent = 2, padding = 0 },
{
action = ":Neorg index",
key = "o",
desc = "Neorg Index Page",
icon = " ",
indent = 2,
},
{
action = ":Neorg journal today",
key = "j",
desc = "Neorg Journal Today",
icon = " ",
indent = 2,
padding = 1,
},
{ icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = 1 },
{ icon = " ", title = "Projects", section = "projects", indent = 2, padding = 1 },
{
align = "center",
pane = 2,
text = {
{ "Enjoy the day", hl = "SnacksDashboardHeader" },
},
},
{
section = "image",
pane = 2,
-- width = 25,
height = 25,
align = "center",
-- source can be a function that returns location (path or url) or a string representing the location
source = function() return "https://picsum.photos/600/600/?rnd=" .. math.random(1, 1000000) end,
-- source = "https://movies.luka.in.rs/api/user/movie/picture/pp__5020__d5eBZq.jpg?1fdfd2345dd67",
-- source = "~/Downloads/4.jpeg",
padding = 1,
},
{
section = "terminal",
cmd = "fortune -s",
height = 2,
pane = 2,
padding = 1,
},
{
section = "startup",
-- pane = 2,
},
},
},
indent = {
enabled = true
},
scroll = {
enabled = true
},
},
}
}
Notice that there is a new section ("image"). Image source is a web URL or a path to a local image, or a function that returns URL or path. In this particular case image is obtained from "picsum" online service. Snacks cashes images so by calling the same URL you'll get the same image from the cache. Because of that source key is a function that appends some random parameter to the URL. You always get one new image per session because URLs do not match strictly!
To function properly, a bug in placement.lua file has to be fixed as well (which I did). The code places extmark while image is being converted but it does not do proper cleanup when the conversion is over. That leaves an ugly image processing message on the screen above the image.
I have also changed a piece of image utils by simplifying logic that fits an image into available space.
Key image options:
- width: a number, maximum image width (cells)
- height: a number maximum image height (cells)
- align: one of the following strings: "left", "center" or "right"; if omitted, defaults to "left"
Note that if you supply "title" as image option, it will be rendered, but it does not obey alignment setting. If you need title above the image, use separate section, as I did in my configuration file.
I believe this is a logical addition to snacks.dashboard - why stay limited to chafa in the era of kitty and other terminals that handle native images perfectly?
It's not up to me, but the randomized URL functionality seems better to not be in this. Maybe something like the following would be better:
@field source string|fun():string
That way the function can do whatever it wants, including randomizing.
Does this support files on the local file system?
(Note: I used the name source to be consistent with some other bits in Snacks)
It's not up to me, but the randomized URL functionality seems better to not be in this. Maybe something like the following would be better:
@field source string|fun():stringThat way the function can do whatever it wants, including randomizing.
Does this support files on the local file system?
(Note: I used the name
sourceto be consistent with some other bits in Snacks)
This is a great idea and, in fact, allows for simpler implementation. I'll implement that today. And, yes, the code supports local images because Folke's Image class supports that out of the box.
Thanks for the tip!
@saxon1964 I'm just a simple user with very limited programming knowledge as a hobby of mine. I'm not a programmer. I haven't even looked at snacks.image code because it's too complicated for me. The previous comment I made was just because it was something quite obvious in the Lua annotations. You're much more experienced than me.
The maintainer will review this properly.
I'd be very happy to see this or something like this added.
I am not a programmer, so this could just be a skill issue, but is it not feasible to add the align property the image section for the dashboard?
I'd be very happy to see this or something like this added.
I am not a programmer, so this could just be a skill issue, but is it not feasible to add the align property the image section for the dashboard?
Align like left/right?
align left, right, or center like header sections
You can align the image from the kitten itself, kitten icat --align left ...
You can align the image from the kitten itself,
kitten icat --align left ...
I'm using ghostty for a term emulator. It uses kitty's image protocol, but does not have kitty's kitten subcommands afaik.
I tried to display hi res images with timg and the Snacks.dashboard command section, which did not work, and is what this pr addresses.
I added a patch file to my configs based off the pr, and it does display images in high res, but they are always aligned left.
I'm not sure, but I may be able to just change Snacks.image's settings to center all images, but it will be this evening before I can try.
Also, I just realized that the kitten is not used anyways... so better to have an alignment option for images like you suggested
@zeffo @nebunebu
Implemented image alignment as requested. Please read the updated intro for details.
Alignment works. Scrolling with a split pane, the image maintains its position and overlays other text, as shown in the second image.
I do notice that when I make changes to the image section of the dashboard, I get the following error, unless I kill the tmux session and restart,
Error detected while processing /nix/store/lzj6cc7cxfimx5pvwmqzsma381373mqg-source/nebvim/init.lua:
E5113: Error while calling lua chunk: ...ovimPackages/start/image.nvim/lua/image/utils/logger.lua:54: 15:26:25.075107 [image.nvim] tmux
does not have allow-passthrough enabled
stack traceback:
[C]: in function 'handler'
...ovimPackages/start/image.nvim/lua/image/utils/logger.lua:54: in function 'throw'
...kages/start/image.nvim/lua/image/backends/kitty/init.lua:29: in function 'setup'
...ack/myNeovimPackages/start/image.nvim/lua/image/init.lua:72: in function 'setup'
...wmqzsma381373mqg-source/nebvim/lua/neb/plugins/image.lua:2: in main chunk
[C]: in function 'require'
.../lzj6cc7cxfimx5pvwmqzsma381373mqg-source/nebvim/init.lua:49: in main chunk
Pass-through is enabled, but killing session and restarting gets rid of error. The error only happens after modifying the image section of the dashboard.
Some of this may be due to how I have been trying things.
-- ~/.nebvim/lua/neb/plugins/snacks/init.lua
local M = {}
function M.setup()
local config = {
bigfile = require("neb.plugins.snacks.bigfile"),
-- bufdelete = require("neb.plugins.snacks.bufdelete"),
dashboard = require("neb.plugins.snacks.dashboard"),
image = require("neb.plugins.snacks.image"),
debug = require("neb.plugins.snacks.debug"),
git = require("neb.plugins.snacks.git"),
-- gitbrowse = require("neb.plugins.snacks.gitbrowse"),
lazygit = require("neb.plugins.snacks.lazygit"),
notifier = require("neb.plugins.snacks.notifier"),
notify = require("neb.plugins.snacks.notify"),
quickfile = require("neb.plugins.snacks.quickfile"),
statuscolumn = require("neb.plugins.snacks.statuscolumn"),
-- toggle = require("neb.plugins.snacks.toggle"),
win = require("neb.plugins.snacks.win"),
words = require("neb.plugins.snacks.words"),
styles = {
snacks_image = {
relative = "cursor",
border = "rounded",
focusable = false,
backdrop = false,
row = 1,
col = 1,
-- width/height are automatically set by the image size unless specified below
},
},
}
require("neb.plugins.snacks.dashboard_image_patch").apply_patch()
require("snacks").setup(config)
end
return M
-- ~/.nebvim/lua/neb/plugins/snacks/dashboard_image_patch.lua
local M = {}
function M.apply_patch()
local status_ok_snacks, snacks = pcall(require, "snacks")
local status_ok_dashboard, dashboard = pcall(require, "snacks.dashboard")
local status_ok_util, util = pcall(require, "snacks.util")
local status_ok_image, image = pcall(require, "snacks.image")
if not (status_ok_snacks and status_ok_dashboard and status_ok_util and status_ok_image) then
vim.notify("Failed to load required snacks modules for patching image section", vim.log.levels.WARN)
return
end
---@diagnostic disable-next-line: duplicate-set-field
---@param opts {source:(string|fun(): string), height?:number, width?:number, align?: "left"|"center"|"right", hl?:string}|snacks.dashboard.Item
---@return snacks.dashboard.Gen
dashboard.sections.image = function(opts)
local source = type(opts.source) == "function" and opts.source() or opts.source
local conversion_status = 0
local convertor = image.convert.convert({
src = source,
on_done = function(convert)
if convert:error() then
conversion_status = 2
vim.notify("Failed to convert dashboard image to PNG file", vim.log.levels.ERROR)
else
conversion_status = 1
end
end,
})
convertor:run()
vim.wait(10000, function()
return conversion_status > 0
end, 200, false)
if conversion_status ~= 1 then
return function(_)
return {
action = nil,
key = nil,
label = nil,
render = function(_, _) end,
text = "",
}
end
end
return function(self)
local width = opts.width or (self.opts and self.opts.width) or vim.fn.winwidth(0)
local height = opts.height or 20
local cells = image.util.fit(convertor.file, { width = width, height = height }, { full = true })
local indent = 0
if opts.align == "center" then
indent = math.floor((width - cells.width) / 2)
elseif opts.align == "right" then
indent = width - cells.width
end
local buf = vim.api.nvim_create_buf(false, true)
return {
action = nil,
key = nil,
label = nil,
render = function(_, pos)
local win = vim.api.nvim_open_win(buf, false, {
relative = "win",
win = self.win,
bufpos = { pos[1], pos[2] }, -- Align with the start of placeholder text
col = indent,
row = 0,
width = cells.width, -- Use the calculated cell width
height = cells.height + 1, -- Use the calcula
style = "minimal",
focusable = false,
noautocmd = true,
zindex = (snacks.config and snacks.config.styles.dashboard.zindex or 50) + 1,
})
local hl = opts.hl or "NormalFloat"
util.wo(win, { winhighlight = "Normal:" .. hl .. ",NormalFloat:" .. hl .. ",FloatBorder:" .. hl })
util.bo(buf, { filetype = snacks.config.styles.dashboard.bo.filetype or "snacks-dashboard" })
image.placement.new(buf, source, {})
local close = vim.schedule_wrap(function()
if vim.api.nvim_win_is_valid(win) then
pcall(vim.api.nvim_win_close, win, true)
end
if vim.api.nvim_buf_is_valid(buf) then
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end
return true
end)
self.on("UpdatePre", close, self.augroup)
self.on("Closed", close, self.augroup)
end,
text = ("\n"):rep(cells.height - 1),
}
end
end
end
return M
-- ~/.nebvim/lua/neb/plugins/dashboard.lua
return {
preset = {
header = [[
███████████████████████████████████████
█▄─▀█▄─▄█▄─▄▄─█▄─▄─▀█▄─█─▄█▄─▄█▄─▀█▀─▄█
██─█▄▀─███─▄█▀██─▄─▀██▄▀▄███─███─█▄█─██
▀▄▄▄▀▀▄▄▀▄▄▄▄▄▀▄▄▄▄▀▀▀▀▄▀▀▀▄▄▄▀▄▄▄▀▄▄▄▀
]],
keys = {
{ icon = "", key = "c", desc = "nix-config", action = ":e $HOME/.nix-config/flake.nix" },
{ icon = "", key = "n", desc = "nebvim", action = ":e $HOME/.nebvim/flake.nix" },
{ icon = "", key = "v", desc = "obsidian vault", action = ":e $HOME/.vault/index.md" },
{ icon = "", key = "w", desc = "wiki", action = ":e $HOME/.wiki/index.md" },
{ icon = "", key = "p", desc = "Projects", action = ":e $HOME/Projects/" },
{ icon = "", key = "t", desc = "Typst Documents", action = ":e $HOME/Documents/typst" },
{ icon = "", key = "m", desc = "Media", action = ":e $HOME/Media" },
},
},
sections = {
{
section = "header",
align = "center",
padidnt = 3,
pane = 1,
},
{
section = "image",
height = 19,
-- source = "https://i.imgur.com/ntbQVTt.png",
source = "~/roseified-tempus_edax_rerum.png",
hl = "SnacksDashboardNormal",
pane = 1,
align = "center",
padding = 1,
},
function()
local version_output = vim.fn.execute("version")
local lines = vim.split(version_output, "\n", { plain = true, trimempty = true })
local first_line = lines and lines[1] or "NVIM v?.?.?"
local version_string = vim.trim(first_line:gsub("^NVIM%s*", ""))
return {
text = {
{ version_string, hl = "MoreMsg" },
},
align = "center",
padding = { 1, 2 },
pane = 1,
}
end,
{
section = "keys",
title = { "Quick Links", hl = "Function" },
indent = 2,
padding = 3,
pane = 2,
},
{
section = "recent_files",
title = { "Recent", hl = "Function" },
indent = 2,
limit = 5,
padding = 3,
pane = 2,
},
},
}
Just to be upfront, I have little idea what I am doing. The patchfile is ai gen'd based off the pr.
Honestly, I'm having a hard time trying to understand what you are trying to achieve. I got only the part about image scrolling. Basically image section shares the same code with Terminal section and terinal behaves in exactly the same way. So if we have a bug there, we have two.
I would definitely like to get some guidance from Folke but it seems that this plugin is not a priority for him right now (the number of pull requests has been growing constantly).
@saxon1964 https://github.com/folke/snacks.nvim/issues/1644#issuecomment-2758908856
So, we have to be patient until mid May.
@saxon1964 The issue I mentioned regarding tmux-passthrough was just a boneheaded mistake on my part. The error was coming from start/image.nvim, which is a plugin unrelated to the Snacks suite and also for viewing images in neovim and which I mistook as coming from Snacks.image. Sorry for any confusion.
This PR is stale because it has been open 30 days with no activity.
Not stale
This PR is stale because it has been open 30 days with no activity.
Not stale
This PR is stale because it has been open 30 days with no activity.
Not stale
This PR is stale because it has been open 30 days with no activity.
Not stale