This commit is contained in:
2026-03-27 07:06:16 +01:00
commit 1541961403
340 changed files with 151916 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
--[[ ASSDRAW EXTENSIONS ]]
local ass_mt = getmetatable(assdraw.ass_new())
-- Opacity.
---@param self table|nil
---@param opacity number|{primary?: number; border?: number, shadow?: number, main?: number} Opacity of all elements.
---@param fraction? number Optionally adjust the above opacity by this fraction.
---@return string|nil
function ass_mt.opacity(self, opacity, fraction)
fraction = fraction ~= nil and fraction or 1
opacity = type(opacity) == 'table' and opacity or {main = opacity}
local text = ''
if opacity.main then
text = text .. string.format('\\alpha&H%X&', opacity_to_alpha(opacity.main * fraction))
end
if opacity.primary then
text = text .. string.format('\\1a&H%X&', opacity_to_alpha(opacity.primary * fraction))
end
if opacity.border then
text = text .. string.format('\\3a&H%X&', opacity_to_alpha(opacity.border * fraction))
end
if opacity.shadow then
text = text .. string.format('\\4a&H%X&', opacity_to_alpha(opacity.shadow * fraction))
end
if self == nil then
return text
elseif text ~= '' then
self.text = self.text .. '{' .. text .. '}'
end
end
-- Icon.
---@param x number
---@param y number
---@param size number
---@param name string
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string; align?: number}
function ass_mt:icon(x, y, size, name, opts)
opts = opts or {}
opts.font, opts.size, opts.bold = 'MaterialIconsRound-Regular', size, false
self:txt(x, y, opts.align or 5, name, opts)
end
-- Text.
-- Named `txt` because `ass.text` is a value.
---@param x number
---@param y number
---@param align number
---@param value string|number
---@param opts {size: number; font?: string; color?: string; bold?: boolean; italic?: boolean; border?: number; border_color?: string; shadow?: number; shadow_color?: string; rotate?: number; wrap?: number; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}; clip?: string}
function ass_mt:txt(x, y, align, value, opts)
local border_size = opts.border or 0
local shadow_size = opts.shadow or 0
local tags = '\\pos(' .. x .. ',' .. y .. ')\\rDefault\\an' .. align .. '\\blur0'
-- font
tags = tags .. '\\fn' .. (opts.font or config.font)
-- font size
tags = tags .. '\\fs' .. opts.size
-- bold
if opts.bold or (opts.bold == nil and options.font_bold) then tags = tags .. '\\b1' end
-- italic
if opts.italic then tags = tags .. '\\i1' end
-- rotate
if opts.rotate then tags = tags .. '\\frz' .. opts.rotate end
-- wrap
if opts.wrap then tags = tags .. '\\q' .. opts.wrap end
-- border
tags = tags .. '\\bord' .. border_size
-- shadow
tags = tags .. '\\shad' .. shadow_size
-- colors
tags = tags .. '\\1c&H' .. (opts.color or bgt)
if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
if shadow_size > 0 then tags = tags .. '\\4c&H' .. (opts.shadow_color or bg) end
-- opacity
if opts.opacity then tags = tags .. self.opacity(nil, opts.opacity) end
-- clip
if opts.clip then tags = tags .. opts.clip end
-- render
self:new_event()
self.text = self.text .. '{' .. tags .. '}' .. value
end
-- Tooltip.
---@param element Rect
---@param value string|number
---@param opts? {size?: number; align?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean; invert_colors?: boolean}
function ass_mt:tooltip(element, value, opts)
if value == '' then return end
opts = opts or {}
opts.size = opts.size or round(16 * state.scale)
opts.border = options.text_border * state.scale
opts.border_color = opts.invert_colors and fg or bg
opts.margin = opts.margin or round(10 * state.scale)
opts.lines = opts.lines or 1
opts.color = opts.invert_colors and bg or fg
local offset = opts.offset or 2
local padding_y = round(opts.size / 6)
local padding_x = round(opts.size / 3)
local width = (opts.width_overwrite or text_width(value, opts)) + padding_x * 2
local height = opts.size * opts.lines + 2 * padding_y
local width_half, height_half = width / 2, height / 2
local margin = opts.margin + Elements:v('window_border', 'size', 0)
local align = opts.align or 8
local x, y = 0, 0 -- center of tooltip
-- Flip alignment to other side when not enough space
if opts.responsive ~= false then
if align == 8 then
if element.ay - offset - height < margin then align = 2 end
elseif align == 2 then
if element.by + offset + height > display.height - margin then align = 8 end
elseif align == 6 then
if element.bx + offset + width > display.width - margin then align = 4 end
elseif align == 4 then
if element.ax - offset - width < margin then align = 6 end
end
end
-- Calculate tooltip center based on alignment
if align == 8 or align == 2 then
x = clamp(margin + width_half, element.ax + (element.bx - element.ax) / 2, display.width - margin - width_half)
y = align == 8 and element.ay - offset - height_half or element.by + offset + height_half
else
x = align == 6 and element.bx + offset + width_half or element.ax - offset - width_half
y = clamp(margin + height_half, element.ay + (element.by - element.ay) / 2, display.height - margin - height_half)
end
-- Draw
local ax, ay, bx, by = round(x - width_half), round(y - height_half), round(x + width_half), round(y + height_half)
self:rect(ax, ay, bx, by, {
color = opts.invert_colors and fg or bg, opacity = config.opacity.tooltip, radius = state.radius
})
local func = opts.timestamp and self.timestamp or self.txt
func(self, x, y, 5, tostring(value), opts)
return {ax = element.ax, ay = ay, bx = element.bx, by = by}
end
-- Timestamp with each digit positioned as if it was replaced with 0
---@param x number
---@param y number
---@param align number
---@param timestamp string
---@param opts {size: number; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}}
function ass_mt:timestamp(x, y, align, timestamp, opts)
local widths, width_total = {}, 0
zero_rep = timestamp_zero_rep(timestamp)
for i = 1, #zero_rep do
local width = text_width(zero_rep:sub(i, i), opts)
widths[i] = width
width_total = width_total + width
end
-- shift x and y to fit align 5
local mod_align = align % 3
if mod_align == 0 then
x = x - width_total
elseif mod_align == 2 then
x = x - width_total / 2
end
if align < 4 then
y = y - opts.size / 2
elseif align > 6 then
y = y + opts.size / 2
end
local opacity = opts.opacity
local primary_opacity
if type(opacity) == 'table' then
opts.opacity = {main = opacity.main, border = opacity.border, shadow = opacity.shadow, primary = 0}
primary_opacity = opacity.primary or opacity.main
else
opts.opacity = {main = opacity, primary = 0}
primary_opacity = opacity
end
for i, width in ipairs(widths) do
self:txt(x + width / 2, y, 5, timestamp:sub(i, i), opts)
x = x + width
end
x = x - width_total
opts.opacity = {main = 0, primary = primary_opacity or 1}
for i, width in ipairs(widths) do
self:txt(x + width / 2, y, 5, timestamp:sub(i, i), opts)
x = x + width
end
opts.opacity = opacity
end
-- Rectangle.
---@param ax number
---@param ay number
---@param bx number
---@param by number
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}; clip?: string, radius?: number}
function ass_mt:rect(ax, ay, bx, by, opts)
opts = opts or {}
local border_size = opts.border or 0
local tags = '\\pos(0,0)\\rDefault\\an7\\blur0'
-- border
tags = tags .. '\\bord' .. border_size
-- colors
tags = tags .. '\\1c&H' .. (opts.color or fg)
if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
-- opacity
if opts.opacity then tags = tags .. self.opacity(nil, opts.opacity) end
-- clip
if opts.clip then
tags = tags .. opts.clip
end
-- draw
self:new_event()
self.text = self.text .. '{' .. tags .. '}'
self:draw_start()
if opts.radius and opts.radius > 0 then
self:round_rect_cw(ax, ay, bx, by, opts.radius)
else
self:rect_cw(ax, ay, bx, by)
end
self:draw_stop()
end
-- Circle.
---@param x number
---@param y number
---@param radius number
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string}
function ass_mt:circle(x, y, radius, opts)
opts = opts or {}
opts.radius = radius
self:rect(x - radius, y - radius, x + radius, y + radius, opts)
end
-- Texture.
---@param ax number
---@param ay number
---@param bx number
---@param by number
---@param char string Texture font character.
---@param opts {size?: number; color: string; opacity?: number; clip?: string; anchor_x?: number, anchor_y?: number}
function ass_mt:texture(ax, ay, bx, by, char, opts)
opts = opts or {}
local anchor_x, anchor_y = opts.anchor_x or ax, opts.anchor_y or ay
local clip = opts.clip or ('\\clip(' .. ax .. ',' .. ay .. ',' .. bx .. ',' .. by .. ')')
local tile_size, opacity = opts.size or 100, opts.opacity or 0.2
local x, y = ax - (ax - anchor_x) % tile_size, ay - (ay - anchor_y) % tile_size
local width, height = bx - x, by - y
local line = string.rep(char, math.ceil((width / tile_size)))
local lines = ''
for i = 1, math.ceil(height / tile_size), 1 do lines = lines .. (lines == '' and '' or '\\N') .. line end
self:txt(
x, y, 7, lines,
{font = 'uosc_textures', size = tile_size, color = opts.color, bold = false, opacity = opacity, clip = clip})
end
-- Rotating spinner icon.
---@param x number
---@param y number
---@param size number
---@param opts? {color?: string; opacity?: number; clip?: string; border?: number; border_color?: string;}
function ass_mt:spinner(x, y, size, opts)
opts = opts or {}
opts.rotate = (state.render_last_time * 1.75 % 1) * -360
opts.color = opts.color or fg
self:icon(x, y, size, 'autorenew', opts)
request_render()
end
+74
View File
@@ -0,0 +1,74 @@
---@alias ButtonData {icon: string; active?: boolean; badge?: string; command?: string | string[]; tooltip?: string;}
---@alias ButtonSubscriber fun(data: ButtonData)
local buttons = {
---@type ButtonData[]
data = {},
---@type table<string, ButtonSubscriber[]>
subscribers = {},
}
---@param name string
---@return ButtonData
function buttons:get(name)
return self.data[name] or {icon = 'help_center', tooltip = 'Uninitialized button "' .. name .. '"'}
end
---@param name string
---@param callback fun(data: ButtonData)
function buttons:subscribe(name, callback)
local pool = self.subscribers[name]
if not pool then
pool = {}
self.subscribers[name] = pool
end
pool[#pool + 1] = callback
return function() buttons:unsubscribe(name, callback) end
end
---@param name string
---@param callback? ButtonSubscriber
function buttons:unsubscribe(name, callback)
if self.subscribers[name] then
if callback == nil then
self.subscribers[name] = {}
else
itable_delete_value(self.subscribers[name], callback)
end
end
end
---@param name string
function buttons:trigger(name)
local pool = self.subscribers[name]
if pool then
local data = self:get(name)
for _, callback in ipairs(pool) do callback(data) end
end
end
---@param name string
---@param data ButtonData
function buttons:set(name, data)
buttons.data[name] = data
buttons:trigger(name)
request_render()
end
mp.register_script_message('set-button', function(name, data)
if type(name) ~= 'string' then
msg.error('Invalid set-button message parameter: 1st parameter (name) has to be a string.')
return
end
if type(data) ~= 'string' then
msg.error('Invalid set-button message parameter: 2nd parameter (data) has to be a string.')
return
end
local data = utils.parse_json(data)
if type(data) == 'table' and type(data.icon) == 'string' then
buttons:set(name, data)
end
end)
return buttons
+66
View File
@@ -0,0 +1,66 @@
require('lib/text')
local char_dir = mp.get_script_directory() .. '/char-conv/'
local data = {}
local languages = get_languages()
for _, lang in ipairs(languages) do
table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
end
local romanization = {}
local function get_romanization_table()
for k, v in pairs(data) do
for _, char in utf8_iter(v) do
romanization[char] = k
end
end
end
get_romanization_table()
function need_romanization()
return next(romanization) ~= nil
end
function char_conv(chars, use_ligature, has_separator)
local separator = has_separator or ' '
local length = 0
local char_conv, sp, cache = {}, {}, {}
local roman_list = {}
local chars_length = utf8_length(chars)
local concat = table.concat
for _, char in utf8_iter(chars) do
local match = romanization[char] or char
roman_list[#roman_list + 1] = match
if use_ligature then
char_conv[#char_conv + 1] = match
else
length = length + 1
if #char <= 2 then
if (char ~= ' ' and length ~= chars_length) then
cache[#cache + 1] = match
elseif (char == ' ' or length == chars_length) then
if length == chars_length then
cache[#cache + 1] = match
end
sp[#sp + 1] = concat(cache)
itable_clear(cache)
end
else
if next(cache) ~= nil then
sp[#sp + 1] = concat(cache)
itable_clear(cache)
end
sp[#sp + 1] = match
end
end
end
if use_ligature then
return concat(char_conv), roman_list
else
return concat(sp, separator), roman_list
end
end
return char_conv
+469
View File
@@ -0,0 +1,469 @@
---@alias CursorEventHandler fun(shortcut: Shortcut)
local cursor = {
x = math.huge,
y = math.huge,
hidden = true,
distance = 0, -- Distance traveled during current move. Reset by `cursor.distance_reset_timer`.
last_hover = false, -- Stores `mouse.hover` boolean of the last mouse event for enter/leave detection.
-- Event handlers that are only fired on zones defined during render loop.
---@type {event: string, hitbox: Hitbox; handler: CursorEventHandler}[]
zones = {},
handlers = {
primary_down = {},
primary_up = {},
secondary_down = {},
secondary_up = {},
wheel_down = {},
wheel_up = {},
move = {},
},
first_real_mouse_move_received = false,
history = CircularBuffer:new(10),
autohide_fs_only = nil,
-- Tracks current key binding levels for each event. 0: disabled, 1: enabled, 2: enabled + window dragging prevented
binding_levels = {
mbtn_left = 0,
mbtn_left_dbl = 0,
mbtn_right = 0,
wheel = 0,
},
is_dragging_prevented = false,
event_forward_map = {
primary_down = 'MBTN_LEFT',
primary_up = 'MBTN_LEFT',
secondary_down = 'MBTN_RIGHT',
secondary_up = 'MBTN_RIGHT',
wheel_down = 'WHEEL_DOWN',
wheel_up = 'WHEEL_UP',
},
event_binding_map = {
primary_down = 'mbtn_left',
primary_up = 'mbtn_left',
primary_click = 'mbtn_left',
secondary_down = 'mbtn_right',
secondary_up = 'mbtn_right',
secondary_click = 'mbtn_right',
wheel_down = 'wheel',
wheel_up = 'wheel',
},
window_dragging_blockers = create_set({'primary_click', 'primary_down'}),
event_propagation_blockers = {
primary_down = 'primary_click',
primary_click = 'primary_down',
secondary_down = 'secondary_click',
secondary_click = 'secondary_down',
},
event_meta = {
primary_down = {is_start = true, trigger_event = 'primary_click'},
primary_up = {is_end = true, start_event = 'primary_down', trigger_event = 'primary_click'},
secondary_down = {is_start = true, trigger_event = 'secondary_click'},
secondary_up = {is_end = true, start_event = 'secondary_down', trigger_event = 'secondary_click'},
},
-- Holds positions and times of starting events (events that start compound events like click).
---@type {[string]: {x: number, y: number, time: number, zone_handled: boolean}}
last_events = {},
}
cursor.autohide_timer = mp.add_timeout(1, function() cursor:autohide() end)
cursor.autohide_timer:kill()
mp.observe_property('cursor-autohide', 'number', function(_, val)
cursor.autohide_timer.timeout = (val or 1000) / 1000
end)
cursor.distance_reset_timer = mp.add_timeout(0.2, function()
cursor.distance = 0
request_render()
end)
cursor.distance_reset_timer:kill()
-- Called at the beginning of each render
function cursor:clear_zones()
itable_clear(self.zones)
end
---@param hitbox Hitbox
function cursor:collides_with(hitbox)
return point_collides_with(self, hitbox)
end
-- Returns zone for event at current cursor position.
---@param event string
function cursor:find_zone(event)
-- Premature optimization to ignore a high frequency event that is not needed as a zone atm.
if event == 'move' then return end
for i = #self.zones, 1, -1 do
local zone = self.zones[i]
local is_blocking_only = zone.event == self.event_propagation_blockers[event]
if (zone.event == event or is_blocking_only) and self:collides_with(zone.hitbox) then
return not is_blocking_only and zone or nil
end
end
end
-- Defines an event zone for a hitbox on currently rendered screen. Available events:
-- - primary_down, primary_up, primary_click, secondary_down, secondary_up, secondary_click, wheel_down, wheel_up
--
-- Notes:
-- - Zones are cleared on beginning of every `render()`, and need to be rebound.
-- - One event type per zone: only the last bound zone per event gets triggered.
-- - In current implementation, you have to choose between `_click` or `_down`. Binding both makes only the last bound fire.
-- - Primary `_down` and `_click` disable dragging. Define `window_drag = true` on hitbox to re-enable.
-- - Anything that disables dragging also implicitly disables cursor autohide.
-- - `move` event zones are ignored due to it being a high frequency event that is currently not needed as a zone.
---@param event string
---@param hitbox Hitbox
---@param callback CursorEventHandler
function cursor:zone(event, hitbox, callback)
self.zones[#self.zones + 1] = {event = event, hitbox = hitbox, handler = callback}
end
-- Binds a permanent cursor event handler active until manually unbound using `cursor:off()`.
-- `_click` events are not available as permanent global events, only as zones.
---@param event string
---@param callback CursorEventHandler
---@return fun() disposer Unbinds the event.
function cursor:on(event, callback)
if self.handlers[event] and not itable_index_of(self.handlers[event], callback) then
self.handlers[event][#self.handlers[event] + 1] = callback
self:decide_keybinds()
end
return function() self:off(event, callback) end
end
-- Unbinds a cursor event handler.
---@param event string
function cursor:off(event, callback)
if self.handlers[event] then
local index = itable_index_of(self.handlers[event], callback)
if index then
table.remove(self.handlers[event], index)
self:decide_keybinds()
end
end
end
-- Binds a cursor event handler to be called once.
---@param event string
function cursor:once(event, callback)
local function callback_wrap()
callback()
self:off(event, callback_wrap)
end
return self:on(event, callback_wrap)
end
-- Trigger the event.
---@param event string
---@param shortcut? Shortcut
function cursor:trigger(event, shortcut)
local forward, zone_handled = true, false
-- Call raw event handlers.
local zone = self:find_zone(event)
local callbacks = self.handlers[event]
if zone or #callbacks > 0 then
forward = false
if zone and shortcut then
zone.handler(shortcut)
zone_handled = true
end
for _, callback in ipairs(callbacks) do callback(shortcut) end
end
if event ~= 'move' then
-- Call compound/parent (click) event handlers if both start and end events are within `parent_zone.hitbox`.
local meta = self.event_meta[event]
if meta then
-- Trigger compound event
local parent_zone = self:find_zone(meta.trigger_event)
if parent_zone then
forward = false -- Canceled here so we don't forward down events if they can lead to a click.
if meta.is_end then
local start_event = self.last_events[meta.start_event]
if start_event and point_collides_with(start_event, parent_zone.hitbox) and shortcut then
parent_zone.handler(create_shortcut('primary_click', shortcut.modifiers))
end
end
end
end
-- Forward unhandled events.
if forward then
local forward_name = self.event_forward_map[event]
local last_down = meta and meta.is_end and self.last_events[meta.start_event]
local down_zone_handled = last_down and last_down.zone_handled
if forward_name and not down_zone_handled then
-- Forward events if there was no handler.
local active = find_active_keybindings(forward_name)
if active and active.cmd then
local is_wheel = event:find('wheel', 1, true)
local is_up = event:sub(-3) == '_up'
if active.owner then
-- Binding belongs to other script, so make it look like regular key event.
-- Mouse bindings are simple, other keys would require repeat and pressed handling,
-- which can't be done with mp.set_key_bindings(), but is possible with mp.add_key_binding().
local state = is_wheel and 'pm' or is_up and 'um' or 'dm'
local name = active.cmd:sub(active.cmd:find('/') + 1, -1)
mp.commandv('script-message-to', active.owner, 'key-binding', name, state, forward_name)
elseif is_wheel or is_up then
-- input.conf binding, react to button release for mouse buttons
mp.command(active.cmd)
end
end
end
end
end
-- Track last events
local last = self.last_events[event] or {}
last.x, last.y, last.time, last.zone_handled = self.x, self.y, mp.get_time(), zone_handled
self.last_events[event] = last
-- Refresh cursor autohide timer.
self:queue_autohide()
end
-- Enables or disables keybinding groups based on what event listeners are bound.
function cursor:decide_keybinds()
local new_levels = {mbtn_left = 0, mbtn_right = 0, wheel = 0}
self.is_dragging_prevented = false
-- Check global events.
for name, handlers in ipairs(self.handlers) do
local binding = self.event_binding_map[name]
if binding then
new_levels[binding] = math.max(new_levels[binding], #handlers > 0 and 1 or 0)
end
end
-- Check zones.
for _, zone in ipairs(self.zones) do
local binding = self.event_binding_map[zone.event]
if binding and cursor:collides_with(zone.hitbox) then
local new_level = (self.window_dragging_blockers[zone.event] and zone.hitbox.window_drag ~= true) and 2
or math.max(new_levels[binding], zone.hitbox.window_drag == false and 2 or 1)
-- We only allow dragging preventing levels when cursor is on top of the draggable element,
-- otherwise it breaks window dragging. This means touch devices need to tap the draggable
-- element before they can start dragging it. Can't think of a way around this atm.
if new_level > 1 and not cursor:collides_with(zone.hitbox) then
new_level = 1
end
new_levels[binding] = math.max(new_levels[binding], new_level)
if new_level > 1 then
self.is_dragging_prevented = true
end
end
end
-- Window dragging only gets prevented when on top of an element, which is when double clicks should be ignored.
new_levels.mbtn_left_dbl = new_levels.mbtn_left == 2 and 2 or 0
for name, level in pairs(new_levels) do
if level ~= self.binding_levels[name] then
local flags = level == 1 and 'allow-vo-dragging+allow-hide-cursor' or ''
mp[(level == 0 and 'disable' or 'enable') .. '_key_bindings'](name, flags)
self.binding_levels[name] = level
self:queue_autohide()
end
end
end
function cursor:_find_history_sample()
local time = mp.get_time()
for _, e in self.history:iter_rev() do
if time - e.time > 0.1 then
return e
end
end
return self.history:tail()
end
-- Returns the current velocity vector in pixels per second.
---@return Point
function cursor:get_velocity()
local snap = self:_find_history_sample()
if snap then
local x, y, time = self.x - snap.x, self.y - snap.y, mp.get_time()
local time_diff = time - snap.time
if time_diff > 0.001 then
return {x = x / time_diff, y = y / time_diff}
end
end
return {x = 0, y = 0}
end
---@param x integer
---@param y integer
function cursor:move(x, y)
local old_x, old_y = self.x, self.y
-- mpv reports initial mouse position on linux as (0, 0), which always
-- displays the top bar, so we hardcode cursor position as infinity until
-- we receive a first real mouse move event with coordinates other than 0,0.
if not self.first_real_mouse_move_received then
if x > 0 and y > 0 and x < 99999999 and y < 99999999 then
self.first_real_mouse_move_received = true
else
x, y = math.huge, math.huge
end
end
-- Add 0.5 to be in the middle of the pixel
self.x, self.y = x + 0.5, y + 0.5
if old_x ~= self.x or old_y ~= self.y then
if self.x == math.huge or self.y == math.huge then
self.hidden = true
self.history:clear()
-- Slowly fadeout elements that are currently visible
for _, id in ipairs(config.cursor_leave_fadeout_elements) do
local element = Elements[id]
if element then
local visibility = element:get_visibility()
if visibility > 0 then
element:tween_property('forced_visibility', visibility, 0, function()
element.forced_visibility = nil
end)
end
end
end
Elements:update_proximities()
Elements:trigger('global_mouse_leave')
else
if self.hidden then
-- Cancel potential fadeouts
for _, id in ipairs(config.cursor_leave_fadeout_elements) do
if Elements[id] then Elements[id]:tween_stop() end
end
self.hidden = false
Elements:trigger('global_mouse_enter')
end
-- Update current move travel distance
-- `mp.get_time() - last.time < 0.5` check is there to ignore first event after long inactivity to
-- filter out big jumps due to window being repositioned/rescaled (e.g. opening a different file).
local last = self.last_events.move
if last and last.x < math.huge and last.y < math.huge and mp.get_time() - last.time < 0.5 then
self.distance = self.distance + get_point_to_point_proximity(cursor, last)
cursor.distance_reset_timer:kill()
cursor.distance_reset_timer:resume()
end
Elements:update_proximities()
-- Update history
self.history:insert({x = self.x, y = self.y, time = mp.get_time()})
end
Elements:proximity_trigger('mouse_move')
self:queue_autohide()
end
self:trigger('move')
request_render()
end
function cursor:leave() self:move(math.huge, math.huge) end
function cursor:is_autohide_allowed()
return options.autohide and (not self.autohide_fs_only or state.fullscreen)
and not self.is_dragging_prevented
and not Menu:is_open()
end
mp.observe_property('cursor-autohide-fs-only', 'bool', function(_, val) cursor.autohide_fs_only = val end)
-- Cursor auto-hiding after period of inactivity.
function cursor:autohide()
if self:is_autohide_allowed() then
self:leave()
self.autohide_timer:kill()
end
end
function cursor:queue_autohide()
if self:is_autohide_allowed() then
self.autohide_timer:kill()
self.autohide_timer:resume()
end
end
-- Calculates distance in which cursor reaches rectangle if it continues moving on the same path.
-- Returns `nil` if cursor is not moving towards the rectangle.
---@param rect Rect
function cursor:direction_to_rectangle_distance(rect)
local prev = self:_find_history_sample()
if not prev then return false end
local end_x, end_y = self.x + (self.x - prev.x) * 1e10, self.y + (self.y - prev.y) * 1e10
return get_ray_to_rectangle_distance(self.x, self.y, end_x, end_y, rect)
end
---@param event string
---@param shortcut Shortcut
---@param cb? fun(shortcut: Shortcut)
function cursor:create_handler(event, shortcut, cb)
return function()
if cb then cb(shortcut) end
self:trigger(event, shortcut)
end
end
-- Movement
local function handle_mouse_pos(_, mouse)
if not mouse then return end
if cursor.last_hover and not mouse.hover then
cursor:leave()
elseif not (cursor.last_hover == false and mouse.hover == false) then -- filters out duplicate mouse out events
cursor:move(mouse.x, mouse.y)
end
cursor.last_hover = mouse.hover
end
local function handle_touch_pos(_, touches)
if not touches then return end
local touch = touches[1]
if touch then
cursor:move(touch.x, touch.y)
end
end
mp.observe_property('mouse-pos', 'native', handle_mouse_pos)
mp.observe_property('touch-pos', 'native', handle_touch_pos)
-- Key binding groups
local modifiers = {nil, 'alt', 'alt+ctrl', 'alt+shift', 'alt+ctrl+shift', 'ctrl', 'ctrl+shift', 'shift'}
local primary_bindings = {}
for i = 1, #modifiers do
local mods = modifiers[i]
local mp_name = (mods and mods .. '+' or '') .. 'mbtn_left'
primary_bindings[#primary_bindings + 1] = {
mp_name,
cursor:create_handler('primary_up', create_shortcut('primary_up', mods)),
cursor:create_handler('primary_down', create_shortcut('primary_down', mods), function(...)
handle_mouse_pos(nil, mp.get_property_native('mouse-pos'))
end),
}
end
mp.set_key_bindings(primary_bindings, 'mbtn_left', 'force')
mp.set_key_bindings({
{'mbtn_left_dbl', 'ignore'},
}, 'mbtn_left_dbl', 'force')
mp.set_key_bindings({
{
'mbtn_right',
cursor:create_handler('secondary_up', create_shortcut('secondary_up')),
cursor:create_handler('secondary_down', create_shortcut('secondary_down')),
},
}, 'mbtn_right', 'force')
mp.set_key_bindings({
{'wheel_up', cursor:create_handler('wheel_up', create_shortcut('wheel_up'))},
{'wheel_down', cursor:create_handler('wheel_down', create_shortcut('wheel_down'))},
}, 'wheel', 'force')
return cursor
+297
View File
@@ -0,0 +1,297 @@
--[[ The MIT License (MIT)
Copyright (c) 2020 Seth Warn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. ]]
-- The lua implementation of the fzy string matching algorithm
local SCORE_GAP_LEADING = -0.005
local SCORE_GAP_TRAILING = -0.005
local SCORE_GAP_INNER = -0.01
local SCORE_MATCH_CONSECUTIVE = 1.0
local SCORE_MATCH_SLASH = 0.9
local SCORE_MATCH_WORD = 0.8
local SCORE_MATCH_CAPITAL = 0.7
local SCORE_MATCH_DOT = 0.6
local SCORE_MAX = math.huge
local SCORE_MIN = -math.huge
local MATCH_MAX_LENGTH = 1024
local fzy = {}
-- Check if `needle` is a subsequence of the `haystack`.
--
-- Usually called before `score` or `positions`.
--
-- Args:
-- needle (string)
-- haystack (string)
-- case_sensitive (bool, optional): defaults to false
--
-- Returns:
-- bool
function fzy.has_match(needle, haystack, case_sensitive)
if not case_sensitive then
needle = string.lower(needle)
haystack = string.lower(haystack)
end
local j = 1
for i = 1, string.len(needle) do
j = string.find(haystack, needle:sub(i, i), j, true)
if not j then
return false
else
j = j + 1
end
end
return true
end
local function is_lower(c)
return c:match("%l")
end
local function is_upper(c)
return c:match("%u")
end
local function precompute_bonus(haystack)
local match_bonus = {}
local last_char = "/"
for i = 1, string.len(haystack) do
local this_char = haystack:sub(i, i)
if last_char == "/" or last_char == "\\" then
match_bonus[i] = SCORE_MATCH_SLASH
elseif last_char == "-" or last_char == "_" or last_char == " " then
match_bonus[i] = SCORE_MATCH_WORD
elseif last_char == "." then
match_bonus[i] = SCORE_MATCH_DOT
elseif is_lower(last_char) and is_upper(this_char) then
match_bonus[i] = SCORE_MATCH_CAPITAL
else
match_bonus[i] = 0
end
last_char = this_char
end
return match_bonus
end
local function compute(needle, haystack, D, M, case_sensitive)
-- Note that the match bonuses must be computed before the arguments are
-- converted to lowercase, since there are bonuses for camelCase.
local match_bonus = precompute_bonus(haystack)
local n = string.len(needle)
local m = string.len(haystack)
if not case_sensitive then
needle = string.lower(needle)
haystack = string.lower(haystack)
end
-- Because lua only grants access to chars through substring extraction,
-- get all the characters from the haystack once now, to reuse below.
local haystack_chars = {}
for i = 1, m do
haystack_chars[i] = haystack:sub(i, i)
end
for i = 1, n do
D[i] = {}
M[i] = {}
local prev_score = SCORE_MIN
local gap_score = i == n and SCORE_GAP_TRAILING or SCORE_GAP_INNER
local needle_char = needle:sub(i, i)
for j = 1, m do
if needle_char == haystack_chars[j] then
local score = SCORE_MIN
if i == 1 then
score = ((j - 1) * SCORE_GAP_LEADING) + match_bonus[j]
elseif j > 1 then
local a = M[i - 1][j - 1] + match_bonus[j]
local b = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE
score = math.max(a, b)
end
D[i][j] = score
prev_score = math.max(score, prev_score + gap_score)
M[i][j] = prev_score
else
D[i][j] = SCORE_MIN
prev_score = prev_score + gap_score
M[i][j] = prev_score
end
end
end
end
-- Compute a matching score.
--
-- Args:
-- needle (string): must be a subsequence of `haystack`, or the result is
-- undefined.
-- haystack (string)
-- case_sensitive (bool, optional): defaults to false
--
-- Returns:
-- number: higher scores indicate better matches. See also `get_score_min`
-- and `get_score_max`.
function fzy.score(needle, haystack, case_sensitive)
local n = string.len(needle)
local m = string.len(haystack)
if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > m then
return SCORE_MIN
elseif n == m then
return SCORE_MAX
else
local D = {}
local M = {}
compute(needle, haystack, D, M, case_sensitive)
return M[n][m]
end
end
-- Compute the locations where fzy matches a string.
--
-- Determine where each character of the `needle` is matched to the `haystack`
-- in the optimal match.
--
-- Args:
-- needle (string): must be a subsequence of `haystack`, or the result is
-- undefined.
-- haystack (string)
-- case_sensitive (bool, optional): defaults to false
--
-- Returns:
-- {int,...}: indices, where `indices[n]` is the location of the `n`th
-- character of `needle` in `haystack`.
-- number: the same matching score returned by `score`
function fzy.positions(needle, haystack, case_sensitive)
local n = string.len(needle)
local m = string.len(haystack)
if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > m then
return {}, SCORE_MIN
elseif n == m then
local consecutive = {}
for i = 1, n do
consecutive[i] = i
end
return consecutive, SCORE_MAX
end
local D = {}
local M = {}
compute(needle, haystack, D, M, case_sensitive)
local positions = {}
local match_required = false
local j = m
for i = n, 1, -1 do
while j >= 1 do
if D[i][j] ~= SCORE_MIN and (match_required or D[i][j] == M[i][j]) then
match_required = (i ~= 1) and (j ~= 1) and (
M[i][j] == D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE)
positions[i] = j
j = j - 1
break
else
j = j - 1
end
end
end
return positions, M[n][m]
end
-- Apply `has_match` and `positions` to an array of haystacks.
--
-- Args:
-- needle (string)
-- haystack ({string, ...})
-- case_sensitive (bool, optional): defaults to false
--
-- Returns:
-- {{idx, positions, score}, ...}: an array with one entry per matching line
-- in `haystacks`, each entry giving the index of the line in `haystacks`
-- as well as the equivalent to the return value of `positions` for that
-- line.
function fzy.filter(needle, haystacks, case_sensitive)
local result = {}
for i, line in ipairs(haystacks) do
if fzy.has_match(needle, line, case_sensitive) then
local p, s = fzy.positions(needle, line, case_sensitive)
table.insert(result, {i, p, s})
end
end
return result
end
-- The lowest value returned by `score`.
--
-- In two special cases:
-- - an empty `needle`, or
-- - a `needle` or `haystack` larger than than `get_max_length`,
-- the `score` function will return this exact value, which can be used as a
-- sentinel. This is the lowest possible score.
function fzy.get_score_min()
return SCORE_MIN
end
-- The score returned for exact matches. This is the highest possible score.
function fzy.get_score_max()
return SCORE_MAX
end
-- The maximum size for which `fzy` will evaluate scores.
function fzy.get_max_length()
return MATCH_MAX_LENGTH
end
-- The minimum score returned for normal matches.
--
-- For matches that don't return `get_score_min`, their score will be greater
-- than than this value.
function fzy.get_score_floor()
return MATCH_MAX_LENGTH * SCORE_GAP_INNER
end
-- The maximum score for non-exact matches.
--
-- For matches that don't return `get_score_max`, their score will be less than
-- this value.
function fzy.get_score_ceiling()
return MATCH_MAX_LENGTH * SCORE_MATCH_CONSECUTIVE
end
-- The name of the currently-running implementation, "lua" or "native".
function fzy.get_implementation_name()
return "lua"
end
return fzy
+68
View File
@@ -0,0 +1,68 @@
local intl_dir = mp.get_script_directory() .. '/intl/'
local locale = {}
local cache = {}
-- https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/supported-languages?pivots=store-installer-msix#list-of-supported-languages
function get_languages()
local languages = {}
for _, lang in ipairs(comma_split(options.languages)) do
if (lang == 'slang') then
local slang = mp.get_property_native('slang')
if slang then
itable_append(languages, slang)
end
else
languages[#languages +1] = lang
end
end
return languages
end
---@param path string
function get_locale_from_json(path)
local expand_path = mp.command_native({'expand-path', path})
local meta, meta_error = utils.file_info(expand_path)
if not meta or not meta.is_file then
return nil
end
local json_file = io.open(expand_path, 'r')
if not json_file then
return nil
end
local json = json_file:read('*all')
json_file:close()
local json_table = utils.parse_json(json)
return json_table
end
---@param text string
function t(text, a)
if not text then return '' end
local key = text
if a then key = key .. '|' .. a end
if cache[key] then return cache[key] end
cache[key] = string.format(locale[text] or text, a or '')
return cache[key]
end
-- Load locales
local languages = get_languages()
for i = #languages, 1, -1 do
lang = languages[i]
if (lang:match('.json$')) then
table_assign(locale, get_locale_from_json(lang))
elseif (lang == 'en') then
locale = {}
else
table_assign(locale, get_locale_from_json(intl_dir .. lang:lower() .. '.json'))
end
end
return {t = t}
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
--[[ Stateless utilities missing in lua standard library ]]
---@alias Shortcut {id: string; key: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean}
---@param number number
function round(number) return math.floor(number + 0.5) end
---@param min number
---@param value number
---@param max number
function clamp(min, value, max) return math.max(min, math.min(value, max)) end
---@param rgba string `rrggbb` or `rrggbbaa` hex string.
function serialize_rgba(rgba)
local a = rgba:sub(7, 8)
return {
color = rgba:sub(5, 6) .. rgba:sub(3, 4) .. rgba:sub(1, 2),
opacity = clamp(0, tonumber(#a == 2 and a or 'ff', 16) / 255, 1),
}
end
-- Trim any white space from the start and end of the string.
---@param str string
---@return string
function trim(str) return str:match('^%s*(.-)%s*$') end
---@param str string
---@return string|nil
function url_encode(str)
if str then
str = str:gsub('([^%w%-%.%_%~])', function(c)
return string.format('%%%02X', string.byte(c))
end)
end
return str
end
-- Escape special characters in url.
---@param str string
---@return string|nil
function url_decode(str)
local function hex_to_char(x)
return string.char(tonumber(x, 16))
end
if str ~= nil then
str = str:gsub('^file://', '')
str = str:gsub('%%(%x%x)', hex_to_char)
if str:find('://localhost:?') then
str = str:gsub('^.*/', '')
end
end
return str
end
-- Trim any `char` from the end of the string.
---@param str string
---@param char string
---@return string
function trim_end(str, char)
local char, end_i = char:byte(), 0
for i = #str, 1, -1 do
if str:byte(i) ~= char then
end_i = i
break
end
end
return str:sub(1, end_i)
end
---@param str string
---@param pattern string
---@return string[]
function split(str, pattern)
local list = {}
local full_pattern = '(.-)' .. pattern
local last_end = 1
local start_index, end_index, capture = str:find(full_pattern, 1)
while start_index do
list[#list + 1] = capture
last_end = end_index + 1
start_index, end_index, capture = str:find(full_pattern, last_end)
end
if last_end <= (#str + 1) then
capture = str:sub(last_end)
list[#list + 1] = capture
end
return list
end
-- Handles common option and message inputs that need to be split by comma when strings.
---@param input string|string[]|nil
---@return string[]
function comma_split(input)
if not input then return {} end
if type(input) == 'table' then return itable_map(input, tostring) end
local str = tostring(input)
return str:match('^%s*$') and {} or split(str, ' *, *')
end
-- Get index of the last appearance of `sub` in `str`.
---@param str string
---@param sub string
---@return integer|nil
function string_last_index_of(str, sub)
local sub_length = #sub
for i = #str, 1, -1 do
for j = 1, sub_length do
if str:byte(i + j - 1) ~= sub:byte(j) then break end
if j == sub_length then return i end
end
end
end
-- Creates a pattern that matches `str` of any case.
-- Usage:
-- ```lua
-- string.gsub(str, anycase('foo'), 'bar')
-- ```
---@param str string
function anycase(str)
return string.gsub(str, '%a', function(c)
return string.format('[%s%s]', c:lower(), c:upper())
end)
end
-- Escapes a string to be used in a matching expression.
---@param value string
function regexp_escape(value)
return string.gsub(value, '[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1')
end
---@param itable table
---@param value any
---@return integer|nil
function itable_index_of(itable, value)
for index = 1, #itable do
if itable[index] == value then return index end
end
end
---@param itable table
---@param value any
---@return boolean
function itable_has(itable, value)
return itable_index_of(itable, value) ~= nil
end
---@param itable table
---@param compare fun(value: any, index: number): boolean|integer|string|nil
---@param from? number Where to start search, defaults to `1`.
---@param to? number Where to end search, defaults to `#itable`.
---@return number|nil index
---@return any|nil value
function itable_find(itable, compare, from, to)
from, to = from or 1, to or #itable
for index = from, to, from < to and 1 or -1 do
if index > 0 and index <= #itable and compare(itable[index], index) then
return index, itable[index]
end
end
end
---@param itable table
---@param decider fun(value: any, index: number): boolean|integer|string|nil
function itable_filter(itable, decider)
local filtered = {}
for index, value in ipairs(itable) do
if decider(value, index) then filtered[#filtered + 1] = value end
end
return filtered
end
---@param itable table
---@param value any
function itable_delete_value(itable, value)
for index = 1, #itable, 1 do
if itable[index] == value then table.remove(itable, index) end
end
return itable
end
---@param itable table
---@param transformer fun(value: any, index: number) : any
function itable_map(itable, transformer)
local result = {}
for index, value in ipairs(itable) do
result[index] = transformer(value, index)
end
return result
end
---@param itable table
---@param start_pos? integer
---@param end_pos? integer
function itable_slice(itable, start_pos, end_pos)
start_pos = start_pos and start_pos or 1
end_pos = end_pos and end_pos or #itable
if end_pos < 0 then end_pos = #itable + end_pos + 1 end
if start_pos < 0 then start_pos = #itable + start_pos + 1 end
local new_table = {}
for index, value in ipairs(itable) do
if index >= start_pos and index <= end_pos then
new_table[#new_table + 1] = value
end
end
return new_table
end
---@generic T
---@param ...T[]|nil
---@return T[]
function itable_join(...)
local args, result = {...}, {}
for i = 1, select('#', ...) do
if args[i] then for _, value in ipairs(args[i]) do result[#result + 1] = value end end
end
return result
end
---@param target any[]
---@param source any[]
function itable_append(target, source)
for _, value in ipairs(source) do target[#target + 1] = value end
return target
end
function itable_clear(itable)
for i = #itable, 1, -1 do itable[i] = nil end
end
---@generic T
---@param input table<T, any>
---@return T[]
function table_keys(input)
local keys = {}
for key, _ in pairs(input) do keys[#keys + 1] = key end
return keys
end
---@generic T
---@param input table<any, T>
---@return T[]
function table_values(input)
local values = {}
for _, value in pairs(input) do values[#values + 1] = value end
return values
end
---@generic T: table<any, any>
---@param target T
---@param ... T|nil
---@return T
function table_assign(target, ...)
local args = {...}
for i = 1, select('#', ...) do
if type(args[i]) == 'table' then for key, value in pairs(args[i]) do target[key] = value end end
end
return target
end
---@generic T: table<any, any>
---@param target T
---@param source T
---@param props string[]
---@return T
function table_assign_props(target, source, props)
for _, name in ipairs(props) do target[name] = source[name] end
return target
end
-- Assign props from `source` to `target` that are not in `props` set.
---@generic T: table<any, any>
---@param target T
---@param source T
---@param props table<string, boolean>
---@return T
function table_assign_exclude(target, source, props)
for key, value in pairs(source) do
if not props[key] then target[key] = value end
end
return target
end
-- `table_assign({}, input)` without loosing types :(
---@generic T: table<any, any>
---@param input T
---@return T
function table_copy(input) return table_assign({}, input) end
-- Converts itable values into `table<value, true>` map.
---@param values any[]
function create_set(values)
local result = {}
for _, value in ipairs(values) do result[value] = true end
return result
end
---@generic T: any
---@param input string
---@param value_sanitizer? fun(value: string, key: string): T
---@return table<string, T>
function serialize_key_value_list(input, value_sanitizer)
local result, sanitize = {}, value_sanitizer or function(value) return value end
for _, key_value_pair in ipairs(comma_split(input)) do
local key, value = key_value_pair:match('^([%w_]+)=([%w%.]+)$')
if key and value then result[key] = sanitize(value, key) end
end
return result
end
---@param key string Key or a combination of a `modifiers+key`. If this includes modifiers, the `modifiers` param is ignored.
---@param modifiers? string
---@return Shortcut
function create_shortcut(key, modifiers)
key = key:lower()
local last_plus_in_key = string_last_index_of(key, '+')
if last_plus_in_key then
modifiers = string.sub(key, 1, last_plus_in_key - 1)
key = string.sub(key, last_plus_in_key + 1)
end
local id_parts, modifiers_set
if modifiers then
id_parts = split(modifiers:lower(), '+')
table.sort(id_parts, function(a, b) return a < b end)
modifiers_set = create_set(id_parts)
modifiers = table.concat(id_parts, '+')
else
id_parts, modifiers, modifiers_set = {}, nil, {}
end
id_parts[#id_parts + 1] = key
return table_assign({id = table.concat(id_parts, '+'), key = key, modifiers = modifiers}, modifiers_set)
end
--[[ EASING FUNCTIONS ]]
function ease_out_quart(x) return 1 - ((1 - x) ^ 4) end
function ease_out_sext(x) return 1 - ((1 - x) ^ 6) end
--[[ CLASSES ]]
---@class Class
Class = {}
function Class:new(...)
local object = setmetatable({}, {__index = self})
object:init(...)
return object
end
function Class:init(...) end
function Class:destroy() end
function class(parent) return setmetatable({}, {__index = parent or Class}) end
---@class CircularBuffer<T> : Class
CircularBuffer = class()
function CircularBuffer:new(max_size) return Class.new(self, max_size) --[[@as CircularBuffer]] end
function CircularBuffer:init(max_size)
self.max_size = max_size
self.pos = 0
self.data = {}
end
function CircularBuffer:insert(item)
self.pos = self.pos % self.max_size + 1
self.data[self.pos] = item
end
function CircularBuffer:get(i)
return i <= #self.data and self.data[(self.pos + i - 1) % #self.data + 1] or nil
end
local function iter(self, i)
if i == #self.data then return nil end
i = i + 1
return i, self:get(i)
end
function CircularBuffer:iter()
return iter, self, 0
end
local function iter_rev(self, i)
if i == 1 then return nil end
i = i - 1
return i, self:get(i)
end
function CircularBuffer:iter_rev()
return iter_rev, self, #self.data + 1
end
function CircularBuffer:head()
return self.data[self.pos]
end
function CircularBuffer:tail()
if #self.data < 1 then return nil end
return self.data[self.pos % #self.data + 1]
end
function CircularBuffer:clear()
itable_clear(self.data)
self.pos = 0
end
+660
View File
@@ -0,0 +1,660 @@
-- https://en.wikipedia.org/wiki/Unicode_block
---@alias CodePointRange {[1]: integer; [2]: integer}
---@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)
}
-- 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
}
local width_length_ratio = 0.5
---@type integer, integer
local osd_width, osd_height = 100, 100
---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)
local max_bytes = #str - i + 1
if char_byte < 0xC0 then
return math.min(max_bytes, 1)
elseif char_byte < 0xE0 then
return math.min(max_bytes, 2)
elseif char_byte < 0xF0 then
return math.min(max_bytes, 3)
elseif char_byte < 0xF8 then
return math.min(max_bytes, 4)
else
return math.min(max_bytes, 1)
end
end
---Creates an iterator for an utf-8 encoded string
---Iterates over utf-8 characters instead of bytes
---@param str string
---@return fun(): integer?, string?
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
---Estimating string length based on the number of characters
---@param char string
---@return number
function utf8_length(str)
local str_length = 0
for _, c in utf8_iter(str) do
str_length = str_length + 1
end
return str_length
end
---Get the next character in an utf-8 encoded string
---@param str string
---@param i integer
---@return integer
function utf8_next(str, i)
if i >= #str then return #str end
local len = utf8_char_bytes(str, i + 1)
return math.min(i + len, #str)
end
---Get the previous character in an utf-8 encoded string
---@param str string
---@param i integer
---@return integer
function utf8_prev(str, i)
if i <= 0 then return 0 end
local pos = 1
local last_valid = 0
while pos <= #str do
local len = utf8_char_bytes(str, pos)
if pos > i then break end
last_valid = pos - 1
pos = pos + len
end
return last_valid
end
---Convert character position to byte position in utf-8 encoded string
---@param str string
---@param char_pos integer
---@return integer
function utf8_charpos_to_bytepos(str, char_pos)
local byte_pos = 1
local current_char = 1
local str_len = #str
while byte_pos <= str_len and current_char < char_pos do
local char_len = utf8_char_bytes(str, byte_pos)
byte_pos = byte_pos + char_len
current_char = current_char + 1
end
return byte_pos
end
---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
---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
---Update osd resolution if valid
---@param width integer
---@param height integer
local function update_osd_resolution(width, height)
if width > 0 and height > 0 then osd_width, osd_height = width, height end
end
mp.observe_property('osd-dimensions', 'native', function(_, dim)
if dim then update_osd_resolution(dim.w, dim.h) end
end)
local measure_bounds
do
local text_osd = mp.create_osd_overlay('ass-events')
text_osd.compute_bounds, text_osd.hidden = true, true
---@param ass_text string
---@return integer, integer, integer, integer
measure_bounds = function(ass_text)
update_osd_resolution(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
end
local normalized_text_width
do
---@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
normalized_text_width = function(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
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 measuring 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
---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
local character_width
do
---@type {[boolean]: {[string]: {[1]: number, [2]: integer}}}
local char_width_cache = {}
---Get measured width of character
---@param char string
---@param bold boolean
---@return number, integer
character_width = function(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, math.huge}
return 0, math.huge
end
end
local measured_char = nil
for _, block in ipairs(same_width_blocks) do
if unicode >= block[1] and unicode <= block[2] then
measured_char = unicode_to_utf8(block[1])
width_px = char_widths[measured_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
if not measured_char then measured_char = char end
-- half as many repetitions for wide characters
local char_count = 10 / char_length(char)
local max_size, horizontal = fit_on_screen(measured_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 measured_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 .. measured_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 ~= measured_char then char_widths[measured_char] = width_px end
char_widths[char] = width_px
return width, px
end
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 = math.huge
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
---Scale normalized width to real width based on font size and italic
---@param opts {size: number; italic?: boolean}
---@return number, number
local function opts_factor_offset(opts)
return opts.size, opts.italic and opts.size * 0.2 or 0
end
---Scale normalized width to real width based on font size and italic
---@param opts {size: number; italic?: boolean}
---@return number
local function normalized_to_real(width, opts)
local factor, offset = opts_factor_offset(opts)
return factor * width + offset
end
do
---@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 options.font_bold, opts.italic or false
if not config.refine.text_width 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 normalized_to_real(width_px[1], opts) end
local width, px = character_based_width(text, bold)
width_cache[bold][text] = {width, px}
return normalized_to_real(width, 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
end
do
---@type {[string]: string}
local cache = {}
function timestamp_zero_rep_clear_cache()
cache = {}
end
---Replace all timestamp digits with 0
---@param timestamp string
function timestamp_zero_rep(timestamp)
local substitute = cache[#timestamp]
if not substitute then
substitute = timestamp:gsub('%d', '0')
cache[#timestamp] = substitute
end
return substitute
end
---Get width of formatted timestamp as if all the digits were replaced with 0
---@param timestamp string
---@param opts {size: number; bold?: boolean; italic?: boolean}
---@return number
function timestamp_width(timestamp, opts)
return text_width(timestamp_zero_rep(timestamp), opts)
end
end
do
local wrap_at_chars = {' ', ' ', '-', ''}
local remove_when_wrap = {' ', ' '}
---Wrap the text at the closest opportunity to target_line_length
---@param text string
---@param opts {size: number; bold?: boolean; italic?: boolean}
---@param target_line_length number
---@return string, integer
function wrap_text(text, opts, target_line_length)
local target_line_width = target_line_length * width_length_ratio * opts.size
local bold, scale_factor, scale_offset = opts.bold or false, opts_factor_offset(opts)
local wrap_at_chars, remove_when_wrap = wrap_at_chars, remove_when_wrap
local lines = {}
for _, text_line in ipairs(split(text, '\n')) do
local line_width = scale_offset
local line_start = 1
local before_end = nil
local before_width = scale_offset
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 char_width = character_width(char, bold) * scale_factor
line_width = line_width + char_width
if (char_end == #text_line) or itable_has(wrap_at_chars, char) then
local remove = itable_has(remove_when_wrap, char)
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 + scale_offset
else
lines[#lines + 1] = text_line:sub(line_start, remove and char_start - 1 or char_end)
line_start = char_end + 1
line_width = scale_offset
end
before_end = line_start
before_width = scale_offset
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'), #lines
end
end
do
local word_separators = create_set({
' ', ' ', '\t', '-', '', '_', ',', '.', '+', '&', '(', ')', '[', ']', '{', '}', '<', '>', '/', '\\',
'', '', '', '', '', '', '', '', '', '', '', '', '', '',
})
---Get the first character of each word
---@param str string
---@return string[]
function initials(str)
local initials, is_word_start, word_separators = {}, true, word_separators
for _, char in utf8_iter(str) do
if word_separators[char] then
is_word_start = true
elseif is_word_start then
initials[#initials + 1] = char
is_word_start = false
end
end
return initials
end
end
-- Returns the index of the beginning or end of the current word/segment in a string.
---@param str string String to search in.
---@param cursor number Where in the string to start searching.
---@param direction number `1` to search forward, `-1` backward.
function find_string_segment_bound(str, cursor, direction)
if #str < 2 then return #str end
cursor = math.max(1, math.min(cursor, #str))
local head, tail = string.sub(str, 1, cursor), string.sub(str, cursor + 1)
if direction < 0 then
local word_pat, other_pat = '[^%c%s%p]+$', '[%c%s%p]+$'
local pat = head:sub(#head):match(word_pat) and word_pat or other_pat
-- First we match all same type consecutive chars starting at cursor
local segment = head:match(pat) or ''
-- If there's only one, we extend the segment with opposite type chars
if segment and #segment == 1 then
local match = head:sub(1, #head - #segment):match(pat == word_pat and other_pat or word_pat)
segment = (match or '') .. segment
end
return cursor - #segment + 1
else
local word_pat, other_pat = '^[^%c%s%p]+', '^[%c%s%p]+'
local pat = tail:sub(1, 1):match(word_pat) and word_pat or other_pat
local segment = tail:match(pat) or ''
if segment and #segment == 1 then
local match = tail:sub(#segment):match(pat == word_pat and other_pat or word_pat)
segment = segment .. (match or '')
end
return cursor + #segment
end
end
-- Highlight matching text in a string.
---@param text string
---@param byte_positions number[]
---@param font_color string
---@return string
function highlight_match(text, byte_positions, font_color, bold)
if not byte_positions or #byte_positions == 0 then
return ass_escape(text)
end
table.sort(byte_positions)
local start_tag = '{\\c&H' .. config.color.match .. '&\\b' .. (bold and '1' or '0') .. '}'
local end_tag = '{\\c&H' .. font_color .. '&}'
local result = {}
local pos_set = {}
for _, p in ipairs(byte_positions) do
pos_set[p] = true
end
local i = 1
local len = #text
while i <= len do
if pos_set[i] then
table.insert(result, start_tag)
local char_len = utf8_char_bytes(text, i)
table.insert(result, ass_escape(text:sub(i, i + char_len - 1)))
table.insert(result, end_tag)
i = i + char_len
else
local char_len = utf8_char_bytes(text, i)
table.insert(result, ass_escape(text:sub(i, i + char_len - 1)))
i = i + char_len
end
end
return table.concat(result)
end
-- Get positions of matching characters in a romanized string.
---@param title string
---@param query string
---@param mode string
---@param roman string[]
function get_roman_match_positions(title, query, mode, roman)
local romans = {}
local char_ranges = {}
local total_len = 0
for _, char in ipairs(roman) do
local part = (mode == "initial") and char:sub(1, 1) or char
part = part:lower()
romans[#romans + 1] = part
char_ranges[#char_ranges + 1] = {total_len + 1, total_len + #part}
total_len = total_len + #part
end
local full_roman = table.concat(romans)
local s, e = full_roman:find(query, 1, true)
if not s then return nil end
local byte_positions = {}
for i, range in ipairs(char_ranges) do
local rs, re = range[1], range[2]
if not (re < s or rs > e) then
byte_positions[#byte_positions + 1] = utf8_charpos_to_bytepos(title, i)
end
end
return byte_positions
end
File diff suppressed because it is too large Load Diff