summaryrefslogtreecommitdiff
path: root/input.lua
diff options
context:
space:
mode:
Diffstat (limited to 'input.lua')
-rw-r--r--input.lua336
1 files changed, 336 insertions, 0 deletions
diff --git a/input.lua b/input.lua
new file mode 100644
index 0000000..7d4ea57
--- /dev/null
+++ b/input.lua
@@ -0,0 +1,336 @@
+-- Copyright 2025 David Vazgenovich Shakaryan
+
+local config = require('config')
+local rt = require('rt')
+
+local input = {}
+
+local mappings = {}
+local active_mapping = {}
+local mapping_bound = false
+
+local click_state = {}
+
+local state
+local osd
+
+function input.init(_state, _osd, _bs)
+ state = _state
+ osd = _osd
+end
+
+local function mouse_has_drifted(x1, y1, x2, y2)
+ return math.abs(x1 - x2) > config.click_max_drift or
+ math.abs(y1 - y2) > config.click_max_drift
+end
+
+-- when mpv registers a double-click, the second click triggers both
+-- double-click and click events. instead of identifying and ignoring this
+-- second click event, we can implement double-click detection ourselves for
+-- more control and to bypass some mpv inconsistencies.
+local function handle_mouse_click(ev, id)
+ if ev.canceled then
+ click_state = {}
+ return
+ end
+
+ local mpos = osd.mpos
+ if not mpos then
+ return
+ end
+
+ local time = mp.get_time()
+ local clk = click_state
+
+ if ev.event == 'down' then
+ clk.ct, clk.ck = time, ev.key_name
+ clk.cx, clk.cy, clk.ci = mpos.x, mpos.y, id
+ return
+ end
+
+ if ev.event ~= 'up' or not clk.ct then
+ return
+ end
+
+ if time - clk.ct > config.click_timeout or
+ clk.ck ~= ev.key_name or clk.ci ~= id or
+ mouse_has_drifted(mpos.x, mpos.y, clk.cx, clk.cy) then
+ click_state = {}
+ return
+ end
+
+ local dbl = clk.pt and clk.ct - clk.pt <= config.click_dbl_time and
+ clk.pk == ev.key_name and clk.pi == id and
+ not mouse_has_drifted(clk.cx, clk.cy, clk.px, clk.py)
+ click_state = dbl and {} or
+ {
+ pt = clk.ct, pk = ev.key_name,
+ px = clk.cx, py = clk.cy, pi = id,
+ }
+ return ev.key_name, dbl
+end
+
+local function process_mouse_click(ev)
+ local ms = osd.mstate
+ if not ms then
+ return
+ end
+
+ local area
+ local val
+ if ms.target == 'scrollbar' then
+ area = 'scrollbar'
+ val = ms.ratio
+ elseif ms.target == 'menu' then
+ area = 'menu'
+ val = ms.option_line
+ end
+
+ local key, dbl = handle_mouse_click(ev, ms.hitbox)
+ return key, dbl, area, val
+end
+
+local function mouse_click(ev)
+ local key, dbl, area, val = process_mouse_click(ev)
+ if not key then
+ return
+ end
+
+ local f
+ if key == 'MBTN_LEFT' then
+ f = rt.mouse_click_left
+ elseif key == 'MBTN_RIGHT' then
+ f = rt.mouse_click_right
+ end
+
+ f(dbl, area, val)
+end
+
+-- mpv does not process key-binding changes requested by script functions until
+-- those functions return. in the meantime, while the function is running, mpv
+-- discards any key presses for keys that were not already bound prior to the
+-- start of function execution. similarly, any pending key presses (for keys
+-- that were bound) are discarded if the binding changes during function
+-- execution.
+--
+-- in other words, we get an inconsistent result where pressing a key while a
+-- function is running triggers the bound function upon completion if that
+-- binding remains the same, but ignores such key presses when our function
+-- changes the corresponding binding or adds a new one. we can avoid this by
+-- leaving our keys bound to a common function and building our own logic to
+-- route keys per the current state.
+local function handle_key(ev)
+ local t = active_mapping[ev.key_name]
+ if not t and ev.key_text then
+ t = active_mapping['ANY_UNICODE']
+ end
+
+ -- ev.is_mouse is false for some mouse events
+ local k = ev.key_name
+ if k:find('MOUSE_') or k:find('MBTN_') or k:find('WHEEL_') then
+ if not osd.mactive then
+ osd:set_mactive(true)
+ end
+ elseif osd.mactive then
+ osd:set_mactive(false)
+ click_state = {}
+ end
+
+ local f = t and t[1]
+ if not f then
+ goto flush
+ end
+
+ if t[2] == 'complex' then
+ f(ev)
+ elseif ev.event == 'down' or
+ (ev.event == 'repeat' and t[2] == 'repeat') then
+ f()
+ end
+
+ ::flush::
+ osd:flush(state)
+end
+
+-- uses enable-section and disable-section to disable builtin key bindings
+-- while the OSD is visible. these commands are technically deprecated for
+-- non-internal use, but they still work, and there doesn't appear to be
+-- another way apart from setting an override for each individual key.
+--
+-- nonexistent `nodrag' section is enabled to disable VO dragging while the
+-- menu is open.
+function input.set_key_bindings()
+ if osd:is_hidden() then
+ if mapping_bound then
+ mp.remove_key_binding('mouse_move')
+ mp.remove_key_binding('unmapped')
+ mp.command_native({'disable-section', 'nodrag'})
+ mp.command_native({
+ 'enable-section', 'default',
+ 'allow-hide-cursor+allow-vo-dragging'})
+ mapping_bound = false
+ end
+ return
+ end
+
+ if not mapping_bound then
+ mp.command_native({'disable-section', 'default'})
+ mp.command_native({'enable-section', 'nodrag'})
+ mp.add_forced_key_binding('MOUSE_MOVE', 'mouse_move') -- noisy
+ mp.add_forced_key_binding(
+ 'UNMAPPED', 'unmapped', handle_key, {complex = true})
+ mapping_bound = true
+ end
+end
+
+local function bind_click(f)
+ mp.command_native({'enable-section', 'click-nodrag'})
+ mp.add_forced_key_binding(
+ 'MBTN_LEFT', 'click', function(ev)
+ if process_mouse_click(ev) then
+ f()
+ end
+ end, {complex = true})
+ mp.add_forced_key_binding('MBTN_LEFT_DBL', 'click-dbl')
+end
+
+local function unbind_click()
+ mp.remove_key_binding('click')
+ mp.remove_key_binding('click-dbl')
+ mp.command_native({'disable-section', 'click-nodrag'})
+end
+
+function input.set_key_mapping(m)
+ active_mapping = mappings[m]
+end
+
+local btn_timer; btn_timer = mp.add_periodic_timer(0.1, function()
+ if osd.mpos and mp.get_time() - osd.mpos_time > config.btn_timeout then
+ osd:show_menu_btn(false)
+ btn_timer:kill()
+ end
+end, true) -- disabled
+function input.update_mpos(mpos)
+ if not mpos then
+ return
+ end
+
+ local ps = osd.mstate or {}
+ local ms = osd:set_mpos(mpos)
+ if not ms then
+ return
+ end
+
+ -- mpv normally sends a cancel event when the mouse drifts before a
+ -- click is released, but only for a first click and not for the second
+ -- click of what mpv internally considers a double-click. this
+ -- replicates that drift detection behaviour, allowing for consistency
+ -- on double-clicks.
+ local clk = click_state
+ if clk.ct and mouse_has_drifted(mpos.x, mpos.y, clk.cx, clk.cy) then
+ click_state = {}
+ end
+
+ if not ms.over_btn_area ~= not ps.over_btn_area then
+ osd:show_menu_btn(ms.over_btn_area)
+ elseif ms.over_btn_area and osd.menu_btn.hidden then
+ osd:show_menu_btn(true)
+ end
+
+ if (ms.target == 'menu_btn') ~= (ps.target == 'menu_btn') then
+ if ms.target == 'menu_btn' then
+ bind_click(rt.toggle_menu)
+ else
+ unbind_click()
+ end
+ end
+
+ if ms.over_btn_area and ms.target ~= 'menu_btn' then
+ btn_timer:resume()
+ else
+ btn_timer:kill()
+ end
+
+ osd:flush(state)
+end
+
+-- after a resize, mpv does not update the mouse coordinates until the mouse is
+-- moved. if the mouse was previously over a clickable element and we do
+-- nothing, a click would trigger that element regardless of mouse position.
+-- since we cannot get the new position, we instead wipe mouse state on resize,
+-- clearing it until the next move.
+function input.on_resize()
+ osd:show_menu_btn(false)
+ osd:set_mpos(nil)
+ click_state = {}
+ unbind_click()
+ btn_timer:kill()
+end
+
+mappings.MENU = {
+ ['BS'] = {rt.prev_menu},
+ ['/'] = {rt.start_search},
+ ['Ctrl+s'] = {rt.toggle_menu_sort},
+ ['Ctrl+R'] = {rt.reload_data},
+
+ ['ENTER'] = {rt.select_option},
+ ['Ctrl+f'] = {rt.favourite_option},
+ ['g'] = {rt.goto_option},
+ ['i'] = {rt.open_option_info},
+ ['?'] = {rt.open_option_info},
+ ['Ctrl+p'] = {rt.goto_playing},
+
+ ['MBTN_LEFT'] = {mouse_click, 'complex'},
+ ['MBTN_RIGHT'] = {mouse_click, 'complex'},
+
+ ['k'] = {rt.cursor_up, 'repeat'},
+ ['j'] = {rt.cursor_down, 'repeat'},
+ ['K'] = {rt.cursor_page_up, 'repeat'},
+ ['J'] = {rt.cursor_page_down, 'repeat'},
+ ['UP'] = {rt.cursor_up, 'repeat'},
+ ['DOWN'] = {rt.cursor_down, 'repeat'},
+ ['Shift+UP'] = {rt.cursor_page_up, 'repeat'},
+ ['Shift+DOWN'] = {rt.cursor_page_down, 'repeat'},
+ ['PGUP'] = {rt.cursor_page_up, 'repeat'},
+ ['PGDWN'] = {rt.cursor_page_down, 'repeat'},
+ ['HOME'] = {rt.cursor_start},
+ ['END'] = {rt.cursor_end},
+ ['WHEEL_UP'] = {rt.cursor_wheel_up, 'repeat'},
+ ['WHEEL_DOWN'] = {rt.cursor_wheel_down, 'repeat'},
+ ['Shift+WHEEL_UP'] = {rt.cursor_wheel_page_up, 'repeat'},
+ ['Shift+WHEEL_DOWN'] = {rt.cursor_wheel_page_down, 'repeat'},
+
+ ['Alt+k'] = {rt.move_option_up, 'repeat'},
+ ['Alt+j'] = {rt.move_option_down, 'repeat'},
+ ['Alt+K'] = {rt.move_option_page_up, 'repeat'},
+ ['Alt+J'] = {rt.move_option_page_down, 'repeat'},
+ ['Alt+UP'] = {rt.move_option_up, 'repeat'},
+ ['Alt+DOWN'] = {rt.move_option_down, 'repeat'},
+ ['Shift+Alt+UP'] = {rt.move_option_page_up, 'repeat'},
+ ['Shift+Alt+DOWN'] = {rt.move_option_page_down, 'repeat'},
+ ['Alt+PGUP'] = {rt.move_option_page_up, 'repeat'},
+ ['Alt+PGDWN'] = {rt.move_option_page_down, 'repeat'},
+ ['Alt+HOME'] = {rt.move_option_start},
+ ['Alt+END'] = {rt.move_option_end},
+ ['Alt+WHEEL_UP'] = {rt.move_option_wheel_up, 'repeat'},
+ ['Alt+WHEEL_DOWN'] = {rt.move_option_wheel_down, 'repeat'},
+ ['Shift+Alt+WHEEL_UP'] = {rt.move_option_wheel_page_up, 'repeat'},
+ ['Shift+Alt+WHEEL_DOWN'] = {rt.move_option_wheel_page_down, 'repeat'},
+}
+
+mappings.SEARCH = {
+ ['ANY_UNICODE'] = {rt.search_input_char, 'complex'},
+ ['BS'] = {rt.search_input_bs, 'repeat'},
+ ['DEL'] = {rt.search_input_del, 'repeat'},
+
+ ['ENTER'] = {rt.end_search},
+ ['ESC'] = {rt.cancel_search},
+ ['Ctrl+c'] = {rt.cancel_search},
+
+ ['LEFT'] = {rt.search_cursor_left, 'repeat'},
+ ['RIGHT'] = {rt.search_cursor_right, 'repeat'},
+ ['Ctrl+a'] = {rt.search_cursor_start},
+ ['Ctrl+e'] = {rt.search_cursor_end},
+}
+
+return input