init
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user