| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 |
- 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
|