Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,15 @@ Approval to commit is not approval to push. Get explicit verbal "push it" first.

## Before Posting
Show proposed wording before posting any comment or PR body. Applies to our fork and upstream alike.

## Always target our fork when opening PRs
This repo is `somanysteves/bug.n`, a fork of `fuhsjr00/bug.n`. `gh pr create`
defaults to the **parent** repo (upstream) when run from inside a fork — running
it without `--repo` will open a PR against `fuhsjr00/bug.n`, which is almost
never what we want.

Always pass `--repo somanysteves/bug.n` to `gh pr create` unless the user has
explicitly asked for a cross-fork PR against upstream. Before creating, state
out loud which repo is being targeted ("opening against `somanysteves/bug.n`")
so the user can catch a mistake before it becomes a closed PR in someone else's
notifications.
112 changes: 96 additions & 16 deletions src/Manager.ahk
Original file line number Diff line number Diff line change
Expand Up @@ -453,28 +453,108 @@ Manager_moveWindow() {
}

Manager_onDisplayChange(a, wParam, uMsg, lParam) {
Local doChange := (Config_monitorDisplayChangeMessages = "on")
Global Config_monitorDisplayChangeMessages, Manager_displayChangeSessionChoice

Debug_logMessage("DEBUG[1] Manager_onDisplayChange( a: " . a . ", uMsg: " . uMsg . ", wParam: " . wParam . ", lParam: " . lParam . " )", 1)
If !(Config_monitorDisplayChangeMessages = "on" || Config_monitorDisplayChangeMessages = "off" || Config_monitorDisplayChangeMessages = 0) {
MsgBox, 291, , % "Would you like to reset the monitor configuration?`n'No' will only rearrange all active views.`n'Cancel' will result in no change."
IfMsgBox Yes
doChange := True
Else IfMsgBox No
{
Loop, % Manager_monitorCount {
View_arrange(A_Index, Monitor_#%A_Index%_aView_#1)
Bar_updateView(A_Index, Monitor_#%A_Index%_aView_#1)
}
Bar_updateStatus()
Bar_updateTitle()
}

decision := Manager_displayChangeDecide(Config_monitorDisplayChangeMessages, Manager_displayChangeSessionChoice)
If (decision = "prompt") {
Manager_displayChangePrompt(choice, remember)
Manager_displayChangeRecordSessionChoice(choice, remember)
decision := Manager_displayChangeDecide(Config_monitorDisplayChangeMessages, choice)
}
If (doChange) {
Manager_displayChangeApply(decision)
}

;; Returns the action to take for a WM_DISPLAYCHANGE event, given the
;; persistent config setting and any session-only override the user picked
;; via the "remember this decision for this session" checkbox.
;; "reset" -> Manager_resetMonitorConfiguration (re-detect monitors)
;; "rearrange" -> redraw active views without re-detecting monitors
;; "ignore" -> no-op
;; "prompt" -> caller should show the dialog
Manager_displayChangeDecide(configValue, sessionChoice) {
If (sessionChoice = "yes")
Return "reset"
If (sessionChoice = "no")
Return "rearrange"
If (sessionChoice = "cancel")
Return "ignore"
If (configValue = "on")
Return "reset"
If (configValue = "off" || configValue = 0)
Return "ignore"
Return "prompt"
}

Manager_displayChangeApply(decision) {
Global Manager_monitorCount

If (decision = "reset") {
Manager_resetMonitorConfiguration()
} Else If (decision = "rearrange") {
Loop, % Manager_monitorCount {
i := A_Index
View_arrange(i, Monitor_#%i%_aView_#1)
Bar_updateView(i, Monitor_#%i%_aView_#1)
}
Bar_updateStatus()
Bar_updateTitle()
}
}

Manager_displayChangeRecordSessionChoice(choice, remember) {
Global Manager_displayChangeSessionChoice
If (remember)
Manager_displayChangeSessionChoice := choice
}

Manager_displayChangePrompt(ByRef choice, ByRef remember) {
Global MgrDispChange_choice, MgrDispChange_remember, MgrDispChange_done

MgrDispChange_choice := "cancel"
MgrDispChange_remember := False
MgrDispChange_done := False

Gui, MgrDispChange:New, +OwnDialogs +AlwaysOnTop +ToolWindow, bug.n
Gui, MgrDispChange:Add, Text, , Would you like to reset the monitor configuration?`n'No' will only rearrange all active views.`n'Cancel' will result in no change.
Gui, MgrDispChange:Add, Checkbox, vMgrDispChange_remember, Remember this decision for this session
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnYes Default w80, &Yes
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnNo x+10 w80, &No
Comment on lines +522 to +523
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old MsgBox, 291 had default button #2 ("No"). This custom Gui sets Default on the Yes button, which changes behavior (pressing Enter now triggers reset instead of rearrange). Consider moving Default to the No button to preserve the previous safer default.

Suggested change
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnYes Default w80, &Yes
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnNo x+10 w80, &No
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnYes w80, &Yes
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnNo Default x+10 w80, &No

Copilot uses AI. Check for mistakes.
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnCancel x+10 w80, &Cancel
Gui, MgrDispChange:Show

While (!MgrDispChange_done)
Sleep, 50

choice := MgrDispChange_choice
remember := MgrDispChange_remember
Comment on lines +514 to +531
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manager_displayChangePrompt uses global state (MgrDispChange_*) plus a fixed GUI name and then waits in a loop until those globals are updated. If this function is entered again before the first dialog finishes (e.g., another WM_DISPLAYCHANGE arrives while the dialog is open), the second invocation will clobber the first invocation’s globals / GUI, which can lead to the waiting loop never completing or the wrong choice being applied. Consider adding a re-entrancy guard (e.g., a static promptOpen flag) and/or storing prompt state per-GUI instance (unique Gui name / hwnd + WinWaitClose + GuiControlGet) so concurrent invocations can’t corrupt each other.

Suggested change
MgrDispChange_choice := "cancel"
MgrDispChange_remember := False
MgrDispChange_done := False
Gui, MgrDispChange:New, +OwnDialogs +AlwaysOnTop +ToolWindow, bug.n
Gui, MgrDispChange:Add, Text, , Would you like to reset the monitor configuration?`n'No' will only rearrange all active views.`n'Cancel' will result in no change.
Gui, MgrDispChange:Add, Checkbox, vMgrDispChange_remember, Remember this decision for this session
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnYes Default w80, &Yes
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnNo x+10 w80, &No
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnCancel x+10 w80, &Cancel
Gui, MgrDispChange:Show
While (!MgrDispChange_done)
Sleep, 50
choice := MgrDispChange_choice
remember := MgrDispChange_remember
Static promptOpen := False
if (promptOpen) {
choice := "cancel"
remember := False
Return
}
promptOpen := True
Try {
MgrDispChange_choice := "cancel"
MgrDispChange_remember := False
MgrDispChange_done := False
Gui, MgrDispChange:New, +OwnDialogs +AlwaysOnTop +ToolWindow, bug.n
Gui, MgrDispChange:Add, Text, , Would you like to reset the monitor configuration?`n'No' will only rearrange all active views.`n'Cancel' will result in no change.
Gui, MgrDispChange:Add, Checkbox, vMgrDispChange_remember, Remember this decision for this session
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnYes Default w80, &Yes
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnNo x+10 w80, &No
Gui, MgrDispChange:Add, Button, gMgrDispChange_btnCancel x+10 w80, &Cancel
Gui, MgrDispChange:Show
While (!MgrDispChange_done)
Sleep, 50
choice := MgrDispChange_choice
remember := MgrDispChange_remember
} Finally {
promptOpen := False
}

Copilot uses AI. Check for mistakes.
}

;; --- Manager_displayChangePrompt button handlers (script-scope labels) ---
MgrDispChange_btnYes:
Gui, MgrDispChange:Submit, NoHide
MgrDispChange_choice := "yes"
MgrDispChange_done := True
Gui, MgrDispChange:Destroy
Return

MgrDispChange_btnNo:
Gui, MgrDispChange:Submit, NoHide
MgrDispChange_choice := "no"
MgrDispChange_done := True
Gui, MgrDispChange:Destroy
Return

MgrDispChange_btnCancel:
MgrDispChangeGuiClose:
MgrDispChangeGuiEscape:
Gui, MgrDispChange:Submit, NoHide
MgrDispChange_choice := "cancel"
MgrDispChange_done := True
Gui, MgrDispChange:Destroy
Return

/*
Possible indications for a ...
new window: 1 (started by Windows Explorer) or 6 (started by cmd, shell or Win+E).
Expand Down
3 changes: 2 additions & 1 deletion tests/run.ahk
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ SetBatchLines, -1
TEST_PASS_COUNT := 0
TEST_FAIL_COUNT := 0

Yunit.Use(CIReporter).Test(TestTiler, TestManagerLoop, TestViewShuffleWindow)
Yunit.Use(CIReporter).Test(TestTiler, TestManagerLoop, TestViewShuffleWindow, TestManagerDisplayChange)

total := TEST_PASS_COUNT + TEST_FAIL_COUNT
FileAppend, % "`n--- " . TEST_PASS_COUNT . " passed, " . TEST_FAIL_COUNT . " failed (" . total . " total) ---`n", *
Expand Down Expand Up @@ -49,3 +49,4 @@ ExitApp, % TEST_FAIL_COUNT
#Include %A_ScriptDir%\test_Tiler.ahk
#Include %A_ScriptDir%\test_Manager_loop.ahk
#Include %A_ScriptDir%\test_View_shuffleWindow.ahk
#Include %A_ScriptDir%\test_Manager_displayChange.ahk
129 changes: 129 additions & 0 deletions tests/test_Manager_displayChange.ahk
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
Tests for the WM_DISPLAYCHANGE decision logic and session-choice
recording in src/Manager.ahk.

The dialog itself (Manager_displayChangePrompt) is a Gui modal and is
not exercised here — these tests cover the pure decision function
(Manager_displayChangeDecide) and the session-storage helper
(Manager_displayChangeRecordSessionChoice) that together drive what
happens when the user ticks "Remember this decision for this session".
*/

class TestManagerDisplayChange
{
;; --- Manager_displayChangeDecide: persistent config, no session override ---

Decide_PromptWhenAskAndNoSession()
{
Yunit.Assert(Manager_displayChangeDecide("ask", "") = "prompt"
, "config=ask + no session choice should prompt; got " . Manager_displayChangeDecide("ask", ""))
}

Decide_ResetWhenConfigOn()
{
Yunit.Assert(Manager_displayChangeDecide("on", "") = "reset"
, "config=on should reset; got " . Manager_displayChangeDecide("on", ""))
}

Decide_IgnoreWhenConfigOff()
{
Yunit.Assert(Manager_displayChangeDecide("off", "") = "ignore"
, "config=off should ignore; got " . Manager_displayChangeDecide("off", ""))
}

Decide_IgnoreWhenConfigZero()
{
;; Legacy: numeric 0 is accepted as a synonym for "off" (Manager.ahk:692).
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment references Manager.ahk:692, but in the current file that line number points to unrelated logic and will drift over time. Consider updating the reference to a stable anchor (e.g., the Manager_displayChangeDecide branch that treats 0 as off) or removing the line-number citation.

Suggested change
;; Legacy: numeric 0 is accepted as a synonym for "off" (Manager.ahk:692).
;; Legacy: numeric 0 is accepted as a synonym for "off" in the
;; Manager_displayChangeDecide branch that treats 0 as off.

Copilot uses AI. Check for mistakes.
Yunit.Assert(Manager_displayChangeDecide(0, "") = "ignore"
, "config=0 should ignore; got " . Manager_displayChangeDecide(0, ""))
}

;; --- Manager_displayChangeDecide: session override beats config ---

Decide_SessionYesOverridesAsk()
{
Yunit.Assert(Manager_displayChangeDecide("ask", "yes") = "reset"
, "session=yes should reset even when config=ask; got " . Manager_displayChangeDecide("ask", "yes"))
}

Decide_SessionNoOverridesAsk()
{
Yunit.Assert(Manager_displayChangeDecide("ask", "no") = "rearrange"
, "session=no should rearrange even when config=ask; got " . Manager_displayChangeDecide("ask", "no"))
}

Decide_SessionCancelOverridesAsk()
{
Yunit.Assert(Manager_displayChangeDecide("ask", "cancel") = "ignore"
, "session=cancel should ignore even when config=ask; got " . Manager_displayChangeDecide("ask", "cancel"))
}

;; Session override beats *any* config value, not just "ask". Not reachable
;; in normal use (the prompt only fires when config=ask), but the function
;; contract is "session wins" — locks in the precedence.
Decide_SessionBeatsConflictingConfig()
{
Yunit.Assert(Manager_displayChangeDecide("on", "cancel") = "ignore"
, "session=cancel must beat config=on; got " . Manager_displayChangeDecide("on", "cancel"))
Yunit.Assert(Manager_displayChangeDecide("off", "yes") = "reset"
, "session=yes must beat config=off; got " . Manager_displayChangeDecide("off", "yes"))
}

;; --- Manager_displayChangeRecordSessionChoice ---

Record_RememberStoresYes()
{
Global Manager_displayChangeSessionChoice
Manager_displayChangeSessionChoice := ""
Manager_displayChangeRecordSessionChoice("yes", True)
Yunit.Assert(Manager_displayChangeSessionChoice = "yes"
, "remember=True with choice=yes should store 'yes'; got '" . Manager_displayChangeSessionChoice . "'")
}

Record_RememberStoresNo()
{
Global Manager_displayChangeSessionChoice
Manager_displayChangeSessionChoice := ""
Manager_displayChangeRecordSessionChoice("no", True)
Yunit.Assert(Manager_displayChangeSessionChoice = "no"
, "remember=True with choice=no should store 'no'; got '" . Manager_displayChangeSessionChoice . "'")
}

Record_RememberStoresCancel()
{
Global Manager_displayChangeSessionChoice
Manager_displayChangeSessionChoice := ""
Manager_displayChangeRecordSessionChoice("cancel", True)
Yunit.Assert(Manager_displayChangeSessionChoice = "cancel"
, "remember=True with choice=cancel should store 'cancel'; got '" . Manager_displayChangeSessionChoice . "'")
}

Record_NoRememberLeavesUnchanged()
{
Global Manager_displayChangeSessionChoice
Manager_displayChangeSessionChoice := ""
Manager_displayChangeRecordSessionChoice("yes", False)
Yunit.Assert(Manager_displayChangeSessionChoice = ""
, "remember=False must not store choice; got '" . Manager_displayChangeSessionChoice . "'")
}

Record_NoRememberPreservesPriorSession()
{
;; If a previous prompt set "no" with remember=True, an unticked
;; "yes" on a later prompt must not overwrite it.
Global Manager_displayChangeSessionChoice
Manager_displayChangeSessionChoice := "no"
Manager_displayChangeRecordSessionChoice("yes", False)
Yunit.Assert(Manager_displayChangeSessionChoice = "no"
, "remember=False must preserve prior session choice; got '" . Manager_displayChangeSessionChoice . "'")
}

Record_RememberOverwritesPrevious()
{
Global Manager_displayChangeSessionChoice
Manager_displayChangeSessionChoice := "no"
Manager_displayChangeRecordSessionChoice("yes", True)
Yunit.Assert(Manager_displayChangeSessionChoice = "yes"
, "remember=True must overwrite prior session choice; got '" . Manager_displayChangeSessionChoice . "'")
}
}
Loading