Skip to content

Commit a50714c

Browse files
shanselmanCopilot
andcommitted
Merge PR #11: Add Settings dialog with hotkey customization and inverted maximize-button behavior
- Settings dialog for hotkey customization (Ctrl/Alt/Shift/Win + A-Z/0-9/F1-F12) - Invert shift-click behavior toggle - DPI-aware layout with proper SuspendLayout/ResumeLayout - Input validation and duplicate hotkey detection - Atomic settings persistence with error surfacing - Fixes #10 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2 parents 7ec82a2 + 5067b24 commit a50714c

5 files changed

Lines changed: 414 additions & 20 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Diagnostics;
2+
using System.Text.Json;
3+
using MaximizeToVirtualDesktop.Interop;
4+
5+
namespace MaximizeToVirtualDesktop;
6+
7+
/// <summary>
8+
/// Persists user-configurable settings.
9+
/// File lives in %LOCALAPPDATA%\MaximizeToVirtualDesktop\settings.json.
10+
/// </summary>
11+
internal sealed class AppSettings
12+
{
13+
private static readonly string FilePath = Path.Combine(
14+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
15+
"MaximizeToVirtualDesktop", "settings.json");
16+
17+
/// <summary>Modifier flags for the maximize hotkey (MOD_CONTROL | MOD_ALT | MOD_SHIFT etc.).</summary>
18+
public uint HotkeyModifiers { get; set; } =
19+
NativeMethods.MOD_CONTROL | NativeMethods.MOD_ALT | NativeMethods.MOD_SHIFT;
20+
21+
/// <summary>Virtual key code for the maximize hotkey.</summary>
22+
public uint HotkeyKey { get; set; } = NativeMethods.VK_X;
23+
24+
/// <summary>Modifier flags for the pin hotkey.</summary>
25+
public uint PinHotkeyModifiers { get; set; } =
26+
NativeMethods.MOD_CONTROL | NativeMethods.MOD_ALT | NativeMethods.MOD_SHIFT;
27+
28+
/// <summary>Virtual key code for the pin hotkey.</summary>
29+
public uint PinHotkeyKey { get; set; } = NativeMethods.VK_P;
30+
31+
/// <summary>
32+
/// When true, any click on the maximize button sends the window to a virtual desktop.
33+
/// Shift+Click performs a normal maximize instead.
34+
/// </summary>
35+
public bool InvertShiftClick { get; set; } = false;
36+
37+
public static AppSettings Load()
38+
{
39+
try
40+
{
41+
if (!File.Exists(FilePath)) return new AppSettings();
42+
var json = File.ReadAllText(FilePath);
43+
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
44+
}
45+
catch (Exception ex)
46+
{
47+
Trace.WriteLine($"AppSettings: Load failed: {ex.Message}");
48+
return new AppSettings();
49+
}
50+
}
51+
52+
public bool Save()
53+
{
54+
try
55+
{
56+
var dir = Path.GetDirectoryName(FilePath)!;
57+
Directory.CreateDirectory(dir);
58+
var tmp = Path.Combine(dir, "settings.tmp");
59+
File.WriteAllText(tmp, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
60+
File.Move(tmp, FilePath, overwrite: true);
61+
return true;
62+
}
63+
catch (Exception ex)
64+
{
65+
Trace.WriteLine($"AppSettings: Save failed: {ex.Message}");
66+
return false;
67+
}
68+
}
69+
}

src/MaximizeToVirtualDesktop/Interop/NativeMethods.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ internal static bool IsWindowElevated(IntPtr hwnd)
201201
internal const uint MOD_CONTROL = 0x0002;
202202
internal const uint MOD_ALT = 0x0001;
203203
internal const uint MOD_SHIFT = 0x0004;
204+
internal const uint MOD_WIN = 0x0008;
204205
internal const uint MOD_NOREPEAT = 0x4000;
205206

206207
internal const uint VK_X = 0x58;

src/MaximizeToVirtualDesktop/MaximizeButtonHook.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,18 @@ internal sealed class MaximizeButtonHook : IDisposable
1212
{
1313
private readonly FullScreenManager _manager;
1414
private readonly Control _syncControl;
15+
private readonly AppSettings _settings;
1516
private IntPtr _hookHandle;
1617
private bool _disposed;
1718

1819
// Must be stored as a field to prevent GC collection
1920
private readonly NativeMethods.LowLevelHookProc _hookProc;
2021

21-
public MaximizeButtonHook(FullScreenManager manager, Control syncControl)
22+
public MaximizeButtonHook(FullScreenManager manager, Control syncControl, AppSettings settings)
2223
{
2324
_manager = manager;
2425
_syncControl = syncControl;
26+
_settings = settings;
2527
_hookProc = HookCallback;
2628
}
2729

@@ -49,8 +51,12 @@ private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
4951
{
5052
if (nCode >= NativeMethods.HC_ACTION && wParam == (IntPtr)NativeMethods.WM_LBUTTONDOWN)
5153
{
52-
// Is Shift held?
53-
if ((NativeMethods.GetAsyncKeyState(NativeMethods.VK_SHIFT) & 0x8000) != 0)
54+
bool shiftHeld = (NativeMethods.GetAsyncKeyState(NativeMethods.VK_SHIFT) & 0x8000) != 0;
55+
// Normal mode: Shift+Click → virtual desktop
56+
// Inverted mode: plain Click → virtual desktop; Shift+Click → normal maximize
57+
bool triggerVirtualDesktop = _settings.InvertShiftClick ? !shiftHeld : shiftHeld;
58+
59+
if (triggerVirtualDesktop)
5460
{
5561
var hookStruct = Marshal.PtrToStructure<NativeMethods.MSLLHOOKSTRUCT>(lParam);
5662
var hwnd = NativeMethods.WindowFromPoint(hookStruct.pt);
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
using MaximizeToVirtualDesktop.Interop;
2+
3+
namespace MaximizeToVirtualDesktop;
4+
5+
/// <summary>
6+
/// Modal settings dialog for configuring hotkeys and maximize-button behavior.
7+
/// Call <see cref="ApplyToSettings"/> after <c>ShowDialog</c> returns <c>DialogResult.OK</c>.
8+
/// </summary>
9+
internal sealed class SettingsDialog : Form
10+
{
11+
private readonly AppSettings _settings;
12+
13+
private readonly CheckBox _chkHotkeyCtrl, _chkHotkeyAlt, _chkHotkeyShift, _chkHotkeyWin;
14+
private readonly ComboBox _cmbHotkeyKey;
15+
16+
private readonly CheckBox _chkPinCtrl, _chkPinAlt, _chkPinShift, _chkPinWin;
17+
private readonly ComboBox _cmbPinKey;
18+
19+
private readonly CheckBox _chkInvertShiftClick;
20+
21+
// (display name, VK code) pairs for the key combo boxes
22+
private static readonly (string Name, uint Vk)[] SupportedKeys =
23+
[
24+
.. Enumerable.Range('A', 26).Select(c => (((char)c).ToString(), (uint)c)),
25+
.. Enumerable.Range('0', 10).Select(c => (((char)c).ToString(), (uint)c)),
26+
.. Enumerable.Range(1, 12).Select(i => ($"F{i}", (uint)(0x70 + i - 1))),
27+
];
28+
29+
public SettingsDialog(AppSettings settings)
30+
{
31+
_settings = settings;
32+
33+
SuspendLayout();
34+
35+
AutoScaleDimensions = new SizeF(96F, 96F);
36+
AutoScaleMode = AutoScaleMode.Dpi;
37+
38+
Text = "Settings — Maximize to Virtual Desktop";
39+
StartPosition = FormStartPosition.CenterScreen;
40+
FormBorderStyle = FormBorderStyle.FixedDialog;
41+
MaximizeBox = false;
42+
MinimizeBox = false;
43+
ShowInTaskbar = false;
44+
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
45+
46+
int margin = 14;
47+
int grpW = 460;
48+
int y = margin + 4;
49+
50+
// Maximize hotkey group
51+
var grpMaximize = new GroupBox { Text = "Maximize Hotkey", Location = new Point(margin, y), Size = new Size(grpW, 72) };
52+
Controls.Add(grpMaximize);
53+
(_chkHotkeyCtrl, _chkHotkeyAlt, _chkHotkeyShift, _chkHotkeyWin, _cmbHotkeyKey) =
54+
AddHotkeyRow(grpMaximize, settings.HotkeyModifiers, settings.HotkeyKey);
55+
y += grpMaximize.Height + 12;
56+
57+
// Pin hotkey group
58+
var grpPin = new GroupBox { Text = "Pin Hotkey", Location = new Point(margin, y), Size = new Size(grpW, 72) };
59+
Controls.Add(grpPin);
60+
(_chkPinCtrl, _chkPinAlt, _chkPinShift, _chkPinWin, _cmbPinKey) =
61+
AddHotkeyRow(grpPin, settings.PinHotkeyModifiers, settings.PinHotkeyKey);
62+
y += grpPin.Height + 12;
63+
64+
// Behavior group
65+
var grpBehavior = new GroupBox { Text = "Behavior", Location = new Point(margin, y), Size = new Size(grpW, 80) };
66+
Controls.Add(grpBehavior);
67+
_chkInvertShiftClick = new CheckBox
68+
{
69+
Text = "Always maximize to virtual desktop on click\r\n" +
70+
"(Shift+Click performs a normal maximize instead)",
71+
AutoSize = true,
72+
Checked = settings.InvertShiftClick,
73+
Location = new Point(10, 28),
74+
};
75+
grpBehavior.Controls.Add(_chkInvertShiftClick);
76+
y += grpBehavior.Height + 16;
77+
78+
// Buttons — uniform height, Reset Defaults right-aligned
79+
int btnW = 90;
80+
int btnResetW = 130;
81+
int btnH = 30;
82+
var btnOk = new Button
83+
{
84+
Text = "OK",
85+
DialogResult = DialogResult.OK,
86+
Location = new Point(margin, y),
87+
Size = new Size(btnW, btnH),
88+
FlatStyle = FlatStyle.System,
89+
};
90+
var btnCancel = new Button
91+
{
92+
Text = "Cancel",
93+
DialogResult = DialogResult.Cancel,
94+
Location = new Point(margin + btnW + 8, y),
95+
Size = new Size(btnW, btnH),
96+
FlatStyle = FlatStyle.System,
97+
};
98+
var btnReset = new Button
99+
{
100+
Text = "Reset Defaults",
101+
Location = new Point(margin + grpW - btnResetW, y),
102+
Size = new Size(btnResetW, btnH),
103+
FlatStyle = FlatStyle.System,
104+
};
105+
btnReset.Click += (_, _) => ResetDefaults();
106+
btnOk.Click += (_, e) =>
107+
{
108+
var error = ValidateHotkeys();
109+
if (error != null)
110+
{
111+
MessageBox.Show(error, "Invalid Settings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
112+
DialogResult = DialogResult.None;
113+
}
114+
};
115+
116+
Controls.AddRange(new Control[] { btnOk, btnCancel, btnReset });
117+
AcceptButton = btnOk;
118+
CancelButton = btnCancel;
119+
120+
ClientSize = new Size(margin + grpW + margin, y + btnH + margin);
121+
122+
ResumeLayout(false);
123+
PerformLayout();
124+
}
125+
126+
private static (CheckBox ctrl, CheckBox alt, CheckBox shift, CheckBox win, ComboBox key)
127+
AddHotkeyRow(GroupBox grp, uint modifiers, uint vk)
128+
{
129+
// FlowLayoutPanel inside the group box handles DPI scaling for the row
130+
var row = new FlowLayoutPanel
131+
{
132+
FlowDirection = FlowDirection.LeftToRight,
133+
AutoSize = true,
134+
AutoSizeMode = AutoSizeMode.GrowAndShrink,
135+
WrapContents = false,
136+
Location = new Point(6, 26),
137+
};
138+
139+
var chkCtrl = new CheckBox { Text = "Ctrl", Checked = (modifiers & NativeMethods.MOD_CONTROL) != 0, AutoSize = true, Margin = new Padding(2, 2, 4, 0) };
140+
var chkAlt = new CheckBox { Text = "Alt", Checked = (modifiers & NativeMethods.MOD_ALT) != 0, AutoSize = true, Margin = new Padding(2, 2, 4, 0) };
141+
var chkShift = new CheckBox { Text = "Shift", Checked = (modifiers & NativeMethods.MOD_SHIFT) != 0, AutoSize = true, Margin = new Padding(2, 2, 4, 0) };
142+
var chkWin = new CheckBox { Text = "Win", Checked = (modifiers & NativeMethods.MOD_WIN) != 0, AutoSize = true, Margin = new Padding(2, 2, 10, 0) };
143+
var lblKey = new Label { Text = "Key:", AutoSize = true, Margin = new Padding(2, 5, 2, 0) };
144+
var cmb = new ComboBox
145+
{
146+
DropDownStyle = ComboBoxStyle.DropDownList,
147+
Width = 60,
148+
Margin = new Padding(2, 2, 0, 0),
149+
};
150+
151+
foreach (var (name, _) in SupportedKeys)
152+
cmb.Items.Add(name);
153+
154+
var idx = Array.FindIndex(SupportedKeys, k => k.Vk == vk);
155+
cmb.SelectedIndex = Math.Max(0, idx);
156+
157+
row.Controls.AddRange(new Control[] { chkCtrl, chkAlt, chkShift, chkWin, lblKey, cmb });
158+
grp.Controls.Add(row);
159+
return (chkCtrl, chkAlt, chkShift, chkWin, cmb);
160+
}
161+
162+
private void ResetDefaults()
163+
{
164+
_chkHotkeyCtrl.Checked = true;
165+
_chkHotkeyAlt.Checked = true;
166+
_chkHotkeyShift.Checked = true;
167+
_chkHotkeyWin.Checked = false;
168+
_cmbHotkeyKey.SelectedIndex = Array.FindIndex(SupportedKeys, k => k.Vk == NativeMethods.VK_X);
169+
170+
_chkPinCtrl.Checked = true;
171+
_chkPinAlt.Checked = true;
172+
_chkPinShift.Checked = true;
173+
_chkPinWin.Checked = false;
174+
_cmbPinKey.SelectedIndex = Array.FindIndex(SupportedKeys, k => k.Vk == NativeMethods.VK_P);
175+
176+
_chkInvertShiftClick.Checked = false;
177+
}
178+
179+
/// <summary>Returns an error message if the current hotkey configuration is invalid, or null if valid.</summary>
180+
private string? ValidateHotkeys()
181+
{
182+
uint hotkeyMod = BuildModifiers(_chkHotkeyCtrl, _chkHotkeyAlt, _chkHotkeyShift, _chkHotkeyWin);
183+
uint pinMod = BuildModifiers(_chkPinCtrl, _chkPinAlt, _chkPinShift, _chkPinWin);
184+
185+
if (hotkeyMod == 0)
186+
return "Maximize hotkey needs at least one modifier (Ctrl, Alt, Shift, or Win).";
187+
if (pinMod == 0)
188+
return "Pin hotkey needs at least one modifier (Ctrl, Alt, Shift, or Win).";
189+
if (_cmbHotkeyKey.SelectedIndex < 0)
190+
return "Please select a key for the maximize hotkey.";
191+
if (_cmbPinKey.SelectedIndex < 0)
192+
return "Please select a key for the pin hotkey.";
193+
194+
uint hotkeyVk = SupportedKeys[_cmbHotkeyKey.SelectedIndex].Vk;
195+
uint pinVk = SupportedKeys[_cmbPinKey.SelectedIndex].Vk;
196+
197+
if (hotkeyMod == pinMod && hotkeyVk == pinVk)
198+
return "Maximize and Pin hotkeys cannot be the same combination.";
199+
200+
return null;
201+
}
202+
203+
/// <summary>Writes the dialog's current values back into the <see cref="AppSettings"/> object.</summary>
204+
public void ApplyToSettings()
205+
{
206+
_settings.HotkeyModifiers = BuildModifiers(_chkHotkeyCtrl, _chkHotkeyAlt, _chkHotkeyShift, _chkHotkeyWin);
207+
_settings.HotkeyKey = _cmbHotkeyKey.SelectedIndex >= 0 ? SupportedKeys[_cmbHotkeyKey.SelectedIndex].Vk : NativeMethods.VK_X;
208+
_settings.PinHotkeyModifiers = BuildModifiers(_chkPinCtrl, _chkPinAlt, _chkPinShift, _chkPinWin);
209+
_settings.PinHotkeyKey = _cmbPinKey.SelectedIndex >= 0 ? SupportedKeys[_cmbPinKey.SelectedIndex].Vk : NativeMethods.VK_P;
210+
_settings.InvertShiftClick = _chkInvertShiftClick.Checked;
211+
}
212+
213+
private static uint BuildModifiers(CheckBox ctrl, CheckBox alt, CheckBox shift, CheckBox win)
214+
{
215+
uint m = 0;
216+
if (ctrl.Checked) m |= NativeMethods.MOD_CONTROL;
217+
if (alt.Checked) m |= NativeMethods.MOD_ALT;
218+
if (shift.Checked) m |= NativeMethods.MOD_SHIFT;
219+
if (win.Checked) m |= NativeMethods.MOD_WIN;
220+
return m;
221+
}
222+
}

0 commit comments

Comments
 (0)