feat: improved text width measuring (#322)

This commit is contained in:
christoph-heinrich
2022-10-25 10:21:28 +02:00
committed by GitHub
parent 26648f081a
commit bef4a77139
2 changed files with 453 additions and 136 deletions

View File

@@ -142,6 +142,9 @@ ui_scale=1
font_scale=1
# Border of text and icons when drawn directly on top of video
text_border=1.2
# Use a faster estimation method instead of accurate measurement
# setting this to `no` might have a noticable impact on performance, especially in large menus.
text_width_estimation=yes
# Execute command for background clicks shorter than this number of milliseconds, 0 to disable
# Execution always waits for `input-doubleclick-time` to filter out double-clicks
click_threshold=0
@@ -176,10 +179,6 @@ stream_quality_options=4320,2160,1440,1080,720,480,360,240,144
media_types=3g2,3gp,aac,aiff,ape,apng,asf,au,avi,avif,bmp,dsf,f4v,flac,flv,gif,h264,h265,j2k,jp2,jfif,jpeg,jpg,jxl,m2ts,m4a,m4v,mid,midi,mj2,mka,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rm,rmvb,spx,svg,tak,tga,tta,tif,tiff,ts,vob,wav,weba,webm,webp,wma,wmv,wv,y4m
# File types to look for when loading external subtitles
subtitle_types=aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt
# Used to approximate text width
# If you are using some wide font and see a lot of right side clipping in menus,
# try bumping this up.
font_height_to_letter_width_ratio=0.5
# Default open-file menu directory
default_directory=~/

View File

@@ -210,6 +210,7 @@ local defaults = {
ui_scale = 1,
font_scale = 1,
text_border = 1.2,
text_width_estimation = true,
pause_on_click_shorter_than = 0, -- deprecated by below
click_threshold = 0,
click_command = 'cycle pause; script-binding uosc/flash-pause-indicator',
@@ -230,7 +231,6 @@ local defaults = {
stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144',
media_types = '3g2,3gp,aac,aiff,ape,apng,asf,au,avi,avif,bmp,dsf,f4v,flac,flv,gif,h264,h265,j2k,jp2,jfif,jpeg,jpg,jxl,m2ts,m4a,m4v,mid,midi,mj2,mka,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rm,rmvb,spx,svg,tak,tga,tta,tif,tiff,ts,vob,wav,weba,webm,webp,wma,wmv,wv,y4m',
subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
font_height_to_letter_width_ratio = 0.5,
default_directory = '~/',
chapter_ranges = 'openings:30abf964,endings:30abf964,ads:c54e4e80',
chapter_range_patterns = 'openings:オープニング;endings:エンディング',
@@ -555,108 +555,421 @@ function get_point_to_rectangle_proximity(point, rect)
return math.sqrt(dx * dx + dy * dy)
end
---@param text string|number
---@param font_size number
function text_width_estimate(text, font_size) return text_length_width_estimate(text_length(text), font_size) end
do
-- https://en.wikipedia.org/wiki/Unicode_block
---@alias CodePointRange {[1]: integer; [2]: integer}
---@param length number
---@param font_size number
function text_length_width_estimate(length, font_size)
return length * font_size * options.font_height_to_letter_width_ratio
end
---@type CodePointRange[]
local zero_width_blocks = {
{0x0000, 0x001F}, -- C0
{0x007F, 0x009F}, -- Delete + C1
{0x034F, 0x034F}, -- combining grapheme joiner
{0x061C, 0x061C}, -- Arabic Letter Strong
{0x200B, 0x200F}, -- {zero-width space, zero-width non-joiner, zero-width joiner, left-to-right mark, right-to-left mark}
{0x2028, 0x202E}, -- {line separator, paragraph separator, Left-to-Right Embedding, Right-to-Left Embedding, Pop Directional Format, Left-to-Right Override, Right-to-Left Override}
{0x2060, 0x2060}, -- word joiner
{0x2066, 0x2069}, -- {Left-to-Right Isolate, Right-to-Left Isolate, First Strong Isolate, Pop Directional Isolate}
{0xFEFF, 0xFEFF}, -- zero-width non-breaking space
-- Some other characters can also be combined https://en.wikipedia.org/wiki/Combining_character
{0x0300, 0x036F}, -- Combining Diacritical Marks 0 BMP Inherited
{0x1AB0, 0x1AFF}, -- Combining Diacritical Marks Extended 0 BMP Inherited
{0x1DC0, 0x1DFF}, -- Combining Diacritical Marks Supplement 0 BMP Inherited
{0x20D0, 0x20FF}, -- Combining Diacritical Marks for Symbols 0 BMP Inherited
{0xFE20, 0xFE2F}, -- Combining Half Marks 0 BMP Cyrillic (2 characters), Inherited (14 characters)
-- Egyptian Hieroglyph Format Controls and Shorthand format Controls
{0x13430, 0x1345F}, -- Egyptian Hieroglyph Format Controls 1 SMP Egyptian Hieroglyphs
{0x1BCA0, 0x1BCAF}, -- Shorthand Format Controls 1 SMP Common
-- not sure how to deal with those https://en.wikipedia.org/wiki/Spacing_Modifier_Letters
{0x02B0, 0x02FF}, -- Spacing Modifier Letters 0 BMP Bopomofo (2 characters), Latin (14 characters), Common (64 characters)
}
---@param text string|number
function text_length(text)
if not text or text == '' then return 0 end
local text_length = 0
for _, _, length in utf8_iter(tostring(text)) do text_length = text_length + length end
return text_length
end
-- All characters have the same width as the first one
---@type CodePointRange[]
local same_width_blocks = {
{0x3400, 0x4DBF}, -- CJK Unified Ideographs Extension A 0 BMP Han
{0x4E00, 0x9FFF}, -- CJK Unified Ideographs 0 BMP Han
{0x20000, 0x2A6DF}, -- CJK Unified Ideographs Extension B 2 SIP Han
{0x2A700, 0x2B73F}, -- CJK Unified Ideographs Extension C 2 SIP Han
{0x2B740, 0x2B81F}, -- CJK Unified Ideographs Extension D 2 SIP Han
{0x2B820, 0x2CEAF}, -- CJK Unified Ideographs Extension E 2 SIP Han
{0x2CEB0, 0x2EBEF}, -- CJK Unified Ideographs Extension F 2 SIP Han
{0x2F800, 0x2FA1F}, -- CJK Compatibility Ideographs Supplement 2 SIP Han
{0x30000, 0x3134F}, -- CJK Unified Ideographs Extension G 3 TIP Han
{0x31350, 0x323AF}, -- CJK Unified Ideographs Extension H 3 TIP Han
}
function utf8_iter(string)
local byte_start, byte_count = 1, 1
---Get byte count of utf-8 character at index i in str
---@param str string
---@param i integer?
---@return integer
local function utf8_char_bytes(str, i)
local char_byte = str:byte(i)
if char_byte < 0xC0 then return 1
elseif char_byte < 0xE0 then return 2
elseif char_byte < 0xF0 then return 3
elseif char_byte < 0xF8 then return 4
else return 1 end
end
return function()
if #string < byte_start then return nil end
---Creates an iterator for an utf-8 encoded string
---Iterates over utf-8 characters instead of bytes
---@param str string
---@return fun(): string
local function utf8_iter(str)
local byte_start = 1
return function()
local start = byte_start
if #str < start then return nil end
local byte_count = utf8_char_bytes(str, start)
byte_start = start + byte_count
return start, str:sub(start, start + byte_count - 1)
end
end
local char_byte = string.byte(string, byte_start)
---Extract Unicode code point from utf-8 character at index i in str
---@param str string
---@param i integer
---@return integer
local function utf8_to_unicode(str, i)
local byte_count = utf8_char_bytes(str, i)
local char_byte = str:byte(i)
local unicode = char_byte
if byte_count ~= 1 then
local shift = 2 ^ (8 - byte_count)
char_byte = char_byte - math.floor(0xFF / shift) * shift
unicode = char_byte * (2 ^ 6) ^ (byte_count - 1)
end
for j = 2, byte_count do
char_byte = str:byte(i + j - 1) - 0x80
unicode = unicode + char_byte * (2 ^ 6) ^ (byte_count - j)
end
return round(unicode)
end
byte_count = 1
if char_byte < 192 then byte_count = 1
elseif char_byte < 224 then byte_count = 2
elseif char_byte < 240 then byte_count = 3
elseif char_byte < 248 then byte_count = 4
elseif char_byte < 252 then byte_count = 5
elseif char_byte < 254 then byte_count = 6
---Convert Unicode code point to utf-8 string
---@param unicode integer
---@return string?
local function unicode_to_utf8(unicode)
if unicode < 0x80 then return string.char(unicode)
else
local byte_count
if unicode < 0x800 then byte_count = 2
elseif unicode < 0x10000 then byte_count = 3
elseif unicode < 0x110000 then byte_count = 4
else return end -- too big
local res = {}
local shift = 2 ^ 6
local after_shift = unicode
for _ = byte_count, 2, -1 do
local before_shift = after_shift
after_shift = math.floor(before_shift / shift)
table.insert(res, 1, before_shift - after_shift * shift + 0x80)
end
shift = 2 ^ (8 - byte_count)
table.insert(res, 1, after_shift + math.floor(0xFF / shift) * shift)
---@diagnostic disable-next-line: deprecated
return string.char(unpack(res))
end
end
local text_osd = mp.create_osd_overlay("ass-events")
text_osd.compute_bounds, text_osd.hidden = true, true
---@type integer, integer
local osd_width, osd_height = 100, 100
mp.observe_property('osd-dimensions', 'native', function (_, dim)
if dim then osd_width, osd_height = dim.w, dim.h end
end)
---@param ass_text string
---@return integer, integer, integer, integer
local function measure_bounds(ass_text)
osd_width, osd_height = mp.get_osd_size()
text_osd.res_x, text_osd.res_y = osd_width, osd_height
text_osd.data = ass_text
local res = text_osd:update()
return res.x0, res.y0, res.x1, res.y1
end
---@type {wrap: integer; bold: boolean; italic: boolean, rotate: number; size: number}
local bounds_opts = {wrap = 2, bold = false, italic = false, rotate = 0, size = 0}
---Measure text width and normalize to a font size of 1
---text has to be ass safe
---@param text string
---@param size number
---@param bold boolean
---@param italic boolean
---@param horizontal boolean
---@return number, integer
local function normalized_text_width(text, size, bold, italic, horizontal)
bounds_opts.bold, bounds_opts.italic, bounds_opts.rotate = bold, italic, horizontal and 0 or -90
local x1, y1 = nil, nil
size = size / 0.8
-- prevent endless loop
local repetitions_left = 5
repeat
size = size * 0.8
bounds_opts.size = size
local ass = assdraw.ass_new()
ass:txt(0, 0, horizontal and 7 or 1, text, bounds_opts)
_, _, x1, y1 = measure_bounds(ass.text)
repetitions_left = repetitions_left - 1
-- make sure nothing got clipped
until (x1 and x1 < osd_width and y1 < osd_height) or repetitions_left == 0
local width = (repetitions_left == 0 and not x1) and 0 or (horizontal and x1 or y1)
return width / size, horizontal and osd_width or osd_height
end
---Estimates character length based on utf8 byte count
---1 character length is roughly the size of a latin character
---@param char string
---@return number
local function char_length(char)
return #char > 2 and 2 or 1
end
---Estimates string length based on utf8 byte count
---Note: Making a string in the iterator with the character is a waste here,
---but as this function is only used when measureing whole string widths it's fine
---@param text string
---@return number
local function text_length(text)
if not text or text == '' then return 0 end
local text_length = 0
for _, char in utf8_iter(tostring(text)) do text_length = text_length + char_length(char) end
return text_length
end
local width_length_ratio = 0.5
---@type {[boolean]: {[string]: {[1]: number, [2]: integer}}}
local char_width_cache = {}
---Finds the best orientation of text on screen and returns the estimated max size
---and if the text should be drawn horizontally
---@param text string
---@return number, boolean
local function fit_on_screen(text)
local estimated_width = text_length(text) * width_length_ratio
if osd_width >= osd_height then
-- Fill the screen as much as we can, bigger is more accurate.
return math.min(osd_width / estimated_width, osd_height), true
else
return math.min(osd_height / estimated_width, osd_width), false
end
end
---Gets next stage from cache
---@param cache {[any]: table}
---@param value any
local function get_cache_stage(cache, value)
local stage = cache[value]
if not stage then
stage = {}
cache[value] = stage
end
return stage
end
---Is measured resolution sufficient
---@param px integer
---@return boolean
local function no_remeasure_required(px)
return px >= 800 or (px * 1.1 >= osd_width and px * 1.1 >= osd_height)
end
---Get measured width of character
---@param char string
---@param bold boolean
---@return number, integer
local function character_width(char, bold)
---@type {[string]: {[1]: number, [2]: integer}}
local char_widths = get_cache_stage(char_width_cache, bold)
local width_px = char_widths[char]
if width_px and no_remeasure_required(width_px[2]) then return width_px[1], width_px[2] end
local unicode = utf8_to_unicode(char, 1)
for _, block in ipairs(zero_width_blocks) do
if unicode >= block[1] and unicode <= block[2] then
char_widths[char] = {0, infinity}
return 0, infinity
end
end
local start = byte_start
byte_start = byte_start + byte_count
return start, byte_count, (byte_count > 2 and 2 or 1)
end
end
function wrap_text(text, target_line_length)
local line_length = 0
local wrap_at_chars = {' ', ' ', '-', ''}
local remove_when_wrap = {' ', ' '}
local lines = {}
local line_start = 1
local before_end = nil
local before_length = 0
local before_line_start = 0
local before_removed_length = 0
local max_length = 0
for char_start, count, char_length in utf8_iter(text) do
local char_end = char_start + count - 1
local char = text.sub(text, char_start, char_end)
local can_wrap = false
for _, c in ipairs(wrap_at_chars) do
if char == c then
can_wrap = true
local repr_char = nil
for _, block in ipairs(same_width_blocks) do
if unicode >= block[1] and unicode <= block[2] then
repr_char = unicode_to_utf8(block[1])
width_px = char_widths[repr_char]
if width_px and no_remeasure_required(width_px[2]) then
char_widths[char] = width_px
return width_px[1], width_px[2]
end
break
end
end
line_length = line_length + char_length
if can_wrap or (char_end == #text) then
local remove = false
for _, c in ipairs(remove_when_wrap) do
if char == c then
remove = true
break
end
end
local line_length_after_remove = line_length - (remove and char_length or 0)
if line_length_after_remove < target_line_length then
before_end = remove and char_start - 1 or char_end
before_length = line_length_after_remove
before_line_start = char_end + 1
before_removed_length = remove and char_length or 0
else
if (target_line_length - before_length) <
(line_length_after_remove - target_line_length) then
lines[#lines + 1] = text.sub(text, line_start, before_end)
line_start = before_line_start
line_length = line_length - before_length - before_removed_length
if before_length > max_length then max_length = before_length end
else
lines[#lines + 1] = text.sub(text, line_start, remove and char_start - 1 or char_end)
line_start = char_end + 1
line_length = remove and line_length - char_length or line_length
if line_length > max_length then max_length = line_length end
line_length = 0
end
before_end = line_start
before_length = 0
if not repr_char then repr_char = char end
-- half as many repetitions for wide characters
local char_count = 10 / char_length(char)
local max_size, horizontal = fit_on_screen(repr_char:rep(char_count))
local size = math.min(max_size * 0.9, 50)
char_count = math.min(math.floor(char_count * max_size / size * 0.8), 100)
local enclosing_char, enclosing_width, next_char_count = '|', 0, char_count
if repr_char == enclosing_char then enclosing_char = ''
else enclosing_width = 2 * character_width(enclosing_char, bold) end
local width_ratio, width, px = nil, nil, nil
repeat
char_count = next_char_count
local str = enclosing_char .. repr_char:rep(char_count) .. enclosing_char
width, px = normalized_text_width(str, size, bold, false, horizontal)
width = width - enclosing_width
width_ratio = width * size / (horizontal and osd_width or osd_height)
next_char_count = math.min(math.floor(char_count / width_ratio * 0.9), 100)
until width_ratio < 0.05 or width_ratio > 0.5 or char_count == next_char_count
width = width / char_count
width_px = {width, px}
if char ~= repr_char then char_widths[repr_char] = width_px end
char_widths[char] = width_px
return width, px
end
---Calculate text width from individual measured characters
---@param text string|number
---@param bold boolean
---@return number, integer
local function character_based_width(text, bold)
local max_width = 0
local min_px = infinity
for line in tostring(text):gmatch("([^\n]*)\n?") do
local total_width = 0
for _, char in utf8_iter(line) do
local width, px = character_width(char, bold)
total_width = total_width + width
if px < min_px then min_px = px end
end
if total_width > max_width then max_width = total_width end
end
return max_width, min_px
end
---Measure width of whole text
---@param text string|number
---@param bold boolean
---@param italic boolean
---@return number, integer
local function whole_text_width(text, bold, italic)
text = tostring(text)
local size, horizontal = fit_on_screen(text)
return normalized_text_width(ass_escape(text), size * 0.9, bold, italic, horizontal)
end
---Get scale factor calculated from font size, bold and italic
---@param opts {size: number; bold?: boolean; italic?: boolean}
local function opts_scale_factor(opts)
return (opts.italic and 1.01 or 1) * opts.size
end
---@type {[boolean]: {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}} | {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}
local width_cache = {}
---Calculate width of text with the given opts
---@param text string|number
---@return number
---@param opts {size: number; bold?: boolean; italic?: boolean}
function text_width(text, opts)
if not text or text == '' then return 0 end
---@type boolean, boolean
local bold, italic = opts.bold or false, opts.italic or false
if options.text_width_estimation then
---@type {[string|number]: {[1]: number, [2]: integer}}
local text_width = get_cache_stage(width_cache, bold)
local width_px = text_width[text]
if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts_scale_factor(opts) end
local width, px = character_based_width(text, bold)
width_cache[bold][text] = {width, px}
return width * opts_scale_factor(opts)
else
---@type {[string|number]: {[1]: number, [2]: integer}}
local text_width = get_cache_stage(get_cache_stage(width_cache, bold), italic)
local width_px = text_width[text]
if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts.size end
local width, px = whole_text_width(text, bold, italic)
width_cache[bold][italic][text] = {width, px}
return width * opts.size
end
end
if #text >= line_start then
lines[#lines + 1] = string.sub(text, line_start)
if line_length > max_length then max_length = line_length end
---Wrap the text at the closest oportunity to target_line_length
---@param text string
---@param opts {size: number; bold?: boolean; italic?: boolean}
---@param target_line_length number
---@return string
function wrap_text(text, opts, target_line_length)
local target_line_width = target_line_length * width_length_ratio * opts.size
local bold, scale_factor = opts.bold or false, opts_scale_factor(opts)
local wrap_at_chars = {' ', ' ', '-', ''}
local remove_when_wrap = {' ', ' '}
local lines = {}
for text_line in text:gmatch("([^\n]*)\n?") do
local line_width = 0
local line_start = 1
local before_end = nil
local before_width = 0
local before_line_start = 0
local before_removed_width = 0
for char_start, char in utf8_iter(text_line) do
local char_end = char_start + #char - 1
local can_wrap = false
for _, c in ipairs(wrap_at_chars) do
if char == c then
can_wrap = true
break
end
end
local char_width = character_width(char, bold) * scale_factor
line_width = line_width + char_width
if can_wrap or (char_end == #text_line) then
local remove = false
for _, c in ipairs(remove_when_wrap) do
if char == c then
remove = true
break
end
end
local line_width_after_remove = line_width - (remove and char_width or 0)
if line_width_after_remove < target_line_width then
before_end = remove and char_start - 1 or char_end
before_width = line_width_after_remove
before_line_start = char_end + 1
before_removed_width = remove and char_width or 0
else
if (target_line_width - before_width) <
(line_width_after_remove - target_line_width) then
lines[#lines + 1] = text_line:sub(line_start, before_end)
line_start = before_line_start
line_width = line_width - before_width - before_removed_width
else
lines[#lines + 1] = text_line:sub(line_start, remove and char_start - 1 or char_end)
line_start = char_end + 1
line_width = remove and line_width - char_width or line_width
line_width = 0
end
before_end = line_start
before_width = 0
end
end
end
if #text_line >= line_start then lines[#lines + 1] = text_line:sub(line_start)
elseif text_line == '' then lines[#lines + 1] = '' end
end
return table.concat(lines, '\n')
end
return table.concat(lines, '\n'), max_length, #lines
end
---Extracts the properties used by property expansion of that string.
@@ -992,9 +1305,12 @@ end
function serialize_chapters(chapters)
chapters = normalize_chapters(chapters)
if not chapters then return end
--- timeline font size isn't accessible here, so normalize to size 1 and then scale during rendering
local opts = {size = 1, bold = true}
for index, chapter in ipairs(chapters) do
chapter.index = index
chapter.title_wrapped, chapter.title_wrapped_width, chapter.title_wrapped_lines = wrap_text(chapter.title, 25)
chapter.title_wrapped = wrap_text(chapter.title, opts, 25)
chapter.title_wrapped_width = text_width(chapter.title_wrapped, opts)
chapter.title_wrapped = ass_escape(chapter.title_wrapped)
end
return chapters
@@ -1077,7 +1393,7 @@ end
-- Tooltip
---@param element {ax: number; ay: number; bx: number; by: number}
---@param value string|number
---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; text_length_override?: number; responsive?: boolean}
---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, responsive?: boolean}
function ass_mt:tooltip(element, value, opts)
opts = opts or {}
opts.size = opts.size or 16
@@ -1087,10 +1403,7 @@ function ass_mt:tooltip(element, value, opts)
local align_top = opts.responsive == false or element.ay - offset > opts.size * 2
local x = element.ax + (element.bx - element.ax) / 2
local y = align_top and element.ay - offset or element.by + offset
local text_width = opts.text_length_override
and opts.text_length_override * opts.size * options.font_height_to_letter_width_ratio
or text_width_estimate(value, opts.size)
local margin = text_width / 2
local margin = (opts.width_overwrite or text_width(value, opts)) / 2 + 10
self:txt(clamp(margin, x, display.width - margin), y, align_top and 2 or 8, value, opts)
end
@@ -1612,9 +1925,9 @@ menu.close()
---@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(), on_close?: fun()}
-- Internal data structure created from `Menu`.
---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; selected_index?: number; keep_open?: boolean; separator?: boolean; items: MenuStackItem[]; parent_menu?: MenuStack; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_length: number; title_width: number; hint_length: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling}
---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; selected_index?: number; keep_open?: boolean; separator?: boolean; items: MenuStackItem[]; parent_menu?: MenuStack; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling}
---@alias MenuStackItem MenuStackValue|MenuStack
---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; keep_open?: boolean; separator?: boolean; title_length: number; title_width: number; hint_length: number; hint_width: number}
---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; keep_open?: boolean; separator?: boolean; title_width: number; hint_width: number}
---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean}
---@class Menu : Element
@@ -1733,7 +2046,7 @@ end
function Menu:update(data)
self.type = data.type
local new_root = {is_root = true, title_length = text_length(data.title), hint_length = text_length(data.hint)}
local new_root = {is_root = true}
local new_all = {}
local new_by_id = {}
local menus_to_serialize = {{new_root, data}}
@@ -1763,8 +2076,6 @@ function Menu:update(data)
'title', 'icon', 'hint', 'active', 'bold', 'italic', 'muted', 'value', 'keep_open', 'separator',
})
if item.keep_open == nil then item.keep_open = menu.keep_open end
item.title_length = text_length(item.title)
item.hint_length = text_length(item.hint)
-- Submenu
if item_data.items then
@@ -1806,13 +2117,16 @@ function Menu:update_content_dimensions()
self.item_padding = round((self.item_height - self.font_size) * 0.6)
self.scroll_step = self.item_height + self.item_spacing
local title_opts = {size = self.font_size, italic = false, bold = false}
local hint_opts = {size = self.font_size_hint}
for _, menu in ipairs(self.all) do
-- Estimate width of a widest item
local max_width = 0
for _, item in ipairs(menu.items) do
local icon_width = item.icon and self.font_size or 0
item.title_width = text_length_width_estimate(item.title_length, self.font_size)
item.hint_width = text_length_width_estimate(item.hint_length, self.font_size_hint)
item.title_width = text_width(item.title, title_opts)
item.hint_width = text_width(item.hint, hint_opts)
local spacings_in_item = 1 + (item.title_width > 0 and 1 or 0)
+ (item.hint_width > 0 and 1 or 0) + (icon_width > 0 and 1 or 0)
local estimated_width = item.title_width + item.hint_width + icon_width
@@ -1821,7 +2135,8 @@ function Menu:update_content_dimensions()
end
-- Also check menu title
local menu_title_width = text_length_width_estimate(menu.title_length, self.font_size)
title_opts.bold, title_opts.italic = true, false
local menu_title_width = text_width(menu.title, title_opts)
if menu_title_width > max_width then max_width = menu_title_width end
menu.max_width = max_width
@@ -2601,20 +2916,20 @@ function Button:render()
-- Tooltip on hover
if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) end
-- Badge
local icon_clip
if self.badge then
local badge_font_size = self.font_size * 0.6
local badge_width = text_width_estimate(self.badge, badge_font_size)
local badge_opts = {size = badge_font_size, color = background, opacity = visibility}
local badge_width = text_width(self.badge, badge_opts)
local width, height = math.ceil(badge_width + (badge_font_size / 7) * 2), math.ceil(badge_font_size * 0.93)
local bx, by = self.bx - 1, self.by - 1
ass:rect(bx - width, by - height, bx, by, {
color = foreground, radius = 2, opacity = visibility,
border = self.active and 0 or 1, border_color = background,
})
ass:txt(bx - width / 2, by - height / 2, 5, self.badge, {
size = badge_font_size, color = background, opacity = visibility,
})
ass:txt(bx - width / 2, by - height / 2, 5, self.badge, badge_opts)
local clip_border = math.max(self.font_size / 20, 1)
local clip_path = assdraw.ass_new()
@@ -3111,28 +3426,27 @@ function Timeline:render()
-- Time values
if text_opacity > 0 then
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2}
-- Upcoming cache time
if buffered_time and options.buffered_time_threshold > 0 and buffered_time < options.buffered_time_threshold then
local x, align = fbx + 5, 4
local font_size = self.font_size * 0.8
local cache_opts = {size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = 1}
local human = round(math.max(buffered_time, 0)) .. 's'
local width = text_width_estimate(human, font_size)
local min_x = bax + spacing + 5 + text_width_estimate(state.time_human, self.font_size)
local max_x = bbx - spacing - 5 - text_width_estimate(state.duration_or_remaining_time_human, self.font_size)
local width = text_width(human, cache_opts)
local time_width = text_width('00:00:00', time_opts)
local min_x, max_x = bax + spacing + 5 + time_width, bbx - spacing - 5 - time_width
if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
draw_timeline_text(x, fcy, align, human, {size = font_size, opacity = text_opacity * 0.6, border = 1})
draw_timeline_text(x, fcy, align, human, cache_opts)
end
local opts = {size = self.font_size, opacity = text_opacity, border = 2}
-- Elapsed time
if state.time_human then
draw_timeline_text(bax + spacing, fcy, 4, state.time_human, opts)
draw_timeline_text(bax + spacing, fcy, 4, state.time_human, time_opts)
end
-- End time
if state.duration_or_remaining_time_human then
draw_timeline_text(bbx - spacing, fcy, 6, state.duration_or_remaining_time_human, opts)
draw_timeline_text(bbx - spacing, fcy, 6, state.duration_or_remaining_time_human, time_opts)
end
end
@@ -3148,7 +3462,9 @@ function Timeline:render()
local tooltip_anchor = {ax = ax, ay = ay, bx = bx, by = by}
-- Timestamp
ass:tooltip(tooltip_anchor, format_time(hovered_seconds), {size = self.font_size, offset = 4})
local opts = {size = self.font_size, offset = 4}
opts.width_overwrite = text_width('00:00:00', opts)
ass:tooltip(tooltip_anchor, format_time(hovered_seconds), opts)
tooltip_anchor.ay = tooltip_anchor.ay - self.font_size - 4
-- Thumbnail
@@ -3175,7 +3491,7 @@ function Timeline:render()
if chapter and not chapter.is_end_only then
ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
size = self.font_size, offset = 10, responsive = false, bold = true,
text_length_override = chapter.title_wrapped_width,
width_overwrite = chapter.title_wrapped_width * self.font_size,
})
end
end
@@ -3320,26 +3636,26 @@ function TopBar:render()
local text = state.playlist_pos .. '' .. state.playlist_count
local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
.. state.playlist_count
local bx = round(title_ax + text_length_width_estimate(#text, self.font_size) + padding * 2)
local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
local bx = round(title_ax + text_width(text, opts) + padding * 2)
ass:rect(title_ax, title_ay, bx, self.by - bg_margin, {color = fg, opacity = visibility, radius = 2})
ass:txt(title_ax + (bx - title_ax) / 2, self.ay + (self.size / 2), 5, formatted_text, {
size = self.font_size, wrap = 2, color = fgt, opacity = visibility,
})
ass:txt(title_ax + (bx - title_ax) / 2, self.ay + (self.size / 2), 5, formatted_text, opts)
title_ax = bx + bg_margin
end
-- Title
local text = state.title
if max_bx - title_ax > self.font_size * 3 and text and text ~= '' then
local bx = math.min(max_bx, title_ax + text_width_estimate(text, self.font_size) + padding * 2)
local opts = {
size = self.font_size, wrap = 2, color = bgt, border = 1, border_color = bg, opacity = visibility,
clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by),
}
local bx = math.min(max_bx, title_ax + text_width(text, opts) + padding * 2)
local by = self.by - bg_margin
ass:rect(title_ax, title_ay, bx, by, {
color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2,
})
ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, text, {
size = self.font_size, wrap = 2, color = bgt, border = 1, border_color = bg, opacity = visibility,
clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by),
})
ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, text, opts)
title_ay = by + 1
end
@@ -3349,14 +3665,16 @@ function TopBar:render()
local height = font_size * 1.5
local text = '' .. state.current_chapter.index .. ': ' .. state.current_chapter.title
local by = title_ay + height
local bx = math.min(max_bx, title_ax + text_width_estimate(text, font_size) + padding * 2)
local opts = {
size = font_size, italic = true, wrap = 2, color = bgt,
border = 1, border_color = bg, opacity = visibility * 0.8,
}
local bx = math.min(max_bx, title_ax + text_width(text, opts) + padding * 2)
opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
ass:rect(title_ax, title_ay, bx, by, {
color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2,
})
ass:txt(title_ax + padding, title_ay + height / 2, 4, '{\\i1}' .. text .. '{\\i0}', {
size = font_size, wrap = 2, color = bgt, border = 1, border_color = bg, opacity = visibility * 0.8,
clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by),
})
ass:txt(title_ax + padding, title_ay + height / 2, 4, text, opts)
end
end
@@ -3981,7 +4299,7 @@ function open_command_menu(data, opts)
mp.command(value)
else
---@diagnostic disable-next-line: deprecated
mp.commandv((unpack or table.unpack)(value))
mp.commandv(unpack(value))
end
end, opts)
if opts and opts.submenu then menu:activate_submenu(opts.submenu) end