cursor.lua 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. local cursor = {
  2. x = math.huge,
  3. y = math.huge,
  4. hidden = true,
  5. hover_raw = false,
  6. -- Event handlers that are only fired on zones defined during render loop.
  7. ---@type {event: string, hitbox: Hitbox; handler: fun(...)}[]
  8. zones = {},
  9. handlers = {
  10. primary_down = {},
  11. primary_up = {},
  12. secondary_down = {},
  13. secondary_up = {},
  14. wheel_down = {},
  15. wheel_up = {},
  16. move = {},
  17. },
  18. first_real_mouse_move_received = false,
  19. history = CircularBuffer:new(10),
  20. autohide_fs_only = nil,
  21. -- Tracks current key binding levels for each event. 0: disabled, 1: enabled, 2: enabled + window dragging prevented
  22. binding_levels = {
  23. mbtn_left = 0,
  24. mbtn_left_dbl = 0,
  25. mbtn_right = 0,
  26. wheel = 0,
  27. },
  28. is_dragging_prevented = false,
  29. event_forward_map = {
  30. primary_down = 'MBTN_LEFT',
  31. primary_up = 'MBTN_LEFT',
  32. secondary_down = 'MBTN_RIGHT',
  33. secondary_up = 'MBTN_RIGHT',
  34. wheel_down = 'WHEEL_DOWN',
  35. wheel_up = 'WHEEL_UP',
  36. },
  37. event_binding_map = {
  38. primary_down = 'mbtn_left',
  39. primary_up = 'mbtn_left',
  40. primary_click = 'mbtn_left',
  41. secondary_down = 'mbtn_right',
  42. secondary_up = 'mbtn_right',
  43. secondary_click = 'mbtn_right',
  44. wheel_down = 'wheel',
  45. wheel_up = 'wheel',
  46. },
  47. window_dragging_blockers = create_set({'primary_click', 'primary_down'}),
  48. event_propagation_blockers = {
  49. primary_down = 'primary_click',
  50. primary_click = 'primary_down',
  51. secondary_down = 'secondary_click',
  52. secondary_click = 'secondary_down',
  53. },
  54. event_parent_map = {
  55. primary_down = {is_start = true, trigger_event = 'primary_click'},
  56. primary_up = {is_end = true, start_event = 'primary_down', trigger_event = 'primary_click'},
  57. secondary_down = {is_start = true, trigger_event = 'secondary_click'},
  58. secondary_up = {is_end = true, start_event = 'secondary_down', trigger_event = 'secondary_click'},
  59. },
  60. -- Holds positions of last events.
  61. ---@type {[string]: {x: number, y: number, time: number}}
  62. last_event = {},
  63. }
  64. cursor.autohide_timer = mp.add_timeout(1, function() cursor:autohide() end)
  65. cursor.autohide_timer:kill()
  66. mp.observe_property('cursor-autohide', 'number', function(_, val)
  67. cursor.autohide_timer.timeout = (val or 1000) / 1000
  68. end)
  69. -- Called at the beginning of each render
  70. function cursor:clear_zones()
  71. itable_clear(self.zones)
  72. end
  73. ---@param hitbox Hitbox
  74. function cursor:collides_with(hitbox)
  75. return point_collides_with(self, hitbox)
  76. end
  77. -- Returns zone for event at current cursor position.
  78. ---@param event string
  79. function cursor:find_zone(event)
  80. -- Premature optimization to ignore a high frequency event that is not needed as a zone atm.
  81. if event == 'move' then return end
  82. for i = #self.zones, 1, -1 do
  83. local zone = self.zones[i]
  84. local is_blocking_only = zone.event == self.event_propagation_blockers[event]
  85. if (zone.event == event or is_blocking_only) and self:collides_with(zone.hitbox) then
  86. return not is_blocking_only and zone or nil
  87. end
  88. end
  89. end
  90. -- Defines an event zone for a hitbox on currently rendered screen. Available events:
  91. -- - primary_down, primary_up, primary_click, secondary_down, secondary_up, secondary_click, wheel_down, wheel_up
  92. --
  93. -- Notes:
  94. -- - Zones are cleared on beginning of every `render()`, and need to be rebound.
  95. -- - One event type per zone: only the last bound zone per event gets triggered.
  96. -- - In current implementation, you have to choose between `_click` or `_down`. Binding both makes only the last bound fire.
  97. -- - Primary `_down` and `_click` disable dragging. Define `window_drag = true` on hitbox to re-enable.
  98. -- - Anything that disables dragging also implicitly disables cursor autohide.
  99. -- - `move` event zones are ignored due to it being a high frequency event that is currently not needed as a zone.
  100. ---@param event string
  101. ---@param hitbox Hitbox
  102. ---@param callback fun(...)
  103. function cursor:zone(event, hitbox, callback)
  104. self.zones[#self.zones + 1] = {event = event, hitbox = hitbox, handler = callback}
  105. end
  106. -- Binds a permanent cursor event handler active until manually unbound using `cursor:off()`.
  107. -- `_click` events are not available as permanent global events, only as zones.
  108. ---@param event string
  109. ---@return fun() disposer Unbinds the event.
  110. function cursor:on(event, callback)
  111. if self.handlers[event] and not itable_index_of(self.handlers[event], callback) then
  112. self.handlers[event][#self.handlers[event] + 1] = callback
  113. self:decide_keybinds()
  114. end
  115. return function() self:off(event, callback) end
  116. end
  117. -- Unbinds a cursor event handler.
  118. ---@param event string
  119. function cursor:off(event, callback)
  120. if self.handlers[event] then
  121. local index = itable_index_of(self.handlers[event], callback)
  122. if index then
  123. table.remove(self.handlers[event], index)
  124. self:decide_keybinds()
  125. end
  126. end
  127. end
  128. -- Binds a cursor event handler to be called once.
  129. ---@param event string
  130. function cursor:once(event, callback)
  131. local function callback_wrap()
  132. callback()
  133. self:off(event, callback_wrap)
  134. end
  135. return self:on(event, callback_wrap)
  136. end
  137. -- Trigger the event.
  138. ---@param event string
  139. function cursor:trigger(event, ...)
  140. local forward = true
  141. -- Call raw event handlers.
  142. local zone = self:find_zone(event)
  143. local callbacks = self.handlers[event]
  144. if zone or #callbacks > 0 then
  145. forward = false
  146. if zone then zone.handler(...) end
  147. for _, callback in ipairs(callbacks) do callback(...) end
  148. end
  149. -- Call compound/parent (click) event handlers if both start and end events are within `parent_zone.hitbox`.
  150. local parent = self.event_parent_map[event]
  151. if parent then
  152. local parent_zone = self:find_zone(parent.trigger_event)
  153. if parent_zone then
  154. forward = false -- Canceled here so we don't forward down events if they can lead to a click.
  155. if parent.is_end then
  156. local last_start_event = self.last_event[parent.start_event]
  157. if last_start_event and point_collides_with(last_start_event, parent_zone.hitbox) then
  158. parent_zone.handler(...)
  159. end
  160. end
  161. end
  162. end
  163. -- Forward unhandled events.
  164. if forward then
  165. local forward_name = self.event_forward_map[event]
  166. if forward_name then
  167. -- Forward events if there was no handler.
  168. local active = find_active_keybindings(forward_name)
  169. if active then
  170. local is_wheel = event:find('wheel', 1, true)
  171. local is_up = event:sub(-3) == '_up'
  172. if active.owner then
  173. -- Binding belongs to other script, so make it look like regular key event.
  174. -- Mouse bindings are simple, other keys would require repeat and pressed handling,
  175. -- which can't be done with mp.set_key_bindings(), but is possible with mp.add_key_binding().
  176. local state = is_wheel and 'pm' or is_up and 'um' or 'dm'
  177. local name = active.cmd:sub(active.cmd:find('/') + 1, -1)
  178. mp.commandv('script-message-to', active.owner, 'key-binding', name, state, forward_name)
  179. elseif is_wheel or is_up then
  180. -- input.conf binding, react to button release for mouse buttons
  181. mp.command(active.cmd)
  182. end
  183. end
  184. end
  185. end
  186. -- Update last event position.
  187. local last = self.last_event[event] or {}
  188. last.x, last.y, last.time = self.x, self.y, mp.get_time()
  189. self.last_event[event] = last
  190. -- Refresh cursor autohide timer.
  191. self:queue_autohide()
  192. end
  193. -- Enables or disables keybinding groups based on what event listeners are bound.
  194. function cursor:decide_keybinds()
  195. local new_levels = {mbtn_left = 0, mbtn_right = 0, wheel = 0}
  196. self.is_dragging_prevented = false
  197. -- Check global events.
  198. for name, handlers in ipairs(self.handlers) do
  199. local binding = self.event_binding_map[name]
  200. if binding then
  201. new_levels[binding] = #handlers > 0 and 1 or 0
  202. end
  203. end
  204. -- Check zones.
  205. for _, zone in ipairs(self.zones) do
  206. local binding = self.event_binding_map[zone.event]
  207. if binding and cursor:collides_with(zone.hitbox) then
  208. local new_level = (self.window_dragging_blockers[zone.event] and zone.hitbox.window_drag ~= true) and 2
  209. or math.max(new_levels[binding], zone.hitbox.window_drag == false and 2 or 1)
  210. new_levels[binding] = new_level
  211. if new_level > 1 then
  212. self.is_dragging_prevented = true
  213. end
  214. end
  215. end
  216. -- Window dragging only gets prevented when on top of an element, which is when double clicks should be ignored.
  217. new_levels.mbtn_left_dbl = new_levels.mbtn_left == 2 and 2 or 0
  218. for name, level in pairs(new_levels) do
  219. if level ~= self.binding_levels[name] then
  220. local flags = level == 1 and 'allow-vo-dragging+allow-hide-cursor' or ''
  221. mp[(level == 0 and 'disable' or 'enable') .. '_key_bindings'](name, flags)
  222. self.binding_levels[name] = level
  223. self:queue_autohide()
  224. end
  225. end
  226. end
  227. function cursor:_find_history_sample()
  228. local time = mp.get_time()
  229. for _, e in self.history:iter_rev() do
  230. if time - e.time > 0.1 then
  231. return e
  232. end
  233. end
  234. return self.history:tail()
  235. end
  236. -- Returns a table with current velocities in in pixels per second.
  237. ---@return Point
  238. function cursor:get_velocity()
  239. local snap = self:_find_history_sample()
  240. if snap then
  241. local x, y, time = self.x - snap.x, self.y - snap.y, mp.get_time()
  242. local time_diff = time - snap.time
  243. if time_diff > 0.001 then
  244. return {x = x / time_diff, y = y / time_diff}
  245. end
  246. end
  247. return {x = 0, y = 0}
  248. end
  249. ---@param x integer
  250. ---@param y integer
  251. function cursor:move(x, y)
  252. local old_x, old_y = self.x, self.y
  253. -- mpv reports initial mouse position on linux as (0, 0), which always
  254. -- displays the top bar, so we hardcode cursor position as infinity until
  255. -- we receive a first real mouse move event with coordinates other than 0,0.
  256. if not self.first_real_mouse_move_received then
  257. if x > 0 and y > 0 then
  258. self.first_real_mouse_move_received = true
  259. else
  260. x, y = math.huge, math.huge
  261. end
  262. end
  263. -- Add 0.5 to be in the middle of the pixel
  264. self.x, self.y = x + 0.5, y + 0.5
  265. if old_x ~= self.x or old_y ~= self.y then
  266. if self.x == math.huge or self.y == math.huge then
  267. self.hidden = true
  268. self.history:clear()
  269. -- Slowly fadeout elements that are currently visible
  270. for _, id in ipairs(config.cursor_leave_fadeout_elements) do
  271. local element = Elements[id]
  272. if element then
  273. local visibility = element:get_visibility()
  274. if visibility > 0 then
  275. element:tween_property('forced_visibility', visibility, 0, function()
  276. element.forced_visibility = nil
  277. end)
  278. end
  279. end
  280. end
  281. Elements:update_proximities()
  282. Elements:trigger('global_mouse_leave')
  283. else
  284. if self.hidden then
  285. -- Cancel potential fadeouts
  286. for _, id in ipairs(config.cursor_leave_fadeout_elements) do
  287. if Elements[id] then Elements[id]:tween_stop() end
  288. end
  289. self.hidden = false
  290. Elements:trigger('global_mouse_enter')
  291. end
  292. Elements:update_proximities()
  293. -- Update history
  294. self.history:insert({x = self.x, y = self.y, time = mp.get_time()})
  295. end
  296. Elements:proximity_trigger('mouse_move')
  297. self:queue_autohide()
  298. end
  299. self:trigger('move')
  300. request_render()
  301. end
  302. function cursor:leave() self:move(math.huge, math.huge) end
  303. function cursor:is_autohide_allowed()
  304. return options.autohide and (not self.autohide_fs_only or state.fullscreen)
  305. and not self.is_dragging_prevented
  306. and not Menu:is_open()
  307. end
  308. mp.observe_property('cursor-autohide-fs-only', 'bool', function(_, val) cursor.autohide_fs_only = val end)
  309. -- Cursor auto-hiding after period of inactivity.
  310. function cursor:autohide()
  311. if self:is_autohide_allowed() then
  312. self:leave()
  313. self.autohide_timer:kill()
  314. end
  315. end
  316. function cursor:queue_autohide()
  317. if self:is_autohide_allowed() then
  318. self.autohide_timer:kill()
  319. self.autohide_timer:resume()
  320. end
  321. end
  322. -- Calculates distance in which cursor reaches rectangle if it continues moving on the same path.
  323. -- Returns `nil` if cursor is not moving towards the rectangle.
  324. ---@param rect Rect
  325. function cursor:direction_to_rectangle_distance(rect)
  326. local prev = self:_find_history_sample()
  327. if not prev then return false end
  328. local end_x, end_y = self.x + (self.x - prev.x) * 1e10, self.y + (self.y - prev.y) * 1e10
  329. return get_ray_to_rectangle_distance(self.x, self.y, end_x, end_y, rect)
  330. end
  331. function cursor:create_handler(event, cb)
  332. return function(...)
  333. call_maybe(cb, ...)
  334. self:trigger(event, ...)
  335. end
  336. end
  337. -- Movement
  338. function handle_mouse_pos(_, mouse)
  339. if not mouse then return end
  340. if cursor.hover_raw and not mouse.hover then
  341. cursor:leave()
  342. else
  343. cursor:move(mouse.x, mouse.y)
  344. end
  345. cursor.hover_raw = mouse.hover
  346. end
  347. mp.observe_property('mouse-pos', 'native', handle_mouse_pos)
  348. -- Key binding groups
  349. mp.set_key_bindings({
  350. {
  351. 'mbtn_left',
  352. cursor:create_handler('primary_up'),
  353. cursor:create_handler('primary_down', function(...)
  354. handle_mouse_pos(nil, mp.get_property_native('mouse-pos'))
  355. end),
  356. },
  357. }, 'mbtn_left', 'force')
  358. mp.set_key_bindings({
  359. {'mbtn_left_dbl', 'ignore'},
  360. }, 'mbtn_left_dbl', 'force')
  361. mp.set_key_bindings({
  362. {'mbtn_right', cursor:create_handler('secondary_up'), cursor:create_handler('secondary_down')},
  363. }, 'mbtn_right', 'force')
  364. mp.set_key_bindings({
  365. {'wheel_up', cursor:create_handler('wheel_up')},
  366. {'wheel_down', cursor:create_handler('wheel_down')},
  367. }, 'wheel', 'force')
  368. return cursor