diff --git a/README.md b/README.md index cedb36b..f4178a7 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,14 @@ the top edge to show them, or set **Auto-hide tabs** to **Never**. | Search tabs | Ctrl + Alt + T | | New window of the current app | Ctrl + Alt + N | | Switch tabs with the mouse | Scroll over the tab bar, or Shift + scroll over the window | +| Move the current tab | Ctrl + scroll over the tab bar | | Close a tab | Middle-click | Change any shortcut under **Settings → Shortcuts**. If Ctrl/Alt + number conflicts with an app, turn it off for that app from the tab's right-click menu. **Optional, off by default:** switch on hover, a leader key to pick tabs by letter -(Alt + S, then the key on the tab), automatic tab colors, and an Alt+Tab replacement. +(Alt + F, then the key on the tab), automatic tab colors, and an Alt+Tab replacement. Drag files onto a tab and pause to bring that window forward. On a **File Explorer** tab, let go to drop them into its folder as Explorer would: Ctrl copies, Shift moves, diff --git a/WtProgram/DesktopPlugins/NotifyIconPlugin.fs b/WtProgram/DesktopPlugins/NotifyIconPlugin.fs index 4b10526..9cec3ce 100755 --- a/WtProgram/DesktopPlugins/NotifyIconPlugin.fs +++ b/WtProgram/DesktopPlugins/NotifyIconPlugin.fs @@ -8,6 +8,8 @@ type NotifyIconPlugin() = let mutable languageSubscription : IDisposable option = None let mutable errorSubscription : IDisposable option = None let mutable errorNoticeShown = false + /// The menu the icon opens next; one that has been shown disposes itself once closed. + let mutable menu : ThemedContextMenu option = None let invoker = InvokerService.invoker member this.icon = Cell.cacheProp this <| fun() -> @@ -15,31 +17,38 @@ type NotifyIconPlugin() = notifyIcon.Visible <- true notifyIcon.Text <- "WindowTabs " + Services.program.version notifyIcon.Icon <- Services.openIcon(ThemeService.appIconName) - notifyIcon.ContextMenu <- new ContextMenu() notifyIcon.MouseClick.Add <| fun e -> if e.Button = MouseButtons.Left then Services.managerView.show() + // Built as the button goes down, so the menu the icon shows on release is in the + // current theme and language. + notifyIcon.MouseDown.Add <| fun e -> + if e.Button = MouseButtons.Right then this.prepareMenu true // The only balloon WindowTabs shows is the error notice, so a click opens the crash log. notifyIcon.BalloonTipClicked.Add <| fun _ -> RuntimeDiagnostics.crashLogPath() |> Option.iter(fun path -> try Diagnostics.Process.Start("explorer.exe",sprintf "/select,\"%s\"" path) |> ignore with _ -> ()) notifyIcon - member this.contextMenuItems = this.icon.ContextMenu.MenuItems - - member this.addItem(text, handler) = - this.contextMenuItems.Add(text, EventHandler(fun obj (e:EventArgs) -> handler())) |> ignore - - member private this.buildMenu() = - this.contextMenuItems.Clear() - this.addItem(tr Strings.TabMenu.settings, fun() -> Services.managerView.show()) - this.contextMenuItems.Add("-").ignore - this.addItem(tr Strings.Tray.exit, fun() -> Services.program.shutdown()) + /// The same themed menu as the tabs', in place of the system's light one. A menu that has just + /// closed still has its chosen command to run and disposes itself; only one never shown is + /// disposed here. After a close the next is ready for the keyboard, which opens it with no click. + member private this.prepareMenu(replaceIdle:bool) = + if not disposed then + let item text run = CmiRegular { text=text; image=None; click=run; flags=List2() } + let items = List2([ item (tr Strings.TabMenu.settings) (fun () -> Services.managerView.show()) + CmiSeparator + item (tr Strings.Tray.exit) (fun () -> Services.program.shutdown()) ]) + let next = new ThemedContextMenu(items,fun () -> invoker.asyncInvoke(fun () -> this.prepareMenu false)) + let previous = menu + menu <- Some next + this.icon.ContextMenuStrip <- next + if replaceIdle then previous |> Option.iter(fun idle -> if not idle.Visible then idle.Dispose()) interface IPlugin with member this.init() = - this.buildMenu() + this.prepareMenu true languageSubscription <- Some(Services.settings.notifyValue "language" (fun _ -> - invoker.asyncInvoke(fun () -> if not disposed then this.buildMenu()))) + invoker.asyncInvoke(fun () -> this.prepareMenu true))) // Once per session: later errors go to the same log without another notice. errorSubscription <- Some(RuntimeDiagnostics.errorLogged.Subscribe(fun () -> invoker.asyncInvoke(fun () -> @@ -53,6 +62,7 @@ type NotifyIconPlugin() = disposed <- true languageSubscription |> Option.iter (fun subscription -> subscription.Dispose()) errorSubscription |> Option.iter (fun subscription -> subscription.Dispose()) + menu |> Option.iter(fun idle -> if not idle.Visible then idle.Dispose()) let icon = this.icon.Icon this.icon.Dispose() if not (isNull icon) then icon.Dispose() diff --git a/WtProgram/GroupPlugins/MouseScrollPlugin.fs b/WtProgram/GroupPlugins/MouseScrollPlugin.fs index 5d8fe9d..82b14bd 100755 --- a/WtProgram/GroupPlugins/MouseScrollPlugin.fs +++ b/WtProgram/GroupPlugins/MouseScrollPlugin.fs @@ -3,26 +3,85 @@ open System open System.Runtime.InteropServices type MouseScrollPlugin() as this = - + let clock = Diagnostics.Stopwatch.StartNew() + let mutable lastWheel = -1000L + let mutable lastAction = "" + let mutable wheelRest = 0 + /// Every notch is a step, taken one at a time so each tab is shown on the way. + let mutable pendingSteps = 0 + let mutable stepping = false + /// The first step of a new turn starts from the window in front. + let mutable restart = true + /// Where scrolling has got to. Further steps go on from here rather than from the window + /// in front, which follows a moment later, so none is lost or counted twice. + let mutable scrollTarget : IntPtr option = None + let stepTimer = new System.Windows.Forms.Timer(Interval=TabNavigation.scrollStepInterval) + member this.wtGroup = Services.get() member this.settings = Services.get() + /// Ctrl + scroll over the tabs carries the current tab along them, down to the right as + /// scrolling switches tabs, and stops at either end. + member private this.moveCurrentTab(right) = + let group = this.wtGroup + TabNavigation.moveTarget group.lorder.list (OS().foreground.hwnd) (Some group.topWindow) right + |> Option.iter(fun (hwnd,index) -> group.ts.moveTab(Tab(hwnd),index)) + + /// One tab along, shown at once. + member private this.scrollTab(next, fresh) = + let group = this.wtGroup + let order = group.lorder.list + if order.Length > 1 then + let from = + match scrollTarget with + | Some hwnd when not fresh && List.contains hwnd order -> hwnd + | _ -> OS().foreground.hwnd + scrollTarget <- TabNavigation.stepTarget order from (Some group.topWindow) 1 next + scrollTarget |> Option.iter(fun hwnd -> group.tabActivate(Tab(hwnd), true)) + + /// One step now and the next once this tab has been drawn; wheel events meanwhile add their + /// own steps, so a quick turn passes visibly through every tab it covers and ends on the last. + member private this.step() = + if pendingSteps=0 then stepping <- false + else + stepping <- true + let next = pendingSteps < 0 + pendingSteps <- pendingSteps + (if next then 1 else -1) + if lastAction="move" then this.moveCurrentTab(next) + else this.scrollTab(next, restart) + restart <- false + stepTimer.Start() + member this.onMouseLL(msg, pt, data:IntPtr) = match msg with | WindowMessages.WM_MOUSEWHEEL -> - let wheelDelta = data.hiword let enableShiftScroll = this.settings.getValue("enableShiftScroll") :?> bool - let doSwitch = - if enableShiftScroll && Win32Helper.IsKeyPressed(VirtualKeyCodes.VK_SHIFT) then - this.wtGroup.isPointInGroup(pt) - else - this.wtGroup.isPointInTs(pt) - if doSwitch then - let next = wheelDelta < int16(0) - this.wtGroup.switchWindow(next, true) + let enableCtrlScroll = this.settings.getValue("enableCtrlScroll") :?> bool + let action = + if enableCtrlScroll && Win32Helper.IsKeyPressed(VirtualKeyCodes.VK_CONTROL) then + if this.wtGroup.isPointInTs(pt) then "move" else "" + elif enableShiftScroll && Win32Helper.IsKeyPressed(VirtualKeyCodes.VK_SHIFT) then + if this.wtGroup.isPointInGroup(pt) then "switch" else "" + elif this.wtGroup.isPointInTs(pt) then "switch" + else "" + if action<>"" then + let now = clock.ElapsedMilliseconds + let fresh = now-lastWheel > 400L || action<>lastAction + if action<>lastAction then pendingSteps <- 0 + // Steps still being shown carry on from where they got to. + if fresh && not stepping then restart <- true + lastWheel <- now + lastAction <- action + let notches,rest = TabNavigation.wheelNotches wheelRest (int data.hiword) fresh + wheelRest <- rest + if notches<>0 then + pendingSteps <- TabNavigation.addSteps pendingSteps notches + if not stepping then this.step() | _ ->() interface IPlugin with member x.init() = + stepTimer.Tick.Add(fun _ -> stepTimer.Stop(); this.step()) this.wtGroup.mouseLL.Add this.onMouseLL + this.wtGroup.exited.Add(fun _ -> stepTimer.Dispose()) diff --git a/WtProgram/ManagerViewService/Views/BehaviorView.fs b/WtProgram/ManagerViewService/Views/HotKeyView.fs similarity index 99% rename from WtProgram/ManagerViewService/Views/BehaviorView.fs rename to WtProgram/ManagerViewService/Views/HotKeyView.fs index 2f4e4b4..a6b1433 100755 --- a/WtProgram/ManagerViewService/Views/BehaviorView.fs +++ b/WtProgram/ManagerViewService/Views/HotKeyView.fs @@ -42,6 +42,21 @@ type HotKeyView() = let update() = for row in rows do row.Collapsed <- not toggle.Checked update() toggle.CheckedChanged.Add(fun _ -> update()) + let numericEnabled = SettingsBindings.settingToggle "enableCtrlNumberHotKey" + SettingsUi.settingRow keyboard "switch-tabs-by-number" numericEnabled + let numericChoice = SettingsBindings.choiceRow keyboard "number-shortcut" + [|tr Strings.Settings.numberShortcutCtrl;tr Strings.Settings.numberShortcutAlt|] + // Switched off, an app enabled from the tab menu still uses the modifier, so it stays. + let numericRow = numericChoice.Parent :?> SettingsRow + SettingsUi.indentDependentRow numericRow + let updateNumeric() = + let appsEnabled = (Services.settings.getValue("enabledNumberShortcutPaths") :?> Set2).items.list.IsEmpty |> not + numericRow.Collapsed <- not numericEnabled.Checked && not appsEnabled + updateNumeric() + numericEnabled.CheckedChanged.Add(fun _ -> updateNumeric()) + let appsSubscription = Services.settings.notifyValue "enabledNumberShortcutPaths" (fun _ -> + if not numericRow.IsDisposed then updateNumeric()) + numericRow.Disposed.Add(fun _ -> appsSubscription.Dispose()) let leaderEnabled = SettingsBindings.settingToggle "enableNumberLeader" SettingsUi.settingRow keyboard "enable-number-leader" leaderEnabled let leaderEditor = editors |> List.find(fun (key,_) -> key="numberLeader") |> snd @@ -91,21 +106,6 @@ type HotKeyView() = button :> Control) SettingsUi.settingRowWithActions keyboard "leader-keys" keysEditor presets |> ignore dependOn leaderEnabled [leaderEditor.Parent :?> SettingsRow;keysEditor.Parent :?> SettingsRow] - let numericEnabled = SettingsBindings.settingToggle "enableCtrlNumberHotKey" - SettingsUi.settingRow keyboard "switch-tabs-by-number" numericEnabled - let numericChoice = SettingsBindings.choiceRow keyboard "number-shortcut" - [|tr Strings.Settings.numberShortcutCtrl;tr Strings.Settings.numberShortcutAlt|] - // Switched off, an app enabled from the tab menu still uses the modifier, so it stays. - let numericRow = numericChoice.Parent :?> SettingsRow - SettingsUi.indentDependentRow numericRow - let updateNumeric() = - let appsEnabled = (Services.settings.getValue("enabledNumberShortcutPaths") :?> Set2).items.list.IsEmpty |> not - numericRow.Collapsed <- not numericEnabled.Checked && not appsEnabled - updateNumeric() - numericEnabled.CheckedChanged.Add(fun _ -> updateNumeric()) - let appsSubscription = Services.settings.notifyValue "enabledNumberShortcutPaths" (fun _ -> - if not numericRow.IsDisposed then updateNumeric()) - numericRow.Disposed.Add(fun _ -> appsSubscription.Dispose()) // Offered only while a shortcut differs from its default; otherwise it would do nothing. let updateRestore() = restore.Offered <- editors |> List.exists(fun (key,editor) -> editor.Shortcut<>SettingsCatalog.shortcutDefault key) @@ -115,6 +115,7 @@ type HotKeyView() = let pointer = SettingsUi.sectionCard table (tr Strings.Shortcuts.mouse) SettingsBindings.toggleRow pointer "activate-on-hover" SettingsBindings.toggleRow pointer "shift-scroll" + SettingsBindings.toggleRow pointer "ctrl-scroll" interface ISettingsView with member _.key = SettingsViewType.HotKeySettings member _.title = tr Strings.Pages.shortcuts diff --git a/WtProgram/Settings.fs b/WtProgram/Settings.fs index e348d57..b1883ad 100755 --- a/WtProgram/Settings.fs +++ b/WtProgram/Settings.fs @@ -142,6 +142,7 @@ type Settings(isStandAlone, ?saveDelay:int) as this = if settingsJson.getBool("minimalMode").def(false) then false else SettingsCatalog.toggleDefault "showTabsOnSwitch" hasExistingSettings) enableShiftScroll = settingsJson.getBool("enableShiftScroll").def(SettingsCatalog.toggleDefault "enableShiftScroll" hasExistingSettings) + enableCtrlScroll = settingsJson.getBool("enableCtrlScroll").def(SettingsCatalog.toggleDefault "enableCtrlScroll" hasExistingSettings) version = settingsJson.getString("version").def(String.Empty) alignment = settingsJson.getString("alignment").def(SettingsCatalog.choiceDefault "alignment") |> SettingsCatalog.normalizeChoice "alignment" switcherStyle = settingsJson.getString("switcherStyle").def(SettingsCatalog.choiceDefault "switcherStyle") |> SettingsCatalog.normalizeChoice "switcherStyle" @@ -223,6 +224,7 @@ type Settings(isStandAlone, ?saveDelay:int) as this = settingsJson.Remove("licenseKey") |> ignore settingsJson.Remove("ticket") |> ignore settingsJson.setBool("enableShiftScroll", settings.enableShiftScroll) + settingsJson.setBool("enableCtrlScroll", settings.enableCtrlScroll) settingsJson.setStringArray("includedPaths", settings.includedPaths.items) settingsJson.setStringArray("excludedPaths", settings.excludedPaths.items) settingsJson.setStringArray("autoGroupingPaths", settings.autoGroupingPaths.items) diff --git a/WtProgram/Shared/ProgramTypes.fs b/WtProgram/Shared/ProgramTypes.fs index bb0b03d..9ccfff3 100755 --- a/WtProgram/Shared/ProgramTypes.fs +++ b/WtProgram/Shared/ProgramTypes.fs @@ -36,6 +36,8 @@ type SettingsRec = { /// Expand auto-hidden tabs for a moment after switching tabs. showTabsOnSwitch: bool enableShiftScroll: bool + /// Ctrl + scroll over the tabs moves the current tab. + enableCtrlScroll: bool /// "Icons" (large icons in a row) or "List" (window titles in a column). switcherStyle: string alignment: string diff --git a/WtProgram/Shared/SettingsCatalog.fs b/WtProgram/Shared/SettingsCatalog.fs index f990eee..0920868 100644 --- a/WtProgram/Shared/SettingsCatalog.fs +++ b/WtProgram/Shared/SettingsCatalog.fs @@ -39,12 +39,13 @@ module SettingsCatalog = { id="search-tabs"; page=HotKeySettings; text=Strings.Settings.searchTabs; binding=Shortcut("searchTabs",1620) } { id="new-tab"; page=HotKeySettings; text=Strings.Settings.newTab; binding=Shortcut("newTab",1614) } { id="switch-tabs-by-number"; page=HotKeySettings; text=Strings.Settings.switchTabsByNumber; binding=Toggle("enableCtrlNumberHotKey",true,true) } + { id="number-shortcut"; page=HotKeySettings; text=Strings.Settings.numberShortcut; binding=Choice("numberHotKeyModifier",["Ctrl";"Alt"],"Ctrl") } { id="enable-number-leader"; page=HotKeySettings; text=Strings.Settings.enableNumberLeader; binding=Toggle("enableNumberLeader",false,false) } - { id="number-leader"; page=HotKeySettings; text=Strings.Settings.numberLeader; binding=Shortcut("numberLeader",1107) } + { id="number-leader"; page=HotKeySettings; text=Strings.Settings.numberLeader; binding=Shortcut("numberLeader",1094) } { id="leader-keys"; page=HotKeySettings; text=Strings.Settings.leaderKeys; binding=Text("numberLeaderKeys","123456789") } - { id="number-shortcut"; page=HotKeySettings; text=Strings.Settings.numberShortcut; binding=Choice("numberHotKeyModifier",["Ctrl";"Alt"],"Ctrl") } { id="activate-on-hover"; page=HotKeySettings; text=Strings.Settings.activateOnHover; binding=Toggle("enableHoverActivate",false,false) } { id="shift-scroll"; page=HotKeySettings; text=Strings.Settings.shiftScroll; binding=Toggle("enableShiftScroll",true,true) } + { id="ctrl-scroll"; page=HotKeySettings; text=Strings.Settings.ctrlScroll; binding=Toggle("enableCtrlScroll",true,true) } { id="tabTextColor"; page=AppearanceSettings; text=Strings.Settings.tabTextColor; binding=Colour } { id="tabNormalBgColor"; page=AppearanceSettings; text=Strings.Settings.tabNormalBgColor; binding=Colour } { id="tabActiveBgColor"; page=AppearanceSettings; text=Strings.Settings.tabActiveBgColor; binding=Colour } diff --git a/WtProgram/Shared/Strings.fs b/WtProgram/Shared/Strings.fs index 778bc46..c20b6c3 100644 --- a/WtProgram/Shared/Strings.fs +++ b/WtProgram/Shared/Strings.fs @@ -143,10 +143,10 @@ module Strings = description={ en="Each group appears as one item."; zh="每个分组只显示一项。"; ja="各グループを 1 項目として表示します。" } keywords={ en="switcher"; zh="切换器"; ja="切り替え" } } let nextTab = { caption={ en="Switch to next tab"; zh="切换到下一个标签"; ja="次のタブに切り替え" } - description={ en="Within the current group."; zh="在当前分组内切换。"; ja="現在のグループ内で切り替えます。" } + description=none keywords=hotkeyWords } let previousTab = { caption={ en="Switch to previous tab"; zh="切换到上一个标签"; ja="前のタブに切り替え" } - description={ en="Within the current group."; zh="在当前分组内切换。"; ja="現在のグループ内で切り替えます。" } + description=none keywords=hotkeyWords } let searchTabs = { caption={ en="Search tabs"; zh="搜索标签"; ja="タブを検索" } description={ en="By title or app name, current group first."; zh="按标题或应用名查找,优先当前分组。"; ja="タイトルやアプリ名で探します。現在のグループを優先。" } @@ -162,7 +162,7 @@ module Strings = let numberShortcut = { caption={ en="Modifier key for 1–9"; zh="数字键搭配的修饰键"; ja="1~9 と組み合わせるキー" } description=none keywords={ en="number"; zh="数字"; ja="番号" } } - let enableNumberLeader = { caption={ en="Use a leader key to select tabs"; zh="使用引导键切换标签"; ja="リーダーキーでタブを選ぶ" }; description={ en="Press the leader, then a selection key. Escape cancels."; zh="先按引导键,再按选择键。Esc 取消。"; ja="リーダーキーの後、選択キーを押します。Esc で解除。" }; keywords=none } + let enableNumberLeader = { caption={ en="Use a leader key to select tabs"; zh="使用引导键切换标签"; ja="リーダーキーでタブを選ぶ" }; description={ en="Press the leader, then a selection key. Esc cancels."; zh="先按引导键,再按选择键。Esc 取消。"; ja="リーダーキーの後、選択キーを押します。Esc で解除。" }; keywords=none } let leaderKeys = { caption={en="Selection keys";zh="选择键";ja="選択キー"}; description={en="Use unique letters or digits, in tab order.";zh="按标签顺序,使用不重复的字母或数字。";ja="タブ順に、重複しない英字または数字を指定します。"}; keywords={en="leader keys letters home row";zh="引导键 字母 主键区";ja="リーダーキー 英字 ホームポジション"} } let leaderKeyPresets = [| {en="123456789";zh="123456789";ja="123456789"}; {en="QWERTYUIOP";zh="QWERTYUIOP";ja="QWERTYUIOP"}; {en="ASDFGHJKL;";zh="ASDFGHJKL;";ja="ASDFGHJKL;"} |] let invalidLeaderKeys = {en="Use at least one unique letter, digit or unshifted punctuation key.";zh="请使用至少一个不重复的字母、数字或无需 Shift 的标点键。";ja="重複しない英字、数字、Shift 不要の記号を 1 つ以上指定してください。"} @@ -204,10 +204,15 @@ module Strings = description={ en="Rest the pointer on a tab instead of clicking."; zh="鼠标停在标签上即可,无需点击。"; ja="クリックしなくても、タブにポインターを置くだけで済みます。" } keywords={ en="mouse over"; zh="悬停"; ja="マウスオーバー" } } let shiftScroll = { caption={ en="Switch tabs with Shift + scroll"; zh="用 Shift + 滚轮切换标签"; ja="Shift + スクロールでタブを切り替え" } - description={ en="Works anywhere over a grouped window. Over the tab strip, Shift isn't needed." - zh="在分组窗口的任意位置均可使用;在标签条上滚动时无需按 Shift。" + description={ en="Works anywhere over a grouped window. Over the tab bar, Shift isn't needed." + zh="在分组窗口的任意位置均可使用;在标签栏上滚动时无需按 Shift。" ja="グループ化されたウィンドウ上ならどこでも使えます。タブバー上では Shift は不要です。" } keywords={ en="wheel"; zh="滚轮"; ja="ホイール" } } + let ctrlScroll = { caption={ en="Move tabs with Ctrl + scroll"; zh="用 Ctrl + 滚轮移动标签"; ja="Ctrl + スクロールでタブを移動" } + description={ en="Over the tab bar, moves the current tab left or right." + zh="在标签栏上滚动,将当前标签向左或向右移动。" + ja="タブバー上で、現在のタブを左右に移動します。" } + keywords={ en="wheel reorder"; zh="滚轮 排序"; ja="ホイール 並べ替え" } } let tabTextColor = { caption={ en="Text and close button"; zh="文字与关闭按钮"; ja="文字と閉じるボタン" }; description=none; keywords=colorWords } let tabNormalBgColor = { caption={ en="Inactive tab"; zh="非活动标签"; ja="非アクティブなタブ" }; description=none; keywords=colorWords } let tabActiveBgColor = { caption={ en="Active tab"; zh="活动标签"; ja="アクティブなタブ" }; description=none; keywords=colorWords } diff --git a/WtProgram/WindowGroup.fs b/WtProgram/WindowGroup.fs index 6aaa35f..f0258d1 100755 --- a/WtProgram/WindowGroup.fs +++ b/WtProgram/WindowGroup.fs @@ -91,6 +91,39 @@ module TabNavigation = |> Option.bind(fun hwnd -> order |> List.tryFindIndex ((=) hwnd)) |> Option.map(fun index -> (index + (if next then 1 else order.Length-1)) % order.Length) + /// The tab a number of steps along from the current one, wrapping as switching does. + let stepTarget (order:IntPtr list) current previousTop steps next = + let current = if List.contains current order then Some current else previousTop + current + |> Option.bind(fun hwnd -> order |> List.tryFindIndex ((=) hwnd)) + |> Option.map(fun index -> + let count = order.Length + order.[((index + (if next then steps else -steps)) % count + count) % count]) + + /// A wheel notch is 120. High-resolution wheels and touchpads send one in parts, so only + /// whole notches count and the rest waits for the next part; turning back or pausing + /// starts afresh. Returns the whole notches, negative towards the user, and the rest. + let wheelNotches rest delta fresh = + let rest = if fresh || (rest<>0 && sign rest<>sign delta) then 0 else rest + let total = rest + delta + total / 120, total % 120 + + /// Notches still to be stepped through, negative towards the user. Each notch counts; + /// turning the other way drops the steps still waiting and goes that way instead. + let addSteps pending notches = + if pending<>0 && sign pending<>sign notches then notches else pending+notches + + /// Each tab a step passes stays in front this long, a couple of frames, so it is drawn and + /// the tabs are seen to move through it instead of jumping. + let scrollStepInterval = 40 + + /// Where Ctrl + scroll carries the current tab: one place along, never past either end. + let moveTarget (order:IntPtr list) foreground previousTop right = + let current = if List.contains foreground order then Some foreground else previousTop + current + |> Option.bind(fun hwnd -> order |> List.tryFindIndex ((=) hwnd) |> Option.map(fun index -> hwnd,index + (if right then 1 else -1))) + |> Option.filter(fun (_,index) -> index>=0 && index string) (title:'a -> string) (tabs:'a list) = diff --git a/WtProgram/WtProgram.fsproj b/WtProgram/WtProgram.fsproj index d5e9f71..6f0cd35 100755 --- a/WtProgram/WtProgram.fsproj +++ b/WtProgram/WtProgram.fsproj @@ -110,7 +110,7 @@ - + diff --git a/docs/settings-architecture.md b/docs/settings-architecture.md index 3c8986a..5b36ea1 100644 --- a/docs/settings-architecture.md +++ b/docs/settings-architecture.md @@ -19,7 +19,7 @@ uses the standard joined style. Switching briefly expands auto-hidden tabs, and Ctrl+Alt+N opens a new tab. The Alt+Tab replacement, taskbar icon combining, leader selection, hover activation and automatic coloring are opt-in. Search defaults to Ctrl+Alt+T to leave common launcher shortcuts available. The configured leader -remains Alt+S when enabled. +is Alt+F when enabled. Stored preferences take priority. Missing optional settings and a full reset use the minimal defaults from `SettingsCatalog`; explicit legacy auto-hide settings retain diff --git a/tests/Architecture.fsx b/tests/Architecture.fsx index 28e364f..801be27 100644 --- a/tests/Architecture.fsx +++ b/tests/Architecture.fsx @@ -230,6 +230,21 @@ let main() = check (navigate [a;c] c (Some b) false=Some 0) "Closed tab influenced navigation" check (navigate [a;c] IntPtr.Zero (Some c) true=Some 0) "Background group lost its last top tab" check (navigate [] a (Some a) true=None && navigate [a] IntPtr.Zero None false=None) "Empty/unknown tab navigation fabricated a target" + // Ctrl + scroll carries the focused tab one place, from its last top tab in a background group. + check (TabNavigation.moveTarget [a;b;c] b (Some a) true=Some(b,2) && TabNavigation.moveTarget [a;b;c] b (Some a) false=Some(b,0)) "Ctrl + scroll did not move the focused tab one place" + check (TabNavigation.moveTarget [a;b;c] IntPtr.Zero (Some c) false=Some(c,1)) "Ctrl + scroll lost a background group's top tab" + check (TabNavigation.moveTarget [a;b;c] c None true=None && TabNavigation.moveTarget [a;b;c] a None false=None) "Ctrl + scroll wrapped a tab past the end" + // A notch sent in parts by a high-resolution wheel is one tab, not one per part. + let notches parts = + parts |> List.fold(fun (total,rest) delta -> let n,r = TabNavigation.wheelNotches rest delta false in total+n,r) (0,0) |> fst + check (notches [-30;-30;-30;-30]= -1 && notches [-120;-120]= -2 && notches [40;40;40;40;40;40]=2) "A notch in parts switched more or fewer than one tab" + check (fst (TabNavigation.wheelNotches -90 60 false)=0 && snd (TabNavigation.wheelNotches -90 60 false)=60) "Turning back kept the other way's part notch" + check (TabNavigation.wheelNotches -90 -60 true=(-0,-60)) "A pause did not start the notch afresh" + // Notches step on from where scrolling got to, wrapping like switching. + check (TabNavigation.stepTarget [a;b;c] a None 1 true=Some b && TabNavigation.stepTarget [a;b;c] a None 4 true=Some b + && TabNavigation.stepTarget [a;b;c] a None 1 false=Some c && TabNavigation.stepTarget [a;b;c] IntPtr.Zero (Some c) 2 true=Some b) "Scrolling stepped to the wrong tab" + // Every notch of a quick turn is a step; turning back drops the steps still waiting. + check (TabNavigation.addSteps -2 -3 = -5 && TabNavigation.addSteps 0 2 = 2 && TabNavigation.addSteps -4 1 = 1) "Scrolling lost notches of a quick turn, or kept them after turning back" check (TabNavigation.neighbour [a;b;c] b=Some c) "Closing the active middle tab did not select its right neighbor" check (TabNavigation.neighbour [a;b;c] c=Some b) "Closing the last tab did not select its left neighbor" check (TabNavigation.closeTarget [a;b;c] (fun hwnd -> hwnd<>b) b a (Some a) None false=None) "Closing a background tab changed selection" diff --git a/tests/DesktopE2E.cs b/tests/DesktopE2E.cs index 311673d..77ecbb2 100644 --- a/tests/DesktopE2E.cs +++ b/tests/DesktopE2E.cs @@ -36,9 +36,8 @@ static class DesktopE2E [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr hwnd, StringBuilder text, int size); [DllImport("user32.dll")] static extern bool PostMessage(IntPtr hwnd, uint message, IntPtr wparam, IntPtr lparam); [DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr hwnd, uint message, IntPtr wparam, IntPtr lparam); - [DllImport("user32.dll")] static extern int GetMenuItemCount(IntPtr menu); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetMenuString(IntPtr menu, uint item, StringBuilder text, int size, uint flags); - [DllImport("user32.dll")] static extern bool GetMenuItemRect(IntPtr window, IntPtr menu, uint item, out Rect rect); + [DllImport("oleacc.dll")] static extern int AccessibleObjectFromWindow(IntPtr window, uint objectId, ref Guid iid, [MarshalAs(UnmanagedType.IDispatch)] out object accessible); + [DllImport("oleacc.dll")] static extern int AccessibleChildren(Accessibility.IAccessible container, int start, int count, [Out] object[] children, out int obtained); [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, Input[] inputs, int size); [DllImport("user32.dll")] static extern uint GetDpiForWindow(IntPtr hwnd); [DllImport("user32.dll")] static extern uint GetGuiResources(IntPtr process, uint kind); @@ -225,6 +224,32 @@ static async Task SwitchMany(IntPtr strip, List order, int count) CheckGrowth(before,after); return before; } + // A menu item's bounds on screen from the window's MSAA tree, or null when it has none by that name. + static Rect? MenuItemRect(IntPtr window, string name) + { + var iid=new Guid("618736E0-3C3D-11CF-810C-00AA00389B71"); // IAccessible + object found; + if(AccessibleObjectFromWindow(window,0xFFFFFFFC,ref iid,out found)!=0) return null; // OBJID_CLIENT + var menu=found as Accessibility.IAccessible; + if(menu==null) return null; + int count; try { count=menu.accChildCount; } catch(COMException) { return null; } + var children=new object[count]; int obtained; + if(count==0 || AccessibleChildren(menu,0,count,children,out obtained)!=0) return null; + foreach(var child in children.Take(obtained)) + { + var entry=child as Accessibility.IAccessible; + if(entry==null) continue; + try + { + var text=entry.get_accName(0); + if(text==null || text.Replace("&","")!=name) continue; + int left,top,width,height; entry.accLocation(out left,out top,out width,out height,0); + return new Rect{ Left=left, Top=top, Right=left+width, Bottom=top+height }; + } + catch(COMException) { } + } + return null; + } static async Task ExitNormally() { // Exercise the shipped NotifyIcon context-menu Exit handler. This bypasses @@ -232,19 +257,19 @@ static async Task ExitNormally() Check(AllowSetForegroundWindow(app.Id),"Cannot grant the tray-menu foreground permission"); var candidates=Windows(app.Id).Where(h=>!IsWindowVisible(h) && Class(h).StartsWith("WindowsForms10.")).ToList(); foreach(var h in candidates) PostMessage(h,0x800,IntPtr.Zero,new IntPtr(0x205)); - await Until("Tray context menu did not open",()=>Windows(app.Id).Any(h=>Class(h)=="#32768" && IsWindowVisible(h))); + // The menu is a WinForms strip in the app's theme, not a system menu: its items are + // found by name through MSAA, which reaches across processes. + var popup=IntPtr.Zero; var item=new Rect(); + await Until("Tray context menu did not open",()=>{ + foreach(var h in Windows(app.Id).Where(IsWindowVisible)) + { + var found=MenuItemRect(h,"Exit WindowTabs"); + if(found.HasValue) { popup=h; item=found.Value; return true; } + } + return false; + }); await Until("Tray menu did not acquire keyboard focus",()=>Windows(app.Id).Contains(GetForegroundWindow())); - var popup=Windows(app.Id).Single(h=>Class(h)=="#32768" && IsWindowVisible(h)); - var menu=SendMessage(popup,0x1E1,IntPtr.Zero,IntPtr.Zero); // MN_GETHMENU - int exitIndex=-1; - for(int i=0;i=0,"Tray menu contains no Exit command"); - Rect item; Check(GetMenuItemRect(IntPtr.Zero,menu,(uint)exitIndex,out item),"Cannot locate Exit menu item"); + Log("Tray Exit item at "+item.Left+","+item.Top+" "+(item.Right-item.Left)+"x"+(item.Bottom-item.Top)); var point=new Point((item.Left+item.Right)/2,(item.Top+item.Bottom)/2); Check(WindowFromPoint(point)==popup,"Exit menu is occluded"); Move(point); Inject(MouseInput(2)); await Task.Delay(30); Inject(MouseInput(4)); diff --git a/tests/Run-DesktopE2E.ps1 b/tests/Run-DesktopE2E.ps1 index 9819527..4c93487 100644 --- a/tests/Run-DesktopE2E.ps1 +++ b/tests/Run-DesktopE2E.ps1 @@ -39,7 +39,7 @@ if ($ReleaseExecutable) { Copy-Item -LiteralPath $taskE2EExecutable -Destination $stage $compiler = Join-Path $env:WINDIR 'Microsoft.NET/Framework/v4.0.30319/csc.exe' $hostExe = Join-Path $stage 'DesktopE2E.exe' -& $compiler /nologo /target:exe /platform:x86 "/out:$hostExe" /r:System.Drawing.dll /r:System.Windows.Forms.dll /r:System.Web.Extensions.dll "/win32manifest:$repo/WtProgram/app.manifest" (Join-Path $PSScriptRoot 'DesktopE2E.cs') +& $compiler /nologo /target:exe /platform:x86 "/out:$hostExe" /r:System.Drawing.dll /r:System.Windows.Forms.dll /r:System.Web.Extensions.dll /r:Accessibility.dll "/win32manifest:$repo/WtProgram/app.manifest" (Join-Path $PSScriptRoot 'DesktopE2E.cs') if ($LASTEXITCODE -ne 0) { throw 'Desktop E2E compilation failed.' } @{ appSha256=(Get-FileHash -LiteralPath (Join-Path $stage 'WindowTabs.exe')).Hash; profile=$Profile; switches=$Switches; cycles=$Cycles; durationMinutes=$DurationMinutes; createdUtc=[DateTime]::UtcNow.ToString('o') } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $stage 'manifest.json') Write-Host "Desktop E2E artifacts: $stage" diff --git a/tests/SettingsTheme.fsx b/tests/SettingsTheme.fsx index 66822e4..e5357e2 100644 --- a/tests/SettingsTheme.fsx +++ b/tests/SettingsTheme.fsx @@ -99,7 +99,7 @@ let main() = check (api.hotKey("newTab").def(SettingsCatalog.shortcutDefault "newTab")=0x064E) "New tab must retain Ctrl+Alt+N by default" check (api.getValue("showTabsOnSwitch")=box true) "Switching must show auto-hidden tabs by default" check (api.hotKey("searchTabs").def(SettingsCatalog.shortcutDefault "searchTabs")=0x0654) "Search must use Ctrl+Alt+T rather than common launcher shortcuts" - for key in ["enableCtrlNumberHotKey";"enableShiftScroll"] do + for key in ["enableCtrlNumberHotKey";"enableShiftScroll";"enableCtrlScroll"] do check (api.getValue(key)=box true) (sprintf "%s lost its existing default" key) checkMinimalDefaults() check (api.getValue("runAtStartup")=box true) "Fresh installs lost their existing startup default" diff --git a/tests/TabInteraction.fsx b/tests/TabInteraction.fsx index 8145064..981a343 100644 --- a/tests/TabInteraction.fsx +++ b/tests/TabInteraction.fsx @@ -193,7 +193,7 @@ let main() = menu.Close() Application.DoEvents() api.updateAppearance(fun _ -> savedAppearance) - check (SettingsCatalog.shortcutDefault "numberLeader"=0x0453) "Tab selection must default to Alt+S" + check (SettingsCatalog.shortcutDefault "numberLeader"=0x0446) "Tab selection must default to Alt+F" check (Theme.leastUsedColor [0;1;2;0;3]=8) "By-window allocation did not balance colours" check (Theme.tabColorOrder.Head=1 && List.last Theme.tabColorOrder=0 && (Theme.tabColorOrder |> List.sort)=[0..15]) "Colour menu must start with blue, end with grey and retain every stored index" check ((Theme.tabAllocationOrder |> List.sort)=[0..15] && List.last Theme.tabAllocationOrder=0) "Automatic allocation lost a colour or used grey early"