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.
650 lines
16 KiB
XML
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>
|