Files
Mush-Soundpack/cosmic rage/worlds/plugins/output_functions.xml
OlegTheSnowman 466be8a544 Fix jump-to-end firing on every line: debounce sustained unfocus, not recency
Debug logging disproved the previous "recent append perturbs GetFocus"
theory - one spurious jump fired 7.67s after the last append. The real
pattern: GetFocus() reports "not focused" for a single poll tick now
and then even while the user never left the window, and that lone blip
was enough for the very next tick to be read as "just switched to the
window," triggering a jump on practically every incoming line.

Now requires 3 consecutive unfocused poll ticks (~0.6s) before the next
focused tick counts as a genuine switch, filtering out single-tick
noise from MUSHclient's own UI thread. Removed the now-unused
last_append_clock tracking from the previous (wrong) fix attempt.
2026-07-11 00:39:44 +03:00

650 lines
16 KiB
XML

<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE muclient>
<!-- Saved on Tuesday, April 06, 2010, 8:39 AM -->
<!-- MuClient version 4.46 -->
<!-- Plugin "output_functions" generated by Plugin Wizard -->
<muclient>
<plugin
name="output_functions"
author="Weyoun"
id="54846c23d8b15594e7eb4b8a"
save_state="y"
language="Lua"
purpose="provides functions to help tts users."
date_written="2010-04-06 08:37:40"
requires="4.82"
version="1.1"
>
</plugin>
<!-- Aliases -->
<aliases>
<alias
match="Line_Get *"
enabled="y"
script="LineGet"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="clearoutputbuffer"
enabled="y"
script="clearlines"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="clearoutputwindow"
enabled="y"
script="clearwindow"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="prevline"
enabled="y"
script="prev_line"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="toggleoutput"
enabled="y"
script="output_toggle"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="toggleinterrupt"
enabled="y"
script="interrupt_toggle"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="curline"
enabled="y"
script="cur_line"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="select *"
enabled="y"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
<send>
if "%1" == "space" then
selectscr(" ")
else
selectscr("\\r\\n")
end
</send>
</alias>
<alias
match="nextline"
enabled="y"
script="next_line"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="whichline"
enabled="y"
script="which_line"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="topline"
enabled="y"
script="top_line"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="snap_shot"
enabled="y"
script="snapshot"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="endline"
enabled="y"
script="end_line"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
<alias
match="toggle_jump_to_end"
enabled="y"
script="toggle_jump_to_end"
omit_from_output="y"
omit_from_log="y"
omit_from_command_history="y"
send_to="12"
sequence="100">
</alias>
</aliases>
<!-- Timers -->
<timers>
<timer
enabled="y"
second="0.2"
script="poll_output_focus"
>
</timer>
</timers>
<!-- Get our standard constants -->
<include name="constants.lua"/>
<!-- Script -->
<script>
<![CDATA[
for i = 1, 9 do
Accelerator("ctrl + "..tostring(i), "Line_Get "..tostring(i))
end
Accelerator("ctrl +0", "Line_Get 10")
Accelerator("ctrl + shift + c", "clearoutputbuffer")
Accelerator("ctrl + alt + shift + c", "clearoutputwindow")
Accelerator("ctrl+shift+o","nextline")
Accelerator("ctrl+alt+enter","toggleinterrupt")
Accelerator("ctrl+alt+o","toggleoutput")
Accelerator("ctrl+shift+i","curline")
Accelerator("ctrl+shift+space","select line")
Accelerator("ctrl+shift+alt+space","select space")
Accelerator("ctrl+shift+u","prevline")
Accelerator("ctrl+shift+n","endline")
Accelerator("ctrl+shift+y","topline")
Accelerator("ctrl+shift+h","whichline")
Accelerator("ctrl+shift+alt+s","snap_shot")
Accelerator("ctrl+alt+j","toggle_jump_to_end")
function selectscr(eol)
buffercheck()
if selecting ~= 1 then
selecting = 1
Execute("tts_interrupt selection start.")
tselect = {}
sline = cline
else
if eol == " " then
Execute("tts_interrupt Selection coppied without line breaks.")
else
Execute("tts_interrupt Selection coppied.")
end
for i = sline, cline do
table.insert(tselect, GetLineInfo(i, 1))
end
SetClipboard(table.concat(tselect, eol))
selecting = 0
tselect = {}
end
end
function output_toggle()
if GetVariable("output") ~= "1" then
SetVariable("output", "1")
Note("output notepad on")
else
SetVariable("output", "0")
Note("output notepad off")
end
end
function interrupt_toggle()
if GetVariable("interrupt") ~= "1" then
SetVariable("interrupt", "1")
Note("interrupt on enter on")
else
SetVariable("interrupt", "0")
Note("interrupt on enter off")
end
end
function LineGet(name, line, wc)
lines = GetLinesInBufferCount()
line = GetLineInfo(lines - wc[1], 1)
if line == nil then
Sound(GetInfo(67).."sounds/pm/bonk.ogg")
return
end
wc = wc[1]
local timeout = 0.4 -- in seconds
if modes[wc] == nil then
modes[wc] = {os.clock(), 1}
elseif os.clock()-modes[wc][1] >= timeout then
modes[wc][1] = os.clock()
modes[wc][2] = 1
else
modes[wc][1] = os.clock()
modes[wc][2] = modes[wc][2] + 1
end
if modes[wc][2] >= 3 then
PasteCommand(line)
Execute("tts_interrupt pasted.")
end
if modes[wc][2] == 2 then
SetClipboard(line)
Execute("tts_interrupt copied.")
end
if modes[wc][2] == 1 then
Execute("tts_interrupt "..line)
end
end
function OnPluginCommandEntered(s)
if world.GetVariable("interrupt") == "1" then
Execute("tts_stop")
end
end
------------------------------------------------------------------
-- Notepad smart-scroll (formerly the separate NotepadSmartScroll
-- plugin, merged in here). See SmartAppendToNotepad's comment below
-- for why this needs raw Win32 access via Alien.
------------------------------------------------------------------
require "alien"
local user32 = alien.load("user32.dll")
local kernel32 = alien.load("kernel32.dll")
local FindWindowEx = user32.FindWindowExA
FindWindowEx:types{ ret = "pointer", "pointer", "pointer", "string", "string", abi = "stdcall" }
local GetWindowThreadProcessId = user32.GetWindowThreadProcessId
GetWindowThreadProcessId:types{ ret = "uint", "pointer", "ref uint", abi = "stdcall" }
local GetCurrentProcessId = kernel32.GetCurrentProcessId
GetCurrentProcessId:types{ ret = "uint", abi = "stdcall" }
local SendMessage = user32.SendMessageA
SendMessage:types{ ret = "long", "pointer", "uint", "uint", "long", abi = "stdcall" }
local GetModuleHandle = kernel32.GetModuleHandleA
GetModuleHandle:types{ ret = "pointer", "string", abi = "stdcall" }
local GetProcAddress = kernel32.GetProcAddress
GetProcAddress:types{ ret = "pointer", "pointer", "string", abi = "stdcall" }
local GetFocus = user32.GetFocus
GetFocus:types{ ret = "pointer", abi = "stdcall" }
-- EM_GETSEL's wParam/lParam are *pointers* to DWORD (needed to get the
-- real 32-bit selection range - the classic packed-return form is capped
-- at 64K). SendMessage above is already typed with "uint"/"long" for its
-- generic wParam/lParam, so a second binding of the same underlying
-- function is needed with "pointer" typed args for this one message.
local user32_handle = GetModuleHandle("user32.dll")
local SendMessagePtr = alien.funcptr(GetProcAddress(user32_handle, "SendMessageA"))
SendMessagePtr:types{ ret = "long", "pointer", "uint", "pointer", "pointer", abi = "stdcall" }
local WM_GETTEXTLENGTH = 0x000E
local EM_GETFIRSTVISIBLELINE = 0x00CE
local EM_LINESCROLL = 0x00B6
local EM_GETSEL = 0x00B0
local EM_SETSEL = 0x00B1
local EM_SCROLLCARET = 0x00B7
local my_pid = GetCurrentProcessId()
-- Finds the "Edit" control belonging to a MUSHclient notepad MDI child
-- window whose title exactly matches. Scoped to top-level windows owned
-- by this process, so it never touches unrelated apps on the desktop.
local function find_notepad_edit(title)
local top = nil
while true do
top = FindWindowEx(nil, top, nil, nil)
if not top then return nil end
local _, pid = GetWindowThreadProcessId(top, 0)
if pid == my_pid then
local mdiClient = FindWindowEx(top, nil, "MDIClient", nil)
if mdiClient then
local frame = FindWindowEx(mdiClient, nil, nil, title)
if frame then
local edit = FindWindowEx(frame, nil, "Edit", nil)
if edit then
return edit
end
end
end
end
end
end
local function get_selection(edit)
local start_buf = alien.buffer(4)
local end_buf = alien.buffer(4)
SendMessagePtr(edit, EM_GETSEL, start_buf, end_buf)
return start_buf:get(1, "uint"), end_buf:get(1, "uint")
end
local function scroll_to_line(edit, target)
local current = SendMessage(edit, EM_GETFIRSTVISIBLELINE, 0, 0)
local delta = target - current
if delta ~= 0 then
SendMessage(edit, EM_LINESCROLL, 0, delta)
end
end
-- Wraps AppendToNotepad(title, text): if the caret wasn't already sitting
-- at the very end of the text (i.e. the user has moved it to read
-- somewhere else), both the caret/selection and the scroll position are
-- restored after the append instead of being left jumped to the end.
-- AppendToNotepad hardcodes SetSel(len,len)+ReplaceSel on every call (see
-- scripting/methods/methods_notepad.cpp in the MUSHclient source) with no
-- scriptable override, so this reads the notepad Edit control's caret and
-- scroll position via raw Win32 calls (through the Alien FFI library,
-- running in-process on MUSHclient's own thread) before appending, and
-- restores them afterward when appropriate.
--
-- Note: text should use "\r\n" line endings, not bare "\n" - the plain
-- Win32 Edit control that backs the notepad doesn't recognise bare "\n"
-- as a line break for scrolling/line-count purposes.
function SmartAppendToNotepad(title, text)
local edit = find_notepad_edit(title)
local was_at_end = true
local sel_start, sel_end, first_visible
if edit then
local old_length = SendMessage(edit, WM_GETTEXTLENGTH, 0, 0)
sel_start, sel_end = get_selection(edit)
was_at_end = (sel_start == old_length) and (sel_end == old_length)
if not was_at_end then
first_visible = SendMessage(edit, EM_GETFIRSTVISIBLELINE, 0, 0)
end
end
AppendToNotepad(title, text)
if edit and not was_at_end then
-- Re-find in case the append above created/recreated the window.
local edit2 = find_notepad_edit(title) or edit
SendMessage(edit2, EM_SETSEL, sel_start, sel_end)
scroll_to_line(edit2, first_visible)
end
end
-- Moves the caret to the very end of a notepad and scrolls it into view.
local function jump_notepad_to_end(edit)
local len = SendMessage(edit, WM_GETTEXTLENGTH, 0, 0)
SendMessage(edit, EM_SETSEL, len, len)
SendMessage(edit, EM_SCROLLCARET, 0, 0)
end
function toggle_jump_to_end()
if GetVariable("jump_to_end_on_focus") == "0" then
SetVariable("jump_to_end_on_focus", "1")
Execute("tts_interrupt Jump to end when switching to output: on")
else
SetVariable("jump_to_end_on_focus", "0")
Execute("tts_interrupt Jump to end when switching to output: off")
end
end
-- Polls for the "output" notepad gaining focus (mouse click, Ctrl+Tab
-- between MDI windows, ActivateNotepad from a script, etc.), so the
-- jump-to-end behaviour applies regardless of how the user switches to
-- it. MUSHclient's own Accelerator/hotkey system can't see keystrokes
-- while a separate notepad window has focus (confirmed by Nick Gammon on
-- the MUSHclient forum), so this can't be event-driven - polling every
-- 0.2s is the safe, non-hooking way to catch it.
--
-- GetFocus() genuinely reports "not focused" for a single poll tick now
-- and then, even while the user never left the window and nothing was
-- just appended (confirmed via logging - one case was 7+ seconds after
-- the last append) - just noise from MUSHclient's own UI thread being
-- momentarily busy. A single such blip was enough to make this treat
-- the very next tick as "just switched to the window" and jump, on
-- practically every incoming line. Requiring several *consecutive*
-- unfocused ticks before the next focused tick counts as a genuine
-- switch filters that out.
was_output_focused = false
unfocused_streak = 0
FOCUS_DEBOUNCE_TICKS = 3 -- ~0.6s of sustained unfocus at the 0.2s poll rate
function poll_output_focus()
if GetVariable("jump_to_end_on_focus") == "0" then
was_output_focused = false
unfocused_streak = 0
return
end
local edit = find_notepad_edit("output")
if not edit then
was_output_focused = false
unfocused_streak = 0
return
end
local focused = GetFocus()
-- Compare by string representation, not raw "==": Alien's pointer
-- returns are full userdata objects, so two separate calls returning
-- the same real HWND don't reliably compare equal with "==" (which
-- falls back to identity comparison).
local is_focused = (tostring(focused) == tostring(edit))
if is_focused then
if not was_output_focused and unfocused_streak >= FOCUS_DEBOUNCE_TICKS then
jump_notepad_to_end(edit)
end
unfocused_streak = 0
was_output_focused = true
else
unfocused_streak = unfocused_streak + 1
was_output_focused = false
end
end
------------------------------------------------------------------
function OnPluginInstall()
if GetVariable("output") == nil then
SetVariable("output","1")
end
if GetVariable("jump_to_end_on_focus") == nil then
SetVariable("jump_to_end_on_focus","1")
end
modes = {}
cline = 1
lastcount=0
line = 0
selecting = 0
end
function OnPluginScreendraw(t,l,line)
buffercheck()
if(GetVariable("output")=="0") then
return
end
SmartAppendToNotepad("output", line.."\r\n")
end
function prev_line()
buffercheck()
oline = cline
cline = skipblanklines(cline - 1, 'b')
line = GetLineInfo(cline, 1)
if cline == oline then
Sound(GetInfo(67).."sounds/pm/bonk.ogg")
end
Execute("tts_interrupt "..line)
end
function cur_line()
buffercheck()
line = GetLineInfo(cline, 1)
Execute("tts_interrupt "..line)
end
function next_line()
buffercheck()
oline = cline
cline = skipblanklines(cline + 1, 'f')
line=GetLineInfo(cline,1)
if cline == oline then
Sound(GetInfo(67).."sounds/pm/bonk.ogg")
end
Execute("tts_interrupt "..line)
end
function end_line()
cline = skipblanklines(GetLinesInBufferCount(), 'b')
line=GetLineInfo(cline,1)
Execute("tts_interrupt Bottom: "..line)
end
function which_line()
buffercheck()
Execute("tts_interrupt line "..cline.." of "..GetLinesInBufferCount() -1)
end
function top_line()
cline=skipblanklines(0, 'f')
line=GetLineInfo(cline,1)
Execute("tts_interrupt top: "..line)
end
function snapshot()
require "wait"
wait.make(function()
Execute("tts_interrupt Beginning snapshot, please wait....")
wait.time(0.5)
for snapline=1, GetLinesInBufferCount() - 1 do
AppendToNotepad("snapshot",GetLineInfo(snapline,1).."\r\n")
end
ActivateNotepad("snapshot")
end)
end
function clearlines(n,l,w)
world.DeleteOutput()
world.Note("output buffer cleared")
cline=1
lastcount=0
line=GetLineInfo(cline,1)
selecting = 0
end
function clearwindow()
ReplaceNotepad("output")
Execute("tts_interrupt output window cleared")
end
function skipblanklines(ln, dir)
count = GetLinesInBufferCount()
if ln < 1 then
return skipblanklines(1, 'f')
elseif ln > count - 1 then
return skipblanklines(count - 1, 'b')
end
i = ln
while i > 0 and i < count and GetLineInfo(i, 1) == "" do
if dir == 'b' then
i = i - 1
else
i = i + 1
end
end
if i == 0 or i == count then
if GetLineInfo(ln, 1) ~= "" then
return ln
elseif i == 0 then
return skipblanklines(1, 'f')
elseif i == count then
return skipblanklines(count - 1, 'b')
end
else
return i
end
end
function buffercheck()
curcount = GetLinesInBufferCount()
if curcount < lastcount then
if cline > 100 then
cline = cline - 100
else
cline = 1
end
if selecting == 1 then
if sline > 100 then
sline = sline - 100
else
sline = 1
end
end
end
lastcount = curcount
end
]]>
</script>
</muclient>