-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- Read-only ICO, CUR and ICNS frame preview
-- @module plugins.icopreview.view

local common = require("core.common")
local core = require("core")
local style = require("core.style")
local DocView = require("core.doc.view")
local transparent = require("core.ui.transparent")
local ico = require("plugins.icopreview.ico")
local icns = require("plugins.icopreview.icns")

local IcoPreview = DocView:extend()

local OUTER_PADDING = 20
local CARD_PADDING = 10
local IMAGE_LABEL_GAP = 6
local CARD_GAP = 16
local HEADER_ICON_GAP = 20
local TRANSPARENT_OPTIONS = { tile_size = 8 }
local HOTSPOT_COLOR = { 255, 0, 0, 255 }
local PLACEHOLDER_BORDER_WIDTH = 1
local NEXT_LAYOUT = { columns = "vertical", vertical = "packed", packed = "columns" }
local ICO_ZOOM_LEVELS = { 50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 800, 900, 1000, 1500, 2000, 2500, 3000 }
-- One shared choice keeps every icon preview on same layout
local layout_mode = "columns"
-- New image-set types remember separate zoom choices after their initial defaults
local default_zoom_multipliers = { icon = 1, cursor = 3, icns = 1 }

--- Return image-set type represented by one document
-- @param doc table icon or cursor document
-- @return string image_set_type
local function get_document_image_set_type(doc)
	local module_name = doc.format and doc.format._module_name

	if module_name == "format.cur" then
		return "cursor"
	end
	if module_name == "format.icns" then
		return "icns"
	end

	return "icon"
end

--- Return nearest configured icon-preview zoom level
-- @param zoom_multiplier number|nil requested zoom
-- @return number level_index
local function get_nearest_zoom_level_index(zoom_multiplier)
	local requested_percent = (tonumber(zoom_multiplier) or 1) * 100
	local nearest_index = 1
	local nearest_distance = math.abs(ICO_ZOOM_LEVELS[1] - requested_percent)

	for index = 2, #ICO_ZOOM_LEVELS do
		local distance = math.abs(ICO_ZOOM_LEVELS[index] - requested_percent)

		if distance < nearest_distance then
			nearest_index = index
			nearest_distance = distance
		end
	end

	return nearest_index
end

--- Snap one requested icon-preview zoom to nearest configured level
-- @param zoom_multiplier number|nil requested zoom
-- @return number zoom_multiplier
local function normalize_zoom_multiplier(zoom_multiplier)
	local level_index = get_nearest_zoom_level_index(zoom_multiplier)

	return ICO_ZOOM_LEVELS[level_index] / 100
end

--- Return file information shown in the preview header
-- @param self table icon or cursor preview
-- @return table lines
local function get_header_lines(self)
	local type_name = "Windows Icon"

	if self.icon.type == "cursor" then
		type_name = "Windows Cursor"
	elseif self.icon.type == "icns" then
		type_name = "macOS Icon"
	end

	return {
		"Name: " .. self:get_name(),
		"Type: " .. type_name,
		string.format("Images: %d", self.icon.count),
		string.format("File size: %d bytes", self.icon.file_size),
	}
end

--- Return whether one decoded frame is better header representative
-- @param entry table candidate image entry
-- @param current table|nil current representative
-- @return boolean better
local function is_better_primary_entry(entry, current)
	if not entry.canvas then
		return false
	end
	if not current then
		return true
	end

	local area = entry.width * entry.height
	local current_area = current.width * current.height

	return area > current_area
		or area == current_area and entry.bits_per_pixel > current.bits_per_pixel
end

--- Return full header height including its separator and lower gap
-- @param self table icon preview
-- @return number height
local function get_header_height(self)
	local lines = get_header_lines(self)
	local text_height = #lines * style.font:get_height()
		+ math.max(0, #lines - 1) * style.padding.y / 2
	local divider_height = math.max(1, style.divider_size or 1)

	return style.padding.y + text_height + OUTER_PADDING * 2 + divider_height
end

--- Return label shown beneath one icon or cursor frame
-- @param entry table parsed icon or cursor entry
-- @return string label
local function get_entry_label(entry)
	local label

	if entry.image then
		label = string.format(
			"[%d] %s %dx%d %dbpp",
			entry.index,
			entry.format,
			entry.width,
			entry.height,
			entry.bits_per_pixel
		)
	else
		label = string.format("[%d] Unknown %dx%d", entry.index, entry.width, entry.height)
	end

	if entry.type_code then
		label = string.format("%s %s", label, entry.type_code)
	end
	if entry.density and entry.density > 1 then
		label = label .. " @2x"
	end
	if entry.hotspot_x ~= nil then
		label = string.format("%s (%d,%d)", label, entry.hotspot_x, entry.hotspot_y)
	end

	return label
end

--- Draw one zoom-scaled source pixel on a cursor hotspot at 300 percent or above
-- @param entry table parsed CUR entry
-- @param image_x number displayed image x
-- @param image_y number displayed image y
-- @param image_width number displayed image width
-- @param image_height number displayed image height
local function draw_hotspot_pixel(entry, image_x, image_y, image_width, image_height)
	if entry.hotspot_x == nil or entry.hotspot_warning then
		return
	end

	local zoom_x = image_width / entry.width
	local zoom_y = image_height / entry.height

	if math.min(zoom_x, zoom_y) < 3 then
		return
	end

	local x = image_x + math.floor(entry.hotspot_x * zoom_x + 0.5)
	local y = image_y + math.floor(entry.hotspot_y * zoom_y + 0.5)
	local pixel_width = math.max(1, math.floor(image_width / entry.width + 0.5))
	local pixel_height = math.max(1, math.floor(image_height / entry.height + 0.5))

	-- Keep edge hotspots from drawing into labels or surrounding cards
	core.push_clip_rect(image_x, image_y, image_width, image_height)
	renderer.draw_rect(x, y, pixel_width, pixel_height, HOTSPOT_COLOR)
	core.pop_clip_rect()
end

--- Load metadata and a canvas for every icon or cursor entry
-- @param image_set_type string parser and decoder type
-- @param filename string icon or cursor path
-- @return table|nil image_set
-- @return string|nil error_message
local function load_image_set(image_set_type, filename)
	local loader = image_set_type == "icns" and icns or ico
	local image_set, read_error = loader.read_file(filename)

	if not image_set then
		return nil, read_error
	end

	for index = 1, #image_set.entries do
		local entry = image_set.entries[index]
		entry.canvas, entry.load_error = loader.load_entry_image(entry)
		entry.label = get_entry_label(entry)

		if not entry.canvas then
			entry.label = entry.label .. " (unavailable)"
		end

		entry.label_width = style.font:get_width(entry.label)

		if is_better_primary_entry(entry, image_set.primary_entry) then
			image_set.primary_entry = entry
		end
	end

	return image_set
end

--- Draw one visible frame when an image encoding cannot be loaded
-- @param x number frame x
-- @param y number frame y
-- @param width number frame width
-- @param height number frame height
local function draw_image_placeholder(x, y, width, height)
	local color = style.error or style.accent or style.text
	local border_width = math.max(1, math.floor(PLACEHOLDER_BORDER_WIDTH * SCALE + 0.5))

	renderer.draw_rect(x, y, width, border_width, color)
	renderer.draw_rect(x, y + height - border_width, width, border_width, color)
	renderer.draw_rect(x, y, border_width, height, color)
	renderer.draw_rect(x + width - border_width, y, border_width, height, color)
end

--- Return representative image rectangle inside the header
-- @param self table icon or cursor preview
-- @param text_height number header text block height
-- @param scroll_y number current vertical scroll
-- @param content_right number right edge before scrollbar lane
-- @return table|nil entry
-- @return number|nil x
-- @return number|nil y
-- @return number|nil width
-- @return number|nil height
local function get_header_icon_rect(self, text_height, scroll_y, content_right)
	local entry = self.icon.primary_entry

	if not entry then
		return nil
	end

	local scale = math.min(1, text_height / math.max(entry.width, entry.height))
	local width = math.max(1, math.floor(entry.width * scale + 0.5))
	local height = math.max(1, math.floor(entry.height * scale + 0.5))
	local x = content_right - style.padding.x - width
	local y = self.position.y + style.padding.y
		+ math.floor((text_height - height) / 2) - scroll_y

	return entry, x, y, width, height
end

--- Build one vertical list layout
-- @param self table icon preview
-- @return table layout
local function build_vertical_layout(self)
	-- Pixel previews keep 100% equal to source bitmap size
	local zoom = self.zoom_multiplier
	local label_height = style.font:get_height()
	local width = 0
	local y = get_header_height(self)
	local items = {}

	for index = 1, #self.icon.entries do
		local entry = self.icon.entries[index]
		local image_width = math.max(1, math.floor(entry.width * zoom + 0.5))
		local image_height = math.max(1, math.floor(entry.height * zoom + 0.5))
		local item_width = math.max(image_width, entry.label_width)
		items[index] = {
			entry = entry,
			x = 0,
			y = y,
			image_width = image_width,
			image_height = image_height,
			width = item_width,
		}
		width = math.max(width, item_width)
		y = y + image_height + IMAGE_LABEL_GAP + label_height + CARD_GAP
	end

	for index = 1, #items do
		items[index].x = OUTER_PADDING + math.floor((width - items[index].width) / 2)
	end

	return {
		items = items,
		width = width + OUTER_PADDING * 2,
		height = math.max(1, y - CARD_GAP + OUTER_PADDING),
	}
end

--- Finish one packed row and center it in available width
-- @param row table current row items
-- @param row_width number occupied row width
-- @param row_height number tallest image height
-- @param y number row top
-- @param available_width number row capacity
local function finish_packed_row(row, row_width, row_height, y, available_width)
	local x = OUTER_PADDING + math.max(0, math.floor((available_width - row_width) / 2))

	for index = 1, #row do
		local item = row[index]
		item.x = x
		item.y = y + row_height - item.image_height
		x = x + item.width + CARD_GAP
	end
end

--- Build wrapped rows that use current pane width
-- @param self table icon preview
-- @return table layout
local function build_packed_layout(self)
	-- Packed rows use same source-pixel zoom as the vertical layout
	local zoom = self.zoom_multiplier
	local label_height = style.font:get_height()
	local available_width = math.max(1, self.size.x - OUTER_PADDING * 2)
	local items = {}
	local row = {}
	local row_width = 0
	local row_height = 0
	local y = get_header_height(self)
	local content_width = available_width

	for index = 1, #self.icon.entries do
		local entry = self.icon.entries[index]
		local image_width = math.max(1, math.floor(entry.width * zoom + 0.5))
		local image_height = math.max(1, math.floor(entry.height * zoom + 0.5))
		local item_width = math.max(image_width, entry.label_width) + CARD_PADDING * 2

		if #row > 0 and row_width + CARD_GAP + item_width > available_width then
			finish_packed_row(row, row_width, row_height, y, available_width)
			content_width = math.max(content_width, row_width)
			y = y + row_height + IMAGE_LABEL_GAP + label_height + CARD_GAP
			row = {}
			row_width = 0
			row_height = 0
		end

		local item = {
			entry = entry,
			image_width = image_width,
			image_height = image_height,
			width = item_width,
		}
		items[#items + 1] = item
		row[#row + 1] = item
		row_width = row_width + (#row > 1 and CARD_GAP or 0) + item_width
		row_height = math.max(row_height, image_height)
	end

	if #row > 0 then
		finish_packed_row(row, row_width, row_height, y, available_width)
		content_width = math.max(content_width, row_width)
		y = y + row_height + IMAGE_LABEL_GAP + label_height
	end

	return {
		items = items,
		width = content_width + OUTER_PADDING * 2,
		height = y + OUTER_PADDING,
	}
end

--- Return evenly distributed item counts for a width-limited column count
-- @param entry_count number total entries
-- @param column_count number requested columns
-- @return table item_counts
local function get_even_column_counts(entry_count, column_count)
	local minimum = entry_count // column_count
	local longer_columns = entry_count % column_count
	local counts = {}

	for column_index = 1, column_count do
		counts[column_index] = minimum + (column_index <= longer_columns and 1 or 0)
	end

	return counts
end

--- Measure one possible column distribution
-- @param entries table ICO entries
-- @param zoom number current image zoom
-- @param item_counts table entries assigned to each column
-- @return table widths
-- @return number total_width
local function measure_columns(entries, zoom, item_counts)
	local widths = {}
	local entry_index = 1
	local total_width = OUTER_PADDING * 2 + CARD_GAP * math.max(0, #item_counts - 1)

	for column_index = 1, #item_counts do
		local width = 0

		for _ = 1, item_counts[column_index] do
			local entry = entries[entry_index]
			local image_width = math.max(1, math.floor(entry.width * zoom + 0.5))
			width = math.max(width, image_width, entry.label_width)
			entry_index = entry_index + 1
		end

		widths[column_index] = width
		total_width = total_width + width
	end

	return widths, total_width
end

--- Fill height-first columns without considering their combined width
-- @param entries table ICO entries
-- @param zoom number current image zoom
-- @param available_height number visible height below header
-- @param label_height number label height
-- @return table item_counts
local function get_height_column_counts(entries, zoom, available_height, label_height)
	local counts = { 0 }
	local column_height = 0

	for index = 1, #entries do
		local entry = entries[index]
		local image_height = math.max(1, math.floor(entry.height * zoom + 0.5))
		local card_height = image_height + IMAGE_LABEL_GAP + label_height
		local next_height = column_height + (counts[#counts] > 0 and CARD_GAP or 0) + card_height

		if counts[#counts] > 0 and next_height > available_height then
			counts[#counts + 1] = 0
			column_height = 0
			next_height = card_height
		end

		counts[#counts] = counts[#counts] + 1
		column_height = next_height
	end

	return counts
end

--- Build height-first columns, reducing their count only when width requires it
-- @param self table icon preview
-- @return table layout
local function build_columns_layout(self)
	local entries = self.icon.entries
	local zoom = self.zoom_multiplier
	local label_height = style.font:get_height()
	local available_height = math.max(1, self.size.y - get_header_height(self) - OUTER_PADDING)
	local item_counts = get_height_column_counts(entries, zoom, available_height, label_height)
	local column_widths, content_width = measure_columns(entries, zoom, item_counts)

	-- Width may cap height-driven count, so redistribute only after that cap
	for column_count = #item_counts - 1, 1, -1 do
		if content_width <= self.size.x then
			break
		end

		local candidate_counts = get_even_column_counts(#entries, column_count)
		local candidate_widths, candidate_width = measure_columns(entries, zoom, candidate_counts)
		item_counts = candidate_counts
		column_widths = candidate_widths
		content_width = candidate_width
	end

	local items = {}
	local entry_index = 1
	local x = OUTER_PADDING
	local content_height = get_header_height(self)

	for column_index = 1, #item_counts do
		local y = get_header_height(self)

		for _ = 1, item_counts[column_index] do
			local entry = entries[entry_index]
			local image_width = math.max(1, math.floor(entry.width * zoom + 0.5))
			local image_height = math.max(1, math.floor(entry.height * zoom + 0.5))
			items[entry_index] = {
				entry = entry,
				x = x,
				y = y,
				image_width = image_width,
				image_height = image_height,
				width = column_widths[column_index],
			}
			y = y + image_height + IMAGE_LABEL_GAP + label_height + CARD_GAP
			entry_index = entry_index + 1
		end

		content_height = math.max(content_height, y - CARD_GAP + OUTER_PADDING)
		x = x + column_widths[column_index] + CARD_GAP
	end

	return {
		items = items,
		width = content_width,
		height = content_height,
		left_aligned = true,
		column_count = #item_counts,
	}
end

--- Return current cached layout
-- @param self table icon preview
-- @return table layout
local function get_layout(self)
	local key = string.format(
		"%s:%g:%g:%g",
		layout_mode,
		self.zoom_multiplier,
		self.size.x,
		self.size.y
	)

	if self.layout_key ~= key then
		if layout_mode == "columns" then
			self.layout = build_columns_layout(self)
		elseif layout_mode == "packed" then
			self.layout = build_packed_layout(self)
		else
			self.layout = build_vertical_layout(self)
		end
		self.layout_key = key
	end

	return self.layout
end

--- Return decoded icon or cursor entry whose visible image contains one point
-- @param self table icon or cursor preview
-- @param x number pointer x
-- @param y number pointer y
-- @return table|nil entry
local function get_entry_at(self, x, y)
	if not self.icon or type(x) ~= "number" or type(y) ~= "number" then
		return nil
	end

	local viewport_x, viewport_y, viewport_width, viewport_height = self:get_content_viewport_rect()
	if x < viewport_x or x >= viewport_x + viewport_width
		or y < viewport_y or y >= viewport_y + viewport_height
	then
		return nil
	end

	local layout = get_layout(self)
	local scroll_x = self.scroll.x or 0
	local scroll_y = self.scroll.y or 0
	local centered_x = layout.left_aligned and 0
		or math.max(0, math.floor((self.size.x - layout.width) / 2))

	for index = 1, #layout.items do
		local item = layout.items[index]
		local image_x = self.position.x + centered_x + item.x - scroll_x
			+ math.floor((item.width - item.image_width) / 2)
		local image_y = self.position.y + item.y - scroll_y

		if item.entry.canvas
			and x >= image_x and x < image_x + item.image_width
			and y >= image_y and y < image_y + item.image_height
		then
			return item.entry
		end
	end

	return nil
end


--- Build a read-only preview for one icon or cursor document
-- @param doc table icon or cursor document
function IcoPreview:new(doc)
	IcoPreview.super.new(self, doc)
	self.is_ico_preview = true
	self.can_select = false
	self.can_edit = false
	self.has_cursor_model = false
	self.draw_cursor = false
	self.image_set_type = get_document_image_set_type(doc)
	self.zoom_multiplier = normalize_zoom_multiplier(
		doc.ico_preview_zoom_multiplier or default_zoom_multipliers[self.image_set_type]
	)
	-- A recreated view keeps its document zoom and makes it next new document default
	doc.ico_preview_zoom_multiplier = self.zoom_multiplier
	default_zoom_multipliers[self.image_set_type] = self.zoom_multiplier
	self.layout = nil
	self.layout_key = nil
	self.icon, self.load_error = load_image_set(self.image_set_type, doc.filename)
end

--- Return visible file information header lines
-- @return table lines
function IcoPreview:get_header_lines()
	return get_header_lines(self)
end

--- Return layout shared by all icon previews
-- @return string layout_mode
function IcoPreview:get_layout_mode()
	return layout_mode
end

--- Return key binding contexts for icon preview
-- @return table contexts
function IcoPreview:get_keymap_contexts()
	return { "ico_preview", "doc_view", "global" }
end

--- Return current preview zoom
-- @return number zoom_multiplier
function IcoPreview:get_zoom_multiplier()
	return self.zoom_multiplier
end

--- Return exact whole-size zoom levels supported by icon previews
-- @return table zoom_levels percentages
function IcoPreview:get_zoom_levels()
	return ICO_ZOOM_LEVELS
end

--- Keep icon-preview zoom local to this preview
-- @return boolean apply_absolute_scale
function IcoPreview:should_apply_absolute_scale()
	return false
end

--- Apply one local icon-preview zoom level
-- @param zoom_multiplier number requested zoom
-- @return boolean changed
function IcoPreview:set_zoom_multiplier(zoom_multiplier)
	local requested = normalize_zoom_multiplier(zoom_multiplier)
	-- Keep this document independent while carrying current choice into new previews
	self.doc.ico_preview_zoom_multiplier = requested
	default_zoom_multipliers[self.image_set_type] = requested

	if math.abs(requested - self.zoom_multiplier) < 1e-6 then
		return false
	end

	self.zoom_multiplier = requested
	self.layout_key = nil
	core.redraw = true

	return true
end

--- Move to next configured icon-preview zoom level
-- @param direction number positive zooms in and negative zooms out
-- @return boolean changed
function IcoPreview:step_zoom(direction)
	local current_index = get_nearest_zoom_level_index(self.zoom_multiplier)
	local target_index = common.clamp(current_index + direction, 1, #ICO_ZOOM_LEVELS)

	return self:set_zoom_multiplier(ICO_ZOOM_LEVELS[target_index] / 100)
end

--- Cycle through columns, vertical and packed frame layouts
-- @return boolean handled
function IcoPreview:toggle_layout()
	layout_mode = NEXT_LAYOUT[layout_mode] or "columns"
	self.layout_key = nil
	self.scroll.x, self.scroll.to.x = 0, 0
	self.scroll.y, self.scroll.to.y = 0, 0
	core.redraw = true

	return true
end

--- Return a stable scroll step
-- @return number line_height
function IcoPreview:get_line_height()
	return 64
end

--- Return total preview height
-- @return number content_height
function IcoPreview:get_scrollable_size()
	return self.icon and get_layout(self).height or self.size.y
end

--- Return horizontal room available to panning
-- @return number max_scroll_x
function IcoPreview:get_scroll_max_x()
	return self.icon and math.max(0, get_layout(self).width - self.size.x) or 0
end

--- Remember one pressed image frame until release while allowing normal panning to start
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean handled always false so pan command can continue
function IcoPreview:begin_entry_click(x, y)
	local entry = get_entry_at(self, x, y)

	self.pending_entry_click = entry and {
		entry = entry,
		x = x,
		y = y,
	} or nil

	return false
end

--- Open icon or cursor frame beneath one point in the ordinary Image Viewer
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean handled
function IcoPreview:open_entry_at(x, y)
	local entry = get_entry_at(self, x, y)

	if not entry then
		return false
	end

	local Doc = require("core.doc")
	local filename = system.format_filename(self.doc.filename, tostring(entry.index))
	local opened, doc = core.try(Doc.open_file, filename, {
		open_handler = "image",
	})

	if opened and doc then
		core.workspace_view:open_doc(doc, {
			open_handler = "image",
		})
	end

	return true
end

--- Show a hand over clickable frames while preserving active pan behavior
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer movement x
-- @param dy number pointer movement y
-- @return boolean|nil handled
function IcoPreview:on_mouse_move(x, y, dx, dy)
	if self.pending_entry_click
		and (x ~= self.pending_entry_click.x or y ~= self.pending_entry_click.y)
	then
		-- Any real movement makes this a pan gesture rather than an image click
		self.pending_entry_click = nil
	end

	local handled = IcoPreview.super.on_mouse_move(self, x, y, dx, dy)

	if not handled then
		self.mouse_cursor = get_entry_at(self, x, y) and "hand" or "arrow"
	end

	return handled
end

--- Open a pressed frame only when matching left button is released without dragging
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean|nil handled
function IcoPreview:on_mouse_up(button, x, y)
	local pending_entry = button == "left" and self.pending_entry_click or nil
	local release_entry = pending_entry and get_entry_at(self, x, y) or nil
	self.pending_entry_click = nil
	local handled = IcoPreview.super.on_mouse_up(self, button, x, y)

	if pending_entry and release_entry == pending_entry.entry then
		return self:open_entry_at(x, y)
	end

	return handled
end

--- Cancel a pending image click when pointer leaves this preview
function IcoPreview:on_mouse_left(...)
	self.pending_entry_click = nil

	return IcoPreview.super.on_mouse_left(self, ...)
end

--- Draw every icon or cursor frame and its label
function IcoPreview:draw()
	self:draw_background(style.background)

	if not self.icon then
		renderer.draw_text(
			style.font,
			"Unable to load this image set: " .. tostring(self.load_error),
			self.position.x + style.padding.x,
			self.position.y + style.padding.y,
			style.error or style.accent or style.text
		)
		self:draw_scrollbar()
		return
	end

	local layout = get_layout(self)
	local scroll_x = self.scroll.x or 0
	local scroll_y = self.scroll.y or 0
	local centered_x = layout.left_aligned and 0
		or math.max(0, math.floor((self.size.x - layout.width) / 2))
	-- Refresh lane before placing header content beside the scrollbar
	self:get_scrollbar_rect()
	local content_x, _, content_width = self:get_content_viewport_rect()
	local content_right = content_x + content_width

	core.push_clip_rect(self.position.x, self.position.y, self.size.x, self.size.y)

	local header_lines = self:get_header_lines()
	local header_x = self.position.x + style.padding.x
	local header_y = self.position.y + style.padding.y - scroll_y
	local text_height = #header_lines * style.font:get_height()
		+ math.max(0, #header_lines - 1) * style.padding.y / 2
	local divider_height = math.max(1, style.divider_size or 1)
	local panel_height = style.padding.y + text_height + OUTER_PADDING + divider_height
	local primary_entry, primary_x, primary_y, primary_width, primary_height =
		get_header_icon_rect(self, text_height, scroll_y, content_right)
	local text_right = primary_entry and primary_x - HEADER_ICON_GAP
		or content_right - style.padding.x
	-- Match fontpreview's quiet highlighted information panel
	renderer.draw_rect(
		self.position.x,
		self.position.y - scroll_y,
		self.size.x,
		panel_height,
		style.line_highlight or style.background2 or style.background
	)

	if text_right > header_x then
		-- Long filenames stop before icon instead of drawing beneath it
		core.push_clip_rect(header_x, self.position.y - scroll_y, text_right - header_x, panel_height)

		for index = 1, #header_lines do
			renderer.draw_text(style.font, header_lines[index], header_x, header_y, style.text)
			header_y = header_y + style.font:get_height()

			if index < #header_lines then
				header_y = header_y + style.padding.y / 2
			end
		end

		core.pop_clip_rect()
	else
		header_y = self.position.y + style.padding.y + text_height - scroll_y
	end

	if primary_entry then
		renderer.draw_canvas_scaled(
			primary_entry.canvas,
			0, 0, primary_entry.width, primary_entry.height,
			primary_x, primary_y, primary_width, primary_height
		)
		draw_hotspot_pixel(primary_entry, primary_x, primary_y, primary_width, primary_height)
	end

	header_y = header_y + OUTER_PADDING
	renderer.draw_rect(
		header_x,
		header_y,
		math.max(1, content_right - style.padding.x - header_x),
		divider_height,
		style.text
	)

	for index = 1, #layout.items do
		local item = layout.items[index]
		local entry = item.entry
		local item_x = self.position.x + centered_x + item.x - scroll_x
		local image_x = item_x + math.floor((item.width - item.image_width) / 2)
		local image_y = self.position.y + item.y - scroll_y
		local label_x = item_x + math.floor((item.width - entry.label_width) / 2)
		local label_y = image_y + item.image_height + IMAGE_LABEL_GAP

		transparent.draw(image_x, image_y, item.image_width, item.image_height, TRANSPARENT_OPTIONS)

		if entry.canvas then
			renderer.draw_canvas_scaled(
				entry.canvas,
				0, 0, entry.width, entry.height,
				image_x, image_y, item.image_width, item.image_height
			)
			draw_hotspot_pixel(entry, image_x, image_y, item.image_width, item.image_height)
		else
			draw_image_placeholder(image_x, image_y, item.image_width, item.image_height)
		end

		renderer.draw_text(style.font, entry.label, label_x, label_y, style.text)
	end

	core.pop_clip_rect()
	self:draw_scrollbar()
end

--- Return whether one document is a file-backed ICO, CUR or ICNS
-- @param doc table|nil candidate document
-- @return boolean supported
function IcoPreview.supports_doc(doc)
	if not doc or doc.filename == nil or type(doc.format) ~= "table" then
		return false
	end

	local module_name = doc.format._module_name

	return module_name == "format.ico" or module_name == "format.cur" or module_name == "format.icns"
end

return IcoPreview
