dotfiles/multimedia/.config/mpv/scripts/uosc/lib/cursor.lua
2024-04-20 09:27:09 +02:00

410 lines
13 KiB
Lua

local cursor = {
x = math.huge,
y = math.huge,
hidden = true,
hover_raw = false,
-- Event handlers that are only fired on zones defined during render loop.
---@type {event: string, hitbox: Hitbox; handler: fun(...)}[]
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_parent_map = {
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 of last events.
---@type {[string]: {x: number, y: number, time: number}}
last_event = {},
}
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)
-- 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 fun(...)
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
---@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
function cursor:trigger(event, ...)
local forward = true
-- 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 then zone.handler(...) end
for _, callback in ipairs(callbacks) do callback(...) end
end
-- Call compound/parent (click) event handlers if both start and end events are within `parent_zone.hitbox`.
local parent = self.event_parent_map[event]
if parent then
local parent_zone = self:find_zone(parent.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 parent.is_end then
local last_start_event = self.last_event[parent.start_event]
if last_start_event and point_collides_with(last_start_event, parent_zone.hitbox) then
parent_zone.handler(...)
end
end
end
end
-- Forward unhandled events.
if forward then
local forward_name = self.event_forward_map[event]
if forward_name then
-- Forward events if there was no handler.
local active = find_active_keybindings(forward_name)
if active 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
-- Update last event position.
local last = self.last_event[event] or {}
last.x, last.y, last.time = self.x, self.y, mp.get_time()
self.last_event[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] = #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)
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 a table with current velocities in 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 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
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
function cursor:create_handler(event, cb)
return function(...)
call_maybe(cb, ...)
self:trigger(event, ...)
end
end
-- Movement
function handle_mouse_pos(_, mouse)
if not mouse then return end
if cursor.hover_raw and not mouse.hover then
cursor:leave()
else
cursor:move(mouse.x, mouse.y)
end
cursor.hover_raw = mouse.hover
end
mp.observe_property('mouse-pos', 'native', handle_mouse_pos)
-- Key binding groups
mp.set_key_bindings({
{
'mbtn_left',
cursor:create_handler('primary_up'),
cursor:create_handler('primary_down', function(...)
handle_mouse_pos(nil, mp.get_property_native('mouse-pos'))
end),
},
}, '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'), cursor:create_handler('secondary_down')},
}, 'mbtn_right', 'force')
mp.set_key_bindings({
{'wheel_up', cursor:create_handler('wheel_up')},
{'wheel_down', cursor:create_handler('wheel_down')},
}, 'wheel', 'force')
return cursor