snacks.nvim icon indicating copy to clipboard operation
snacks.nvim copied to clipboard

feat(dashboard): introducing support for full resolution dashboard images in terminals that support Kitty graphics protocol

Open saxon1964 opened this issue 9 months ago • 16 comments

(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.

1

s1

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?

saxon1964 avatar Mar 16 '25 21:03 saxon1964

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)

gwww avatar Mar 21 '25 19:03 gwww

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)

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 avatar Mar 23 '25 07:03 saxon1964

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

dpetka2001 avatar Mar 23 '25 10:03 dpetka2001

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?

nebunebu avatar Apr 02 '25 00:04 nebunebu

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?

saxon1964 avatar Apr 02 '25 13:04 saxon1964

align left, right, or center like header sections

nebunebu avatar Apr 02 '25 14:04 nebunebu

You can align the image from the kitten itself, kitten icat --align left ...

zeffo avatar Apr 03 '25 15:04 zeffo

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.

nebunebu avatar Apr 03 '25 15:04 nebunebu

Also, I just realized that the kitten is not used anyways... so better to have an alignment option for images like you suggested

zeffo avatar Apr 03 '25 18:04 zeffo

@zeffo @nebunebu Implemented image alignment as requested. Please read the updated intro for details. s1 s2 s3

saxon1964 avatar Apr 07 '25 11:04 saxon1964

image

image

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.

nebunebu avatar Apr 07 '25 19:04 nebunebu

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 avatar Apr 09 '25 06:04 saxon1964

@saxon1964 https://github.com/folke/snacks.nvim/issues/1644#issuecomment-2758908856

So, we have to be patient until mid May.

dpetka2001 avatar Apr 09 '25 07:04 dpetka2001

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

nebunebu avatar Apr 12 '25 20:04 nebunebu

This PR is stale because it has been open 30 days with no activity.

github-actions[bot] avatar May 13 '25 02:05 github-actions[bot]

Not stale

gwww avatar May 13 '25 11:05 gwww

This PR is stale because it has been open 30 days with no activity.

github-actions[bot] avatar Jun 13 '25 02:06 github-actions[bot]

Not stale

gwww avatar Jun 13 '25 11:06 gwww

This PR is stale because it has been open 30 days with no activity.

github-actions[bot] avatar Jul 30 '25 02:07 github-actions[bot]

Not stale

gwww avatar Jul 30 '25 10:07 gwww

This PR is stale because it has been open 30 days with no activity.

github-actions[bot] avatar Sep 01 '25 02:09 github-actions[bot]

Not stale

gwww avatar Sep 01 '25 11:09 gwww