410 lines
13 KiB
Lua
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
|