diff --git a/README.md b/README.md index f55222e..3bd981a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ right-drag asks, and **Ctrl + Z** in Explorer undoes it. or below and for moving the focus between panes are under **Settings → Shortcuts**, unset by default. - **Workspaces:** save groups and window positions, then restore them onto open windows; - apps that are not running can be launched and their windows restored. + apps that are not running can be launched and their windows restored. Shortcuts to save + one and to restore the last saved or restored one are under **Settings → Shortcuts**, + unset by default. - **Taskbar:** optionally combine each group into one taskbar button, which shows every app in the group. - **Languages:** English, 中文, 日本語. @@ -86,7 +88,9 @@ To update, exit WindowTabs from the tray and replace the exe. Settings are kept. - **Odd behavior after upgrading?** **Settings → Support → Reset to defaults** (your old settings are backed up first). - **Run as administrator?** Not needed and not recommended. Windows blocks some - input between admin and normal windows. + input between admin and normal windows, and groups of normal windows keep a + taskbar button each even with **Combine taskbar icons** on; the group's tab menu + says why. - **Dropping files on a tab does nothing?** Windows blocks dragging from an app into one running as administrator. diff --git a/WtProgram/GroupPlugins/SuperBarPlugin.fs b/WtProgram/GroupPlugins/SuperBarPlugin.fs index 23254ec..e73f313 100755 --- a/WtProgram/GroupPlugins/SuperBarPlugin.fs +++ b/WtProgram/GroupPlugins/SuperBarPlugin.fs @@ -791,6 +791,7 @@ type SuperBarPlugin(canShareInput:IntPtr -> bool, ?desktopOf:IntPtr -> Guid opti let mutable desktopSubscription : IDisposable option = None let mutable disposed = false let mutable exitCleanedUp = false + let mutable currentCombineState = SingleWindow new() = SuperBarPlugin(fun hwnd -> Services.get().canShareInput hwnd) @@ -832,6 +833,12 @@ type SuperBarPlugin(canShareInput:IntPtr -> bool, ?desktopOf:IntPtr -> Guid opti member _.taskbarVisibilityChanges = taskbarVisibility.changeCount member _.isAwaitingDesktopPlacement = placementRetry.Enabled + member _.combineState = currentCombineState + + /// The tab menu and the diagnostics report say why the group's windows keep their own buttons. + member private this.setCombineState state = + currentCombineState <- state + TaskbarCombineStates.set this.ts.hwnd state /// A window without a taskbar button is on no virtual desktop and shows on all of them; /// a window the taskbar window owns follows that window's desktop instead. @@ -910,11 +917,16 @@ type SuperBarPlugin(canShareInput:IntPtr -> bool, ?desktopOf:IntPtr -> Guid opti let desktop = placementDesktop placementAttempts.Observe(memberSet,desktop,this.canCombine) if not this.canCombine || (desktop.IsNone && placedOwner=IntPtr.Zero) then + this.setCombineState( + if members.count<=1 then SingleWindow + elif not this.canCombine then DifferentPermissions + else DesktopUnknown) this.clearTaskbarButton() members.iter(fun hwnd -> this.showInTaskbar(hwnd,true)) // The asynchronous desktop query publishes completion; no timer polls an unknown answer. placementRetry.Stop() elif placedOwner=IntPtr.Zero && not(placementAttempts.TryBegin()) then + this.setCombineState PlacementFailed members.iter(fun hwnd -> this.showInTaskbar(hwnd,true)) this.retryPlacement() elif members.isEmpty.not then @@ -1003,10 +1015,12 @@ type SuperBarPlugin(canShareInput:IntPtr -> bool, ?desktopOf:IntPtr -> Guid opti active |> Option.iter button.markActive if this.adopt desktop then + this.setCombineState Combined placementRetry.Stop() placementAttempts.Succeeded() members.iter(fun hwnd -> this.showInTaskbar(hwnd,false)) else + this.setCombineState PlacementFailed // Keep each application's desktop and button until the proxy is safely placed. this.clearTaskbarButton() members.iter(fun hwnd -> this.showInTaskbar(hwnd,true)) @@ -1146,6 +1160,7 @@ type SuperBarPlugin(canShareInput:IntPtr -> bool, ?desktopOf:IntPtr -> Guid opti if not exitCleanedUp then exitCleanedUp <- true disposed <- true + TaskbarCombineStates.remove this.ts.hwnd desktopSubscription |> Option.iter(fun subscription -> subscription.Dispose()) desktopSubscription <- None iconSubscription |> Option.iter(fun subscription -> subscription.Dispose()) diff --git a/WtProgram/ManagerViewService/Views/DiagnosticsView.fs b/WtProgram/ManagerViewService/Views/DiagnosticsView.fs index 691daed..b47a93a 100755 --- a/WtProgram/ManagerViewService/Views/DiagnosticsView.fs +++ b/WtProgram/ManagerViewService/Views/DiagnosticsView.fs @@ -112,6 +112,20 @@ type DiagnosticsView() = | SettingsFile.AppData -> "AppData" | SettingsFile.WorkingDirectory -> "working directory")) | _ -> () + // Whether each group's taskbar icons are combined, and if not why: a group's windows can + // keep their own buttons while its tab menu shows combining on. A group of one window has + // nothing to combine. + result.["taskbarGroups"] <- + JArray(groups.list |> List.filter(fun group -> group.windows.count>1) |> List.map(fun group -> + let state = + match TaskbarCombineStates.tryGet group.hwnd with + | Some Combined -> "combined" + | Some SingleWindow -> "single window" + | Some DifferentPermissions -> "not combined: different permissions" + | Some DesktopUnknown -> "not combined: virtual desktop unknown" + | Some PlacementFailed -> "not combined: placement on the virtual desktop failed" + | None -> "off" + JObject(JProperty("windows",group.windows.count),JProperty("taskbar",state)) |> box) |> List.toArray) // The longest part, and optional, comes last. if includeWindows.Checked then result.["windows"] <- windowDetails() result diff --git a/WtProgram/ManagerViewService/Views/HotKeyView.fs b/WtProgram/ManagerViewService/Views/HotKeyView.fs index e76e04d..352f0ca 100755 --- a/WtProgram/ManagerViewService/Views/HotKeyView.fs +++ b/WtProgram/ManagerViewService/Views/HotKeyView.fs @@ -3,11 +3,11 @@ open System.Windows.Forms type HotKeyView() = let panel,table = SettingsUi.page() - // Setting id for each program hotkey, so a conflict can name the other action. - let actions = ["nextTab","next-tab";"prevTab","previous-tab";"searchTabs","search-tabs";"newTab","new-tab";"closeTab","close-tab";"numberLeader","number-leader" - "splitPickRight","split-pick-right";"splitPickBelow","split-pick-below" - "splitFocusLeft","split-focus-left";"splitFocusRight","split-focus-right";"splitFocusUp","split-focus-up";"splitFocusDown","split-focus-down"] + // Setting id for each program hotkey, so a conflict can name the other action. Read from the + // catalog: a list of its own here left new shortcuts off the page. + let actions = SettingsCatalog.all |> List.choose(fun item -> match item.binding with Shortcut(key,_) -> Some(key,item.id) | _ -> None) let isSplit (key:string) = key.StartsWith "split" + let isWorkspace (key:string) = key.EndsWith "Workspace" let hotKey key = let editor = new SettingsShortcutInput(Font=SettingsUi.bodyFont(),Shortcut=Services.program.getHotKey key) editor.Changed.Add(fun _ -> @@ -38,7 +38,7 @@ type HotKeyView() = SettingsUi.add table keyboard SettingsUi.note keyboard (tr Strings.Shortcuts.keyboardNote) for (key,editor),(_,id) in List.zip editors actions do - if key<>"numberLeader" && not(isSplit key) then SettingsUi.settingRow keyboard id editor + if key<>"numberLeader" && not(isSplit key) && not(isWorkspace key) then SettingsUi.settingRow keyboard id editor // Rows inset under a toggle and collapsed while it is off. let dependOn (toggle:SettingsToggle) (rows:SettingsRow list) = for row in rows do SettingsUi.indentDependentRow row @@ -149,6 +149,9 @@ type HotKeyView() = let split = SettingsUi.sectionCard table (tr Strings.Shortcuts.splitView) for (key,editor),(_,id) in List.zip editors actions do if isSplit key then SettingsUi.settingRow split id editor + let workspaces = SettingsUi.sectionCard table (tr Strings.Pages.workspaces) + for (key,editor),(_,id) in List.zip editors actions do + if isWorkspace key then SettingsUi.settingRow workspaces id editor let pointer = SettingsUi.sectionCard table (tr Strings.Shortcuts.mouse) SettingsBindings.toggleRow pointer "activate-on-hover" SettingsBindings.toggleRow pointer "shift-scroll" diff --git a/WtProgram/ManagerViewService/Views/WorkspaceView.fs b/WtProgram/ManagerViewService/Views/WorkspaceView.fs index cc3acb1..1a0b4b0 100755 --- a/WtProgram/ManagerViewService/Views/WorkspaceView.fs +++ b/WtProgram/ManagerViewService/Views/WorkspaceView.fs @@ -241,6 +241,12 @@ type WorkspaceView() as this = if not this.wm.isReadOnly then this.list.RowActions <- [EditGlyph;DeleteGlyph] loaded <- true this.reload() + // A workspace saved by shortcut meanwhile shows here, and is not lost at this page's next save. + let saved = WorkspaceModel.saved.Subscribe(fun source -> + if not (obj.ReferenceEquals(source,this.wm)) then + this.wm.loadSettings() + this.reload()) + panel.Disposed.Add(fun _ -> saved.Dispose()) panel /// Rebuilds the rows from the model in the order saved, keeping expansion and selection. diff --git a/WtProgram/Notice.fs b/WtProgram/Notice.fs index 3342d11..488f0b0 100644 --- a/WtProgram/Notice.fs +++ b/WtProgram/Notice.fs @@ -3,14 +3,38 @@ open System open System.Drawing open System.Windows.Forms -/// A line of text at the top of the main monitor, in the theme's colours, over everything and -/// taking no focus: what WindowTabs is doing to the windows just now, so a user does not pull -/// at them meanwhile. One at a time; it goes on its own after a while. +/// How long a notice stays. Work in progress stays until what follows replaces it; an error stays +/// until it is closed, as it may need doing something about. +type NoticeKind = + | Progress + | Info + /// Done, but not all of it: some windows were not found. + | Reminder + | Error + +/// A card at the bottom right of the main monitor, where Windows shows its own notifications, in +/// the theme's colours, over everything and taking no focus: what WindowTabs is doing to the +/// windows just now, so a user does not pull at them meanwhile. One at a time; it slides in, and +/// out again on its own after a while. Its size is fixed, as an IDE's notifications are, so one +/// replacing another does not jump: the app's icon, a bold title, and the message below it in up +/// to two lines. Under the pointer it shows a close button and does not go. type NoticeWindow() = inherit Form(FormBorderStyle=FormBorderStyle.None,ShowInTaskbar=false,StartPosition=FormStartPosition.Manual,TopMost=true) + let mutable title = "" let mutable text = "" + let mutable kind = Info let mutable font : Font option = None + let mutable titleFont : Font option = None + let mutable icon : Icon option = None + let mutable warning : Icon option = None let hide = new Timer(Interval=4000) + let animation = new Timer(Interval=15) + let clock = Diagnostics.Stopwatch() + let mutable leaving = false + let mutable hovered = false + let mutable closeHot = false + /// Where the notice rests once in. + let mutable target = Rectangle.Empty let mutable wired = false override _.ShowWithoutActivation = true override _.CreateParams = @@ -23,8 +47,56 @@ type NoticeWindow() = // check on every member, which the finalizer's Dispose failed and took the process with it. if not wired then wired <- true - hide.Tick.Add(fun _ -> hide.Stop(); this.Hide()) + hide.Tick.Add(fun _ -> hide.Stop(); this.leave()) + animation.Tick.Add(fun _ -> this.step()) base.OnHandleCreated(e) + /// How long a kind of notice stays on its own, in milliseconds; None until replaced or closed. + static member duration = function + | Info -> Some 4000 + | Reminder -> Some 8000 + | Progress | Error -> None + /// How long this notice stays on its own. + member _.staysFor = NoticeWindow.duration kind + /// Whether it is counting down to going; not while under the pointer. + member _.isCountingDown = hide.Enabled + /// Without the system's animations it comes and goes at once. + member private _.animates = SystemInformation.UIEffectsEnabled + /// How far it slides, from the right. + member private _.slide = Dpi.scale 24 + member private this.step() = + let duration = if leaving then 150.0 else 200.0 + let t = min 1.0 (float clock.ElapsedMilliseconds/duration) + let eased = 1.0-(1.0-t)**3.0 + let shown = if leaving then 1.0-eased else eased + this.Opacity <- shown + this.Location <- Point(target.Left+int(float this.slide*(1.0-shown)),target.Top) + if t>=1.0 then + animation.Stop() + if leaving then + leaving <- false + this.Hide() + member private this.leave() = + hide.Stop() + if not this.Visible then () + elif this.animates then + leaving <- true + clock.Restart() + animation.Start() + else this.Hide() + /// Starts the count to going again, from the whole time, for a notice that goes on its own. + member private this.countDown() = + hide.Stop() + match this.staysFor with + | Some milliseconds when not hovered -> + hide.Interval <- milliseconds + hide.Start() + | _ -> () + /// Gone at once. + member this.hideNow() = + hide.Stop() + animation.Stop() + leaving <- false + this.Hide() member private this.textFont = match font with | Some value -> value @@ -33,29 +105,97 @@ type NoticeWindow() = let value = new Font("Segoe UI",10.5f*scale) font <- Some value value + member private this.boldFont = + match titleFont with + | Some value -> value + | None -> + let value = new Font(this.textFont,FontStyle.Bold) + titleFont <- Some value + value + /// Logical pixels: a title and two lines of message. + static member size = Size(360,80) + member private this.padding = Dpi.scale 16 + member private this.iconSide = Dpi.scale 24 + /// The close button, at the top right; shown under the pointer only. + member this.closeBounds = + let side = Dpi.scale 20 + Rectangle(this.Width-Dpi.scale 8-side,Dpi.scale 8,side,side) + /// The app's icon, or for an error the system's warning sign, at the size it is drawn. + member private this.kindIcon = + let load (cache:Icon option) (read:unit -> Icon) = + match cache with + | Some _ -> cache + | None -> (try Some(new Icon(read(),Size(this.iconSide,this.iconSide))) with _ -> None) + match kind with + | Error -> + warning <- load warning (fun () -> SystemIcons.Warning) + warning + | _ -> + icon <- load icon (fun () -> Services.openIcon(ThemeService.appIconName)) + icon member private this.reshape() = if this.Width>0 && this.Height>0 then use outline = MenuShape.outline this.Width this.Height let previous = this.Region this.Region <- new Region(outline) if not(isNull previous) then previous.Dispose() - /// Shows the text at the top of the main monitor's work area; a later call replaces it. - /// `stay` keeps it until the next call, else it goes after a few seconds. - member this.show(message:string, stay:bool) = + /// Shows the title and message at the bottom right of the main monitor's work area; a later + /// call replaces them. The kind says how long it stays. + member this.show(heading:string, message:string, noticeKind:NoticeKind) = + title <- heading text <- message + kind <- noticeKind let palette = SettingsColors.current() this.BackColor <- palette.surface - let size = TextRenderer.MeasureText(text,this.textFont,Size(Int32.MaxValue,Int32.MaxValue),TextFormatFlags.NoPrefix) - let padding = Dpi.scale 18 - let bounds = Size(size.Width+2*padding,size.Height+Dpi.scale 16) + let bounds = Size(Dpi.scale NoticeWindow.size.Width,Dpi.scale NoticeWindow.size.Height) let area = Screen.PrimaryScreen.WorkingArea - this.Bounds <- Rectangle(area.Left+(area.Width-bounds.Width)/2,area.Top+Dpi.scale 12,bounds.Width,bounds.Height) - this.reshape() - if not this.Visible then this.Show() + let margin = Dpi.scale 12 + target <- Rectangle(area.Right-margin-bounds.Width,area.Bottom-margin-bounds.Height,bounds.Width,bounds.Height) + // A notice on its way out comes back in rather than going with the new text. + let entering = not this.Visible || leaving + leaving <- false + if entering && this.animates then + this.Opacity <- 0.0 + this.Bounds <- Rectangle(target.Left+this.slide,target.Top,target.Width,target.Height) + this.reshape() + if not this.Visible then this.Show() + clock.Restart() + animation.Start() + else + // Still sliding in, it keeps on towards the new place. + if not animation.Enabled then + this.Opacity <- 1.0 + this.Bounds <- target + else this.Size <- target.Size + this.reshape() + if not this.Visible then this.Show() this.Invalidate() this.Update() - hide.Stop() - if not stay then hide.Start() + this.countDown() + override this.OnMouseEnter(e) = + base.OnMouseEnter(e) + if not leaving then + hovered <- true + hide.Stop() + this.Invalidate() + override this.OnMouseMove(e) = + base.OnMouseMove(e) + let hot = this.closeBounds.Contains(e.Location) + if hot<>closeHot then + closeHot <- hot + this.Invalidate() + /// The count starts over once the pointer leaves, so what was being read is not cut short. + override this.OnMouseLeave(e) = + base.OnMouseLeave(e) + hovered <- false + closeHot <- false + this.Invalidate() + if this.Visible && not leaving then this.countDown() + override this.OnMouseUp(e) = + base.OnMouseUp(e) + if e.Button=MouseButtons.Left && this.closeBounds.Contains(e.Location) then + hovered <- false + this.leave() override this.OnPaint(e) = base.OnPaint(e) let palette = SettingsColors.current() @@ -67,13 +207,47 @@ type NoticeWindow() = use pen = new Pen(palette.border) g.DrawPath(pen,outline) g.Restore(state) - TextRenderer.DrawText(g,text,this.textFont,this.ClientRectangle,palette.text, - TextFormatFlags.HorizontalCenter ||| TextFormatFlags.VerticalCenter ||| TextFormatFlags.NoPrefix) + let padding = this.padding + let iconSide = this.iconSide + this.kindIcon |> Option.iter(fun icon -> + g.DrawIcon(icon,Rectangle(padding,(this.Height-iconSide)/2,iconSide,iconSide))) + // Under the pointer, a close button: a cross on a lit square while the pointer is on it. + let close = this.closeBounds + if hovered then + if closeHot then + use lit = new SolidBrush(palette.hover) + g.FillRectangle(lit,close) + let arm = float32(Dpi.scale 4) + let cx,cy = float32 close.X+float32 close.Width/2.0f,float32 close.Y+float32 close.Height/2.0f + use cross = new Pen((if closeHot then palette.text else palette.muted),float32(max 1 (Dpi.scale 1))) + g.DrawLine(cross,cx-arm,cy-arm,cx+arm,cy+arm) + g.DrawLine(cross,cx-arm,cy+arm,cx+arm,cy-arm) + // Wrapped text cannot be centred by its flags, so the title and message are measured and + // centred together; the title keeps to one line and the message to two, then an ellipsis. + // The close button keeps its column free whether it shows or not, so the text never moves. + let left = padding+iconSide+Dpi.scale 12 + let width = close.Left-Dpi.scale 4-left + let flags = TextFormatFlags.Left ||| TextFormatFlags.WordBreak ||| TextFormatFlags.EndEllipsis ||| TextFormatFlags.NoPrefix + let lineHeight = TextRenderer.MeasureText("Xg",this.textFont,Size.Empty,TextFormatFlags.NoPrefix).Height + let titleHeight = TextRenderer.MeasureText("Xg",this.boldFont,Size.Empty,TextFormatFlags.NoPrefix).Height + let gap = Dpi.scale 2 + let measured = TextRenderer.MeasureText(text,this.textFont,Size(width,Int32.MaxValue),flags).Height + let bodyHeight = min measured (2*lineHeight) + let top = (this.Height-(titleHeight+gap+bodyHeight))/2 + TextRenderer.DrawText(g,title,this.boldFont,Rectangle(left,top,width,titleHeight),palette.text, + TextFormatFlags.Left ||| TextFormatFlags.SingleLine ||| TextFormatFlags.EndEllipsis ||| TextFormatFlags.NoPrefix) + TextRenderer.DrawText(g,text,this.textFont,Rectangle(left,top+titleHeight+gap,width,bodyHeight),palette.text,flags) override this.Dispose(disposing) = if disposing then hide.Dispose() + animation.Dispose() + for cached in [icon;warning] do cached |> Option.iter(fun value -> value.Dispose()) + icon <- None + warning <- None font |> Option.iter(fun value -> value.Dispose()) font <- None + titleFont |> Option.iter(fun value -> value.Dispose()) + titleFont <- None this.Region |> Option.ofObj |> Option.iter(fun region -> region.Dispose()) base.Dispose(disposing) @@ -89,7 +263,13 @@ module Notice = value /// A notice that cannot be shown is no reason to stop what it was about. let private attempt action = try action() with error -> Diagnostics.Trace.TraceWarning("Notice failed: {0}",error.Message) - let show (message:string) = attempt (fun () -> (current()).show(message,false)) - /// Stays until the next notice. - let hold (message:string) = attempt (fun () -> (current()).show(message,true)) - let hideNow() = attempt (fun () -> window |> Option.iter(fun value -> if not value.IsDisposed then value.Hide())) + let private post kind (title:string) (message:string) = attempt (fun () -> (current()).show(title,message,kind)) + /// Goes after a few seconds. + let show title message = post Info title message + /// Done, but not all of it; stays a little longer. + let remind title message = post Reminder title message + /// Stays until closed. + let error title message = post Error title message + /// Work in progress; stays until the next notice. + let hold title message = post Progress title message + let hideNow() = attempt (fun () -> window |> Option.iter(fun value -> if not value.IsDisposed then value.hideNow())) diff --git a/WtProgram/Program.fs b/WtProgram/Program.fs index 8f9c451..e858f65 100755 --- a/WtProgram/Program.fs +++ b/WtProgram/Program.fs @@ -102,6 +102,8 @@ type Program(lifetime:LifetimeScope, ?settings:Settings) as this = ("splitFocusRight", fun () -> Services.desktop.foregroundGroup.iter(fun g -> g.executeSplit(FocusToward PaneRight))) ("splitFocusUp", fun () -> Services.desktop.foregroundGroup.iter(fun g -> g.executeSplit(FocusToward PaneUp))) ("splitFocusDown", fun () -> Services.desktop.foregroundGroup.iter(fun g -> g.executeSplit(FocusToward PaneDown))) + ("saveWorkspace", fun () -> this.saveWorkspace()) + ("restoreWorkspace", fun () -> this.restoreWorkspace()) ])) let hotKeyManager = lifetime.Own(new HotKeyManager()) @@ -263,22 +265,23 @@ type Program(lifetime:LifetimeScope, ?settings:Settings) as this = | None -> sessionReady <- true | Some _ -> // Said on screen first, so a user leaves the windows alone while they move. - Notice.hold (tr Strings.Session.restoring) + let title = tr Strings.Notices.lastSession + Notice.hold title (tr Strings.Notices.restoring) let go = new System.Windows.Forms.Timer(Interval=50) go.Tick.Add(fun _ -> go.Stop() go.Dispose() try sessions.restoreSession((mode="Apps"), - (fun apps -> Notice.hold (tr (Strings.Session.launching apps))), + (fun apps -> Notice.hold title (tr (Strings.Notices.launching apps))), fun result -> sessionReady <- true sessionSoon.Request() - Notice.show (tr (Strings.Session.restored result.restored result.missing.Length)) + this.noticeRestored title result Diagnostics.Trace.TraceInformation("Session restored: {0} windows, {1} not found, {2} failed",result.restored,result.missing.Length,result.failed.Length)) with error -> sessionReady <- true - Notice.hideNow() + Notice.error title (tr (Strings.Notices.restoreFailed error.Message)) Diagnostics.Trace.TraceWarning("Session restore failed: {0}",error.Message)) go.Start()) timer.Start() @@ -684,6 +687,51 @@ type Program(lifetime:LifetimeScope, ?settings:Settings) as this = tabSearchCell.set(Some(search)) search.Show() + /// The saved workspaces as Settings last wrote them, which an open Settings page may have changed. + member private this.readWorkspaces() = + let workspaces = WorkspaceModel() + workspaces.loadSettings() + workspaces + + /// What a restore did; windows left out keep it up longer, to be read. + member private this.noticeRestored title (result:WorkspaceRestoreResult) = + let message = tr (Strings.Notices.restored result.restored result.missing.Length) + if result.missing.IsEmpty && result.failed.IsEmpty then Notice.show title message + else Notice.remind title message + + /// As Save layout in Settings, said on screen since nothing else shows it. + member this.saveWorkspace() = + let workspaces = this.readWorkspaces() + let title = tr Strings.Pages.workspaces + if workspaces.isReadOnly then Notice.error title (tr Strings.Workspaces.unsupportedVersion) + else + try + workspaces.create(fun ws -> + let windows = ws.groups.list |> List.sumBy(fun group -> group.cast().windows.count) + Notice.show ws.name (tr (Strings.Notices.saved windows))) + with error -> + Notice.error title (tr (Strings.Notices.saveFailed error.Message)) + Diagnostics.Trace.TraceWarning("Workspace save failed: {0}",error.Message) + + /// Brings back the workspace last saved or restored, with the result on screen rather than in a dialog. + member this.restoreWorkspace() = + let workspaces = this.readWorkspaces() + match workspaces.lastUsed with + | None -> Notice.show (tr Strings.Pages.workspaces) (tr Strings.Workspaces.noneSaved) + | Some ws -> + // Said first, and painted before the windows move, so a user leaves them alone meanwhile. + Notice.hold ws.name (tr Strings.Notices.restoring) + let go = new System.Windows.Forms.Timer(Interval=50) + go.Tick.Add(fun _ -> + go.Stop() + go.Dispose() + try + workspaces.restoreLast() |> Option.iter(fun result -> this.noticeRestored result.workspace result) + with error -> + Notice.error ws.name (tr (Strings.Notices.restoreFailed error.Message)) + Diagnostics.Trace.TraceWarning("Workspace restore failed: {0}",error.Message)) + go.Start() + /// A shortcut another program already holds stays unregistered, without a message at every /// start; choosing it again in Settings says it is in use. member this.registerHotKeys() = diff --git a/WtProgram/Shared/ProgramTypes.fs b/WtProgram/Shared/ProgramTypes.fs index fced937..154d4f4 100755 --- a/WtProgram/Shared/ProgramTypes.fs +++ b/WtProgram/Shared/ProgramTypes.fs @@ -149,6 +149,27 @@ type IProgram = /// Windows an empty pane can take, on this desktop: handle, tab name and program. abstract member paneCandidates : unit -> (IntPtr * string * string) list +/// Whether a group set to combine its taskbar icons has its one button, and if not, why its +/// windows keep their own: else the setting looked on while nothing was combined. +type TaskbarCombineState = + | Combined + | SingleWindow + /// WindowTabs and a window run with different rights, such as one as administrator. + | DifferentPermissions + /// Windows has not told which virtual desktop the windows are on. + | DesktopUnknown + /// Windows did not let the group's button move to the windows' virtual desktop. + | PlacementFailed + +/// Each group's state by its strip's handle, written on the group's thread and read from any. +module TaskbarCombineStates = + let private states = Collections.Concurrent.ConcurrentDictionary() + let set (group:IntPtr) state = states.[group] <- state + let tryGet (group:IntPtr) = match states.TryGetValue group with | true,state -> Some state | _ -> None + let remove (group:IntPtr) = states.TryRemove(group) |> ignore + /// Not combined although the group should be, for a reason the user can act on. + let isBlocked = function DifferentPermissions | DesktopUnknown | PlacementFailed -> true | Combined | SingleWindow -> false + type IGroup = abstract member hwnd : IntPtr abstract member addWindow: IntPtr * bool -> unit diff --git a/WtProgram/Shared/SettingsCatalog.fs b/WtProgram/Shared/SettingsCatalog.fs index 85e9d61..49e9bea 100644 --- a/WtProgram/Shared/SettingsCatalog.fs +++ b/WtProgram/Shared/SettingsCatalog.fs @@ -57,6 +57,9 @@ module SettingsCatalog = { id="split-focus-right"; page=HotKeySettings; text=Strings.Settings.splitFocusRight; binding=Shortcut("splitFocusRight",0) } { id="split-focus-up"; page=HotKeySettings; text=Strings.Settings.splitFocusUp; binding=Shortcut("splitFocusUp",0) } { id="split-focus-down"; page=HotKeySettings; text=Strings.Settings.splitFocusDown; binding=Shortcut("splitFocusDown",0) } + // Unbound too, for the same reason. + { id="save-workspace"; page=HotKeySettings; text=Strings.Settings.saveWorkspace; binding=Shortcut("saveWorkspace",0) } + { id="restore-workspace"; page=HotKeySettings; text=Strings.Settings.restoreWorkspace; binding=Shortcut("restoreWorkspace",0) } { 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) } diff --git a/WtProgram/Shared/Strings.fs b/WtProgram/Shared/Strings.fs index 50570ca..139b3af 100644 --- a/WtProgram/Shared/Strings.fs +++ b/WtProgram/Shared/Strings.fs @@ -82,6 +82,19 @@ module Strings = let restoreTabName = { en="Reset name"; zh="重置名称"; ja="名前をリセット"; ar="إعادة تعيين الاسم" } let sortTabs = { en="Sort by app and title"; zh="按应用和标题排序"; ja="アプリとタイトル順に並べ替え"; ar="ترتيب حسب التطبيق والعنوان" } let combineTaskbarIcons = { en="Combine taskbar icons"; zh="合并任务栏图标"; ja="タスクバーアイコンをまとめる"; ar="دمج أيقونات شريط المهام" } + /// Shown greyed out under "Combine taskbar icons" while the group's windows keep their own buttons. + let combineBlockedPermissions = { en="Not combined: WindowTabs and these windows run with different rights (one as administrator)" + zh="未合并:WindowTabs 与这些窗口的权限不同(其中一方以管理员身份运行)" + ja="まとめられていません: WindowTabs とこれらのウィンドウの権限が異なります (一方が管理者として実行中)" + ar="لم يتم الدمج: يعمل WindowTabs وهذه النوافذ بصلاحيات مختلفة (أحدهما كمسؤول)" } + let combineBlockedDesktop = { en="Not combined: Windows has not told which virtual desktop these windows are on" + zh="未合并:Windows 未提供这些窗口所在的虚拟桌面" + ja="まとめられていません: これらのウィンドウがどの仮想デスクトップにあるか Windows から取得できません" + ar="لم يتم الدمج: لم يُحدّد Windows سطح المكتب الافتراضي الذي توجد عليه هذه النوافذ" } + let combineBlockedPlacement = { en="Not combined: Windows did not let the taskbar button move to this virtual desktop" + zh="未合并:Windows 未允许将任务栏按钮移到此虚拟桌面" + ja="まとめられていません: Windows がタスクバー ボタンをこの仮想デスクトップへ移動できませんでした" + ar="لم يتم الدمج: لم يسمح Windows بنقل زر شريط المهام إلى سطح المكتب الافتراضي هذا" } /// For the group's own taskbar button, shown while its icons are combined. let changeTaskbarIcon = { en="Change taskbar icon…"; zh="更换任务栏图标…"; ja="タスクバーアイコンを変更…"; ar="تغيير أيقونة شريط المهام…" } let restoreTaskbarIcon = { en="Restore taskbar icon"; zh="恢复默认任务栏图标"; ja="タスクバーアイコンを元に戻す"; ar="استعادة أيقونة شريط المهام" } @@ -152,10 +165,11 @@ module Strings = description={ en="Runs in the system tray after you sign in."; zh="登录后在系统托盘中运行。"; ja="サインイン後にシステムトレイで実行します。"; ar="يعمل في منطقة الإعلام بعد تسجيل الدخول." } keywords={ en="startup autostart"; zh="开机启动 自启动"; ja="スタートアップ 自動起動"; ar="بدء التشغيل التشغيل التلقائي" } } let restoreSession = { caption={ en="Restore the last session after sign-in"; zh="登录后恢复上次会话"; ja="サインイン後に前回のセッションを復元"; ar="استعادة الجلسة الأخيرة بعد تسجيل الدخول" } - description={ en="Puts open windows back into the groups, positions and split views they had, optionally launching missing apps first." - zh="把已打开的窗口放回原来的分组、位置和分屏,可选先启动缺少的应用。" - ja="開いているウィンドウを元のグループ、位置、分割表示に戻します。足りないアプリを先に起動することもできます。" - ar="يعيد النوافذ المفتوحة إلى مجموعاتها ومواضعها وعروضها المقسّمة السابقة، مع تشغيل التطبيقات الناقصة أولًا اختياريًا." } + // One line beside the choice, which says whether apps are started. + description={ en="Puts windows back into their groups, places and splits." + zh="将窗口放回原来的分组、位置和分屏。" + ja="ウィンドウを元のグループ、位置、分割に戻します。" + ar="يعيد النوافذ إلى مجموعاتها ومواضعها وتقسيماتها." } keywords={ en="session reboot"; zh="会话 重启"; ja="セッション 再起動"; ar="جلسة إعادة التشغيل" } } let enableTabsByDefault = { caption={ en="Use tabs for new apps"; zh="新应用自动启用标签"; ja="新しいアプリでタブを使う"; ar="استخدام علامات التبويب للتطبيقات الجديدة" } description={ en="Only apps first discovered after this change. Existing apps keep their tab setting."; zh="只影响此后首次出现的应用。已有应用保留原来的标签设置。"; ja="変更後に初めて検出するアプリに適用します。既存のアプリのタブ設定は維持します。"; ar="ينطبق ذلك على التطبيقات التي يكتشفها البرنامج لأول مرة بعد هذا التغيير فقط. وتحتفظ التطبيقات الموجودة بإعداد علامات التبويب الحالي." } @@ -212,6 +226,19 @@ module Strings = ja="前面のタブのウィンドウを閉じます。" ar="يغلق نافذة علامة التبويب الأمامية." } keywords={ en="close window"; zh="关闭窗口"; ja="ウィンドウを閉じる"; ar="إغلاق النافذة" } } + let private workspaceWords = { en="workspace layout"; zh="工作区 布局"; ja="ワークスペース 配置"; ar="مساحة العمل التخطيط" } + let saveWorkspace = { caption={ en="Save workspace"; zh="保存工作区"; ja="ワークスペースを保存"; ar="حفظ مساحة العمل" } + description={ en="Saves the windows as they are as a new workspace, as Save layout does." + zh="将当前窗口保存为新的工作区,与“保存布局”相同。" + ja="現在のウィンドウを新しいワークスペースとして保存します(「配置を保存」と同じ)。" + ar="يحفظ النوافذ بحالتها الحالية كمساحة عمل جديدة، كما يفعل «حفظ التخطيط»." } + keywords=workspaceWords } + let restoreWorkspace = { caption={ en="Restore workspace"; zh="恢复工作区"; ja="ワークスペースを復元"; ar="استعادة مساحة العمل" } + description={ en="Restores the workspace last saved or restored." + zh="恢复最近一次保存或恢复的工作区。" + ja="最後に保存または復元したワークスペースを復元します。" + ar="يستعيد آخر مساحة عمل تم حفظها أو استعادتها." } + keywords=workspaceWords } let private splitWords = { en="split view pane"; zh="分屏 窗格"; ja="分割 ペイン"; ar="عرض مقسّم جزء" } let splitPickRight = { caption={ en="Window on the right…"; zh="在右侧放入窗口…"; ja="右側にウィンドウを配置…"; ar="نافذة على اليمين…" } description={ en="Lists the windows to put beside the tab in front."; zh="列出可放到当前标签旁边的窗口。"; ja="前面のタブの隣に置くウィンドウを一覧表示します。"; ar="يعرض النوافذ التي يمكن وضعها بجانب علامة التبويب الأمامية." } @@ -340,24 +367,38 @@ module Strings = keywords={ en="factory"; zh="恢复默认 出厂"; ja="初期化"; ar="إعدادات المصنع" } } /// What letting go of a dragged tab here would do, on the preview. - module Session = - let restoring = { en="Restoring the last session…"; zh="正在恢复上次会话…"; ja="前回のセッションを復元しています…"; ar="جارٍ استعادة الجلسة الأخيرة…" } + /// Notices: a bold title naming what is restored or saved (the last session, or a workspace + /// by its name), and below it what is happening to it. + module Notices = + let lastSession = { en="Last session"; zh="上次会话"; ja="前回のセッション"; ar="الجلسة الأخيرة" } + let restoring = { en="Restoring…"; zh="正在恢复…"; ja="復元しています…"; ar="جارٍ الاستعادة…" } let launching count = - { en=(if count=1 then "Starting an app for the last session…" else sprintf "Starting %d apps for the last session…" count) - zh=sprintf "正在为上次会话启动 %d 个应用…" count - ja=sprintf "前回のセッションのためにアプリを %d 個起動しています…" count - ar=sprintf "جارٍ تشغيل %d من التطبيقات للجلسة الأخيرة…" count } + { en=(if count=1 then "Starting an app…" else sprintf "Starting %d apps…" count) + zh=sprintf "正在启动 %d 个应用…" count + ja=sprintf "アプリを %d 個起動しています…" count + ar=sprintf "جارٍ تشغيل %d من التطبيقات…" count } let restored restored missing = - { en=(sprintf "Session restored: %d windows" restored)+(if missing=0 then "" else sprintf ", %d not found" missing) - zh=(sprintf "已恢复上次会话:%d 个窗口" restored)+(if missing=0 then "" else sprintf ",%d 个未找到" missing) - ja=(sprintf "前回のセッションを復元しました: %d 個のウィンドウ" restored)+(if missing=0 then "" else sprintf "、%d 個が見つかりません" missing) - ar=(sprintf "تمت استعادة الجلسة: %d نافذة" restored)+(if missing=0 then "" else sprintf "، %d غير موجودة" missing) } + { en=(if restored=1 then "Restored 1 window" else sprintf "Restored %d windows" restored)+(if missing=0 then "" else sprintf ", %d not found" missing) + zh=(sprintf "已恢复 %d 个窗口" restored)+(if missing=0 then "" else sprintf ",%d 个未找到" missing) + ja=(sprintf "%d 個のウィンドウを復元しました" restored)+(if missing=0 then "" else sprintf "(%d 個は見つかりません)" missing) + ar=(sprintf "تمت استعادة %d نافذة" restored)+(if missing=0 then "" else sprintf "، %d غير موجودة" missing) } + /// An error notice, with the reason the system gave. + let restoreFailed reason = + { en=sprintf "Could not restore. %s" reason; zh=sprintf "恢复失败。%s" reason; ja=sprintf "復元できませんでした。%s" reason; ar=sprintf "تعذّرت الاستعادة. %s" reason } + let saveFailed reason = + { en=sprintf "Could not save. %s" reason; zh=sprintf "保存失败。%s" reason; ja=sprintf "保存できませんでした。%s" reason; ar=sprintf "تعذّر الحفظ. %s" reason } + let saved windows = + { en=(if windows=1 then "Saved with 1 window" else sprintf "Saved with %d windows" windows) + zh=sprintf "已保存,共 %d 个窗口" windows + ja=sprintf "保存しました(ウィンドウ %d 個)" windows + ar=sprintf "تم الحفظ (%d نافذة)" windows } module General = /// The choices of Restore the last session at start. let sessionOff = { en="Off"; zh="关闭"; ja="オフ"; ar="إيقاف" } - let sessionWindows = { en="Open windows only"; zh="仅已打开的窗口"; ja="開いているウィンドウのみ"; ar="النوافذ المفتوحة فقط" } - let sessionApps = { en="Open windows, and start their apps"; zh="已打开的窗口,并启动缺少的应用"; ja="開いているウィンドウと、アプリの起動"; ar="النوافذ المفتوحة، مع تشغيل تطبيقاتها" } + /// Short, so the choice leaves its row's description room for one line. + let sessionWindows = { en="Windows only"; zh="仅窗口"; ja="ウィンドウのみ"; ar="النوافذ فقط" } + let sessionApps = { en="Windows and missing apps"; zh="窗口和缺少的应用"; ja="ウィンドウと不足アプリ"; ar="النوافذ والتطبيقات الناقصة" } let settingsFile = { en="Settings file"; zh="设置文件"; ja="設定ファイル"; ar="ملف الإعدادات" } let openFolder = { en="Open folder"; zh="打开文件夹"; ja="フォルダーを開く"; ar="فتح المجلد" } let export = { en="Export…"; zh="导出…"; ja="エクスポート…"; ar="تصدير…" } @@ -564,6 +605,7 @@ module Strings = zh=sprintf "正在启动 %d 个应用…" count ja=sprintf "%d 個のアプリを起動中…" count ar=(if count=1 then "جارٍ تشغيل التطبيق…" else sprintf "جارٍ تشغيل التطبيقات (%d)…" count) } + let noneSaved = { en="No workspace saved yet"; zh="尚未保存任何工作区"; ja="保存されたワークスペースはまだありません"; ar="لم يتم حفظ أي مساحة عمل بعد" } let invalidSetting = { en="Invalid workspace setting"; zh="工作区设置无效"; ja="ワークスペースの設定が無効です"; ar="إعداد مساحة العمل غير صالح" } let dataWarnings = { en="Workspace data"; zh="工作区数据"; ja="ワークスペースのデータ"; ar="بيانات مساحة العمل" } let titleInvalid = { en="Window title is missing or exceeds 4,096 characters."; zh="窗口标题缺失或超过 4,096 个字符。"; ja="ウィンドウのタイトルがないか、4,096 文字を超えています。"; ar="عنوان النافذة مفقود أو يتجاوز 4096 حرفًا." } diff --git a/WtProgram/Shared/ThemedMenu.fs b/WtProgram/Shared/ThemedMenu.fs index 711ea16..2b05467 100644 --- a/WtProgram/Shared/ThemedMenu.fs +++ b/WtProgram/Shared/ThemedMenu.fs @@ -38,12 +38,16 @@ type ThemeMenuRenderer(p:SettingsPalette) = e.Graphics.SmoothingMode <- smoothing override _.OnRenderMenuItemBackground(e) = fill e.Graphics (if e.Item.Selected && e.Item.Enabled then p.selection else p.surface) (Rectangle(Point.Empty,e.Item.Size)) + /// Marks a note's item, drawn inset under the item it is about. + static member noteTag = box "note" + static member noteInset = Dpi.scale 14 override _.OnRenderItemText(e) = - e.TextColor <- textColor e.Item + let isNote = obj.ReferenceEquals(e.Item.Tag,ThemeMenuRenderer.noteTag) + e.TextColor <- if isNote && not SystemInformation.HighContrast then p.muted else textColor e.Item // Keep the shortcut column fixed while bringing every label closer to its glyph. if e.TextFormat &&& TextFormatFlags.Right = enum(0) then let r = e.TextRectangle - let inset = Dpi.scale 6 + let inset = Dpi.scale 6 - (if isNote then ThemeMenuRenderer.noteInset else 0) e.TextRectangle <- Rectangle(r.X-inset,r.Y,r.Width+inset,r.Height) base.OnRenderItemText(e) override _.OnRenderArrow(e) = @@ -152,12 +156,17 @@ type ThemedContextMenu(items:List2,closed:unit -> unit,?ownsFor | CmiSection text -> target.Add(new ToolStripSeparator(Text=text,AccessibleName=text,Font=sectionFont,AutoSize=false, Height=sectionFont.Height+Dpi.scale 8,Margin=Padding.Empty)) |> ignore + | CmiNote text -> + // Smaller and inset, under the item it is about; the right padding keeps room for the inset. + let note = new ToolStripMenuItem(text,Font=sectionFont,Enabled=false,Tag=ThemeMenuRenderer.noteTag, + Padding=Padding(0,0,ThemeMenuRenderer.noteInset,0)) + target.Add(note) |> ignore | _ -> let text,image,flags,children,click = match item with | CmiRegular value -> value.text,value.image,value.flags.list,[],Some value.click | CmiPopUp value -> value.text,value.image,[],value.items.list,None - | CmiSeparator | CmiSection _ -> failwith "Unexpected separator" + | CmiSeparator | CmiSection _ | CmiNote _ -> failwith "Unexpected separator" let parts = text.Split([|'\t'|],2) let entry = new ToolStripMenuItem(parts.[0]) target.Add(entry) |> ignore diff --git a/WtProgram/Shared/Win32.fs b/WtProgram/Shared/Win32.fs index 4cac209..37dfd1d 100755 --- a/WtProgram/Shared/Win32.fs +++ b/WtProgram/Shared/Win32.fs @@ -223,9 +223,11 @@ type OS()= WinUserApi.EndDeferWindowPos(!hdswp).ignore - member this.setWinEventHook(min:WinEvent, max:WinEvent, proc, pid, tid) = + /// Only events about windows themselves, unless `anyObject`. + member this.setWinEventHook(min:WinEvent, max:WinEvent, proc, pid, tid, ?anyObject:bool) = + let anyObject = defaultArg anyObject false let proc hWinEventHook event hwnd idObject idChild dwEventThread dwmsEventTime = - if idObject = IntPtr.Zero then + if anyObject || idObject = IntPtr.Zero then proc hWinEventHook event hwnd idObject idChild dwEventThread dwmsEventTime let del = WINEVENTPROC(proc) @@ -257,6 +259,13 @@ type OS()= proc(hwnd) this.setWinEventHook(event, event, proc, 0, 0) + /// Any change in the order of top-level windows. Windows reports it on the desktop window, as + /// a change to its client area's children, which the window-only filter would drop. + member this.setTopLevelOrderEvent (proc:unit -> unit) = + let desktop = WinUserApi.GetDesktopWindow() + let proc _ _ hwnd _ _ _ _ = if hwnd=desktop then proc() + this.setWinEventHook(WinEvent.EVENT_OBJECT_REORDER, WinEvent.EVENT_OBJECT_REORDER, proc, 0, 0, anyObject=true) + and Window(hwnd:nativeint, os:OS) = let Cell = CellScope() diff --git a/WtProgram/Shared/Win32Menu.fs b/WtProgram/Shared/Win32Menu.fs index 558e642..1c0eb5b 100755 --- a/WtProgram/Shared/Win32Menu.fs +++ b/WtProgram/Shared/Win32Menu.fs @@ -6,6 +6,8 @@ type ContextMenuItem = | CmiRegular of CmiRegular | CmiSeparator | CmiSection of string + /// A note about the item above it, inset under it so it does not read as one more command. + | CmiNote of string | CmiPopUp of CmiPopUp and CmiRegular = { @@ -87,6 +89,8 @@ type NativeContextMenu(items:List2) = imageAt position (item.flags.list |> List.exists(fun flag -> flag &&& MenuFlags.MF_CHECKED <> 0)) item.image | CmiSeparator -> WinUserApi.AppendMenu(menu,MenuFlags.MF_SEPARATOR,0,"") |> ignore | CmiSection text -> WinUserApi.AppendMenu(menu,MenuFlags.MF_STRING ||| MenuFlags.MF_GRAYED,0,text) |> ignore + // A native menu draws no inset of its own. + | CmiNote text -> WinUserApi.AppendMenu(menu,MenuFlags.MF_STRING ||| MenuFlags.MF_GRAYED,0," "+text) |> ignore | CmiPopUp item -> let child = create item.items.list if WinUserApi.AppendMenu(menu,MenuFlags.MF_POPUP,int child,item.text) then roots.Remove(child) |> ignore diff --git a/WtProgram/TabStripDecorator.fs b/WtProgram/TabStripDecorator.fs index 8dcb269..2bcda04 100755 --- a/WtProgram/TabStripDecorator.fs +++ b/WtProgram/TabStripDecorator.fs @@ -500,7 +500,18 @@ type TabStripDecorator(group:WindowGroup) as this = click = fun() -> Services.desktop.restartGroup(group.hwnd, group.isSuperBarEnabled.not) flags = checkedFlag(group.isSuperBarEnabled) }) - + // A tick alone read as combined while each window kept its own button (#84). + let combineBlockedItem = + if not group.isSuperBarEnabled then None + else + TaskbarCombineStates.tryGet group.hwnd |> Option.filter TaskbarCombineStates.isBlocked |> Option.map(fun state -> + let text = + match state with + | DifferentPermissions -> Strings.TabMenu.combineBlockedPermissions + | DesktopUnknown -> Strings.TabMenu.combineBlockedDesktop + | _ -> Strings.TabMenu.combineBlockedPlacement + CmiNote(tr text)) + // Only a group with combined icons has a taskbar button of its own to change. let customIcon = if group.isSuperBarEnabled then Services.program.groupIcon hwnd else None let changeIconItem = @@ -652,6 +663,7 @@ type TabStripDecorator(group:WindowGroup) as this = Some(CmiSeparator) Some(sortTabsItem) Some(combineIconsInTaskbar) + combineBlockedItem (if group.isSuperBarEnabled then Some(changeIconItem) else None) (if customIcon.IsSome then Some(restoreIconItem) else None) Some(CmiSeparator) diff --git a/WtProgram/WindowGroup.fs b/WtProgram/WindowGroup.fs index 3dd76fc..2307b29 100755 --- a/WtProgram/WindowGroup.fs +++ b/WtProgram/WindowGroup.fs @@ -279,8 +279,15 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: /// Collapsing to one window: the top window's bounds are not the frame until placement ends. let mutable splitMerging = false let splitSettleTimer = new System.Windows.Forms.Timer(Interval=100) - /// Looks, while split, for a pane raised over the tabs with no notice to the group. - let splitGuardTimer = new System.Windows.Forms.Timer(Interval=200) + /// Told of every change in the order of top-level windows, while split or while the strip is + /// owned by nothing: a window raised over the tabs gives the group no other notice. + let mutable reorderHook : IDisposable option = None + /// Looks, while split, for a pane raised over the tabs; once for a burst of order changes, + /// a split's own transitions making many. + let mutable splitGuardSoon : CoalescedAction option = None + /// When the strip owned by nothing was last put back over its window, on transitionClock. + let mutable lastStripLift = Int64.MinValue/2L + let mutable stripLiftSoon : CoalescedAction option = None /// One divider per split and one window per empty pane, by their place in the layout. let mutable splitDividers : Map = Map.empty let mutable emptySplitPanes : Map = Map.empty @@ -462,7 +469,8 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: member this.init(ts:TabStrip) = _ts := Some(ts) splitSettleTimer.Tick.Add(fun _ -> this.checkSplitSettled()) - splitGuardTimer.Tick.Add(fun _ -> this.guardSplitStrip()) + splitGuardSoon <- Some(new CoalescedAction(30,fun () -> this.guardSplitStrip())) + stripLiftSoon <- Some(new CoalescedAction(30,fun () -> if not isDestroyed.value then this.guardUnownedStrip())) transitionRelease.Tick.Add(fun _ -> transitionRelease.Stop() if not transitionHold.IsEmpty then @@ -791,8 +799,60 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: member private this.setTsOwner(owner:Window) = let strip = this.os.windowFromHwnd(this.ts.hwnd) + let shared = this.canShareInput owner.hwnd StartupTrace.measure "group.owner" (fun () -> - strip.setOwner(if this.canShareInput owner.hwnd then owner else this.os.nullWindow)) + strip.setOwner(if shared then owner else this.os.nullWindow)) + // Windows raises an owned strip with its window; one owned by nothing is watched instead. + this.watchOrder() + + /// Whether the tabs can be covered with no notice to the group: by a pane while split, or + /// by the window itself when the strip is owned by nothing. + member private this.needsOrderWatch = + not isDestroyed.value && not this.isEmpty && + (this.isSplit || this.os.windowFromHwnd(this.ts.hwnd).parent.hwnd=IntPtr.Zero) + + /// Windows reports every change in the order of top-level windows on the desktop window: an + /// app raising itself again a moment after it is activated, as Chromium browsers do, or a + /// title bar click. Listened to only while it matters, in place of looking every so often. + member private this.watchOrder() = + if not this.needsOrderWatch then this.unwatchOrder() + elif reorderHook.IsNone then + reorderHook <- Some(this.os.setTopLevelOrderEvent(fun () -> this.onOrderChanged())) + + member private this.unwatchOrder() = + reorderHook |> Option.iter(fun hook -> hook.Dispose()) + reorderHook <- None + + /// Not unhooked from inside its own report: setTsOwner and destroy unhook it once not needed. + member private this.onOrderChanged() = + if this.needsOrderWatch then + if this.isSplit then splitGuardSoon |> Option.iter(fun soon -> soon.Request()) + else this.guardUnownedStrip() + + /// A strip owned by nothing goes back just above the window, if the window has come over it. + /// Done in the same step as whatever raised the window, the tabs do not flash. + member private this.liftUnownedStrip(hwnd:IntPtr) = + let strip = this.os.windowFromHwnd(this.ts.hwnd) + // Its place is just above the window, which is all that is asked: hidden windows of every + // app lie between, and a strip left far below, a hundred and more of them, was out of + // reach of a walk up from it. + if strip.parent.hwnd=IntPtr.Zero && this.windows.contains hwnd && not(isMinimized hwnd) && not this.isSplit then + let previous = this.os.windowFromHwnd(hwnd).prevZorder + if previous.hwnd<>strip.hwnd then + lastStripLift <- transitionClock.ElapsedMilliseconds + strip.insertAfter(previous) + + /// A window already in front raises itself over a strip it does not own when it is maximized + /// or its title bar is clicked, with no foreground change to tell the group: its tabs went + /// under it until another window was activated. + /// Each move of the strip is itself a change of order reported back here: against a window + /// that keeps itself over everything, the two would take turns for good. Reported again too + /// soon after the last move, the strip goes back once that time is up instead. + member private this.guardUnownedStrip() = + if isVisibleCell.value && not liveResize && not inMoveSize.value && not reconcileMinimizeState then + if transitionClock.ElapsedMilliseconds-lastStripLift<30L then + stripLiftSoon |> Option.iter(fun soon -> soon.Request()) + else this.liftUnownedStrip this.topWindow /// Permission checks stay off the placement hot path and expire with group membership. member this.canShareInput hwnd = @@ -801,10 +861,14 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: match inputOwnership.TryGetValue hwnd with | true,compatible -> compatible | _ -> - let compatible = WindowInputOwnership.CanShareWithCurrentProcess hwnd + let compatible = this.shareInput hwnd inputOwnership.[hwnd] <- compatible compatible + /// Whether a window's input may be linked with this process's; a suite stands in for one + /// running with other rights. + member val shareInput : IntPtr -> bool = WindowInputOwnership.CanShareWithCurrentProcess with get,set + member private this.setTsParent(parentHwnd) = let strip = this.os.windowFromHwnd(this.ts.hwnd) let parent = this.os.windowFromHwnd(parentHwnd) @@ -894,7 +958,7 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: this.saveZorder() if covered && not this.isEmpty then this.setTsParent(this.topWindow) elif behindAbove && not this.isEmpty then this.keepPanesBelowStrip(this.topWindow)) - | _ -> splitGuardTimer.Stop() + | _ -> this.watchOrder() /// What the panes go just below: the divider, which lies over their edges, or else the strip. member private this.paneAnchor = @@ -1006,7 +1070,7 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: member private this.updateSplitChrome() = match groupLayout with | Split s -> - splitGuardTimer.Start() + this.watchOrder() let strip,gap,frame = this.ts.hwnd,this.splitGap,this.layoutFrame let visible = SplitView.windows s |> List.forall(fun hwnd -> this.windows.contains hwnd && not(isMinimized hwnd || WindowCloaking.IsCloaked hwnd)) && @@ -1975,6 +2039,8 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: this.makeTopWindowForeground() this.foreground <- this.os.foreground.hwnd isMaximizedExport.update() + // Maximized or restored, a window rises over a strip it does not own. + this.liftUnownedStrip hwnd | _ -> () | WinEvent.EVENT_SYSTEM_FOREGROUND -> // Activated some other way (Alt+Tab, its own window), an owned tab can stay under the @@ -1990,6 +2056,11 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: if this.windows.contains(hwnd) then this.saveTopWindowPlacement() this.updateIsVisible() + // A strip owned by nothing, as when the window runs with other rights, is not + // raised with its window: a group of one window has no order change to move it, and + // it stayed under the windows its window came over, its tabs hidden behind theirs. + let strip = this.os.windowFromHwnd(this.ts.hwnd) + if not this.isEmpty && strip.parent.hwnd=IntPtr.Zero then this.setTsParent(this.topWindow) // An app raised by a click comes over the divider and the empty pane, which are // owned by nothing: its invisible resize border then took their edges. this.keepPanesBelowStrip(this.topWindow) @@ -2169,7 +2240,8 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: if isDestroyed.value.not then isDestroyed.set(true) splitSettleTimer.Dispose() - splitGuardTimer.Dispose() + this.unwatchOrder() + for soon in [splitGuardSoon;stripLiftSoon] do soon |> Option.iter(fun soon -> (soon :> IDisposable).Dispose()) transitionRelease.Dispose() // The chrome's thread disposes its windows and ends; nothing here waits for it. chromeInvoker |> Option.iter(fun invoker -> @@ -2291,6 +2363,8 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: // Activated but still under the other tabs when its owner is a combined taskbar's preview; // only once it is in front, as a refused activation leaves the strip with the real top window. if this.os.foreground.hwnd=hwnd then window.raise() + // Raised just now over a strip it does not own: the strip goes back over it at once. + this.liftUnownedStrip hwnd if this.os.foreground.hwnd=hwnd then lastActiveTab <- Some hwnd // The layout follows once the tab is in front: parking a split first re-placed every window // while the tab chosen was still under them, and a tab behind showed for a moment. Active, diff --git a/WtProgram/Workspace/WorkspaceModel.fs b/WtProgram/Workspace/WorkspaceModel.fs index c02e171..c2fd262 100755 --- a/WtProgram/Workspace/WorkspaceModel.fs +++ b/WtProgram/Workspace/WorkspaceModel.fs @@ -407,10 +407,15 @@ type WorkspaceModel() as this = /// by handle: by their places in the list read then, it named the wrong windows once a tab /// had closed or moved since. let layouts = Dictionary() + /// A shortcut saves through a model of its own, so an open Settings page reads the list again. + static let savedEvt = Event() do Observable.init(this) + /// The saved workspaces changed; the model that wrote them is given. + static member saved = savedEvt.Publish + member this.workspaces = _workspaces.list member _.isReadOnly = readOnly member this.workspaceAdded = workspaceAddedEvt.Publish @@ -575,20 +580,43 @@ type WorkspaceModel() as this = member this.addWorkspace(ws:Workspace) = ws.cast().removed.Add <| fun() -> this.onWorkspaceRemoved(ws) _workspaces.Add(ws) - this.saveSettings() - workspaceAddedEvt.Trigger(ws) + this.saveSettings() + // Reading the list again after a shortcut's save is no new workspace to open up. + if not loading then workspaceAddedEvt.Trigger(ws) - member this.create() = + /// `added` is told the workspace once it is saved. + member this.create(?added:Workspace -> unit) = if not readOnly && not creating then creating <- true try this.createWorkspace(false,fun ws -> creating <- false - if not readOnly then this.addWorkspace(ws)) + if not readOnly then + this.addWorkspace(ws) + this.markUsed ws + added |> Option.iter(fun f -> f ws)) with _ -> creating <- false reraise() + /// The restore shortcut brings back the workspace last saved or restored, by its name. + member private this.markUsed(ws:Workspace) = + let root = Services.settings.root + root.setString("lastWorkspace",ws.name) + Services.settings.root <- root + + /// The workspace last saved or restored; the newest one when that one is gone or renamed. + member this.lastUsed : Workspace option = + let name = Services.settings.root.getString("lastWorkspace") + let named = name |> Option.bind(fun name -> this.workspaces.tryFind(fun ws -> ws.name=name)) + named |> Option.orElse(List.tryLast this.workspaces.list) + + /// What restoring the last used workspace did, or None when none is saved. + member this.restoreLast() = + this.lastUsed |> Option.map(fun ws -> + this.markUsed ws + this.restoreWorkspace ws) + /// The windows as they are now, kept on their own as the session: written as the groups /// change and brought back at the next start after a sign-in, when no window is left from /// the last run. Apart from the saved workspaces, which are the user's. @@ -632,7 +660,10 @@ type WorkspaceModel() as this = /// What was restored, or None when no workspace is selected. member this.restore() = - if this.canRestore then Some(this.restoreWorkspace(this.selected :?> Workspace)) + if this.canRestore then + let ws = this.selected :?> Workspace + this.markUsed ws + Some(this.restoreWorkspace ws) else None /// The selected node's edit fields, or None when it cannot be edited. @@ -668,6 +699,7 @@ type WorkspaceModel() as this = root.setInt32("workspaceSchemaVersion",WorkspaceData.version) recovery |> Option.iter(fun data -> root.["workspaceRecovery"] <- data.DeepClone()) Services.settings.root <- root + savedEvt.Trigger this member this.onWorkspaceRemoved(ws) = diff --git a/docs/input-isolation-probe.md b/docs/input-isolation-probe.md index d3984c4..83b063d 100644 --- a/docs/input-isolation-probe.md +++ b/docs/input-isolation-probe.md @@ -99,11 +99,17 @@ selection manually accepted. Cross-integrity taskbar combining remains unavailab both select text continuously and use a combined taskbar button. Activation, previews, close, minimize/restore and virtual desktop membership remain correct. -2. **P2 — Explain the current fallback in settings and the group menu.** +2. **P2 — Explain the current fallback in settings and the group menu.** Partly done: + the group's tab menu shows the reason under **Combine taskbar icons**, and the + diagnostics report lists each group's taskbar state. The settings page and when + combining resumes are not explained yet. - [ ] Show a concise reason when combining is requested but permissions require individual buttons. Preserve the saved preference and explain when it resumes. - - [ ] Add the text through `Strings.fs` in English, Chinese and Japanese; verify - light, dark and high contrast appearance. + Done for the group menu; the preference is kept. Not yet on the settings page, + and the text does not say when combining resumes. + - [x] Add the text through `Strings.fs` in English, Chinese and Japanese (Arabic + too). + - [ ] Verify the menu note in light, dark and high contrast appearance. - [ ] Acceptance: users can distinguish an enabled preference from an effective fallback without having to inspect process permissions or diagnostic logs. diff --git a/docs/split-view-design.md b/docs/split-view-design.md index d804ad0..461c43d 100644 --- a/docs/split-view-design.md +++ b/docs/split-view-design.md @@ -352,7 +352,7 @@ no command of its own. | Changing how taskbar buttons combine (the group is rebuilt) | The layout is captured asynchronously and applied once the members have moved over; a missing member means a single window | | Exiting WindowTabs | Every member is visible natively again; the dividers and the empty panes are released. Each pane window keeps its place in the split on itself (`WindowSplitLayout`, window properties beside the group token: its path with the axis at each level, a ratio per split above, the frame, focus and parked), so the next start shows the split again as the windows regroup, growing as each arrives; a window closed meanwhile leaves its room to its neighbour, and empty panes are not kept. Across a sign-in the windows are new: workspaces keep each group's split by the windows' places in its list (`SavedSplit`), and the session, a workspace written on its own as the groups change, brings groups, positions and splits back at a start that finds no window from the last run, when the setting asks for it | | DPI or monitor changes | Logical sizes are scaled again for the monitor holding the group's frame | -| A pane raised over the tabs by its own app, with no foreground change | A 200ms guard while split notices it and puts the tabs back over both panes | +| A pane raised over the tabs by its own app, with no foreground change | While split, the group listens for changes in the order of top-level windows (reported on the desktop window); a guard runs once per burst of them and puts the tabs back over both panes | | A tab behind the panes above one of them | Put below the backdrop once placement settles, and by the same guard: an app that keeps to its own grid is a little larger than the pane it waits under, and above another pane its edge showed at the seam | | An app that paints less than its frame (a frameless Electron window's clear resize margin, about 4px inside the frame DWM reports), or one still catching up with a size | A backdrop window (`SplitBackdrop`) the colour of the theme's surface covers the group's frame just below the lowest pane and above the tabs behind, so the seam shows it rather than whatever waits behind | diff --git a/tests/GroupLifecycle.fsx b/tests/GroupLifecycle.fsx index 433c534..b9adc4a 100644 --- a/tests/GroupLifecycle.fsx +++ b/tests/GroupLifecycle.fsx @@ -284,6 +284,8 @@ let main() = let owner hwnd = WinUserApi.GetWindow(hwnd,GetWindowConstants.GW_OWNER) pumpUntil "Compatible input queues did not combine taskbar ownership" (fun () -> permissionHandles |> List.forall TaskbarProxies.ownsWindow) + let stateOf (group:GroupInfo) = TaskbarCombineStates.tryGet (group :> IGroup).hwnd + check (stateOf permissionProbe=Some Combined) "A combined group did not report itself combined" let cloakTarget = onGroup permissionProbe (fun group -> group.topWindow) let mutable cloak = 1 check (DwmApi.DwmSetWindowAttribute(cloakTarget,13,&cloak,4)=0) "Could not cloak input-isolation fixture" @@ -298,6 +300,8 @@ let main() = onGroup permissionProbe (fun _ -> compatible <- false;permissionPlugin.onZorderChanged()) check (permissionHandles |> List.forall(fun hwnd -> owner hwnd=IntPtr.Zero)) "Incompatible input queues retained taskbar proxy owners" check (nativeOrder permissionProbe=permissionHandles) "Taskbar input isolation changed tab membership" + // #84: the tab menu and the diagnostics report say why the windows keep their buttons. + check (stateOf permissionProbe=Some DifferentPermissions) "Incompatible permissions were not reported as the reason" let changes = onGroup permissionProbe (fun _ -> permissionPlugin.taskbarVisibilityChanges) onGroup permissionProbe (fun _ -> for _ in 1..100 do permissionPlugin.onZorderChanged()) check (onGroup permissionProbe (fun _ -> permissionPlugin.taskbarVisibilityChanges)=changes) @@ -307,10 +311,63 @@ let main() = finally (permissionProbe :> IGroup).destroy() pumpUntil "Permission probe did not exit" (fun () -> permissionProbe.isExited) + check ((TaskbarCombineStates.tryGet (permissionProbe :> IGroup).hwnd).IsNone) "An exited group kept its taskbar state" check (permissionHandles |> List.forall(fun hwnd -> WinUserApi.GetWindow(hwnd,GetWindowConstants.GW_OWNER)=IntPtr.Zero)) "Taskbar isolation probe left an owner after shutdown" check (permissionHandles |> List.map(fun hwnd -> WinUserApi.GetWindowLong(hwnd,WindowLongFieldOffset.GWL_EXSTYLE)) = permissionStyles) "Taskbar exit registration changed the application's original window styles" + // A window running with other rights leaves its strip owned by nothing, so Windows does + // not raise the strip with it. Activated over another window, the window's tabs came up + // with it rather than staying under the window it came over. + do + use isolated = new Form(Text="Isolated tab",ShowInTaskbar=false) + use cover = new Form(Text="Covering window",ShowInTaskbar=false,StartPosition=FormStartPosition.Manual) + let fixture = desktopApi.createGroup(false) :?> GroupInfo + onGroup fixture (fun group -> group.shareInput <- (fun _ -> false)) + try + isolated.Show() + (fixture :> IGroup).addWindow(isolated.Handle,false) + pumpUntil "The isolated window did not join its group" (fun () -> nativeOrder fixture=[isolated.Handle]) + let strip = onGroup fixture (fun group -> group.ts.hwnd) + check (WinUserApi.GetWindow(strip,GetWindowConstants.GW_OWNER)=IntPtr.Zero) "A strip took an owner whose input it may not share" + cover.Bounds <- isolated.Bounds + cover.Show() + // The group places its strip as the window joins; the cover goes on top after that. + let settle = Diagnostics.Stopwatch.StartNew() + while settle.ElapsedMilliseconds<500L do + Application.DoEvents() + Thread.Sleep(10) + OS().windowFromHwnd(cover.Handle).insertAfter(WindowHandleTypes.HWND_TOP) + // Left behind as the strip of a window that went under another. + OS().windowFromHwnd(strip).insertAfter(cover.Handle) + let rank hwnd = OS().windowFromHwnd(hwnd).zorder + check (rank cover.Handle 0L)) + OS().windowFromHwnd(isolated.Handle).insertAfter(WindowHandleTypes.HWND_TOP) + onGroup fixture (fun group -> group.main(isolated.Handle,WinEvent.EVENT_SYSTEM_FOREGROUND)) + pumpUntil (sprintf "An isolated window came to the front without its tabs: strip %d, window %d, cover %d" (rank strip) (rank isolated.Handle) (rank cover.Handle)) (fun () -> + rank strip rank strip + group.tabActivate(Tab(isolated.Handle),false) + rank strip,rank isolated.Handle) + check (fst afterClick IGroup).destroy() + pumpUntil "Isolated fixture did not exit" (fun () -> fixture.isExited) + // Placement failure keeps real windows independent; a later successful attempt may combine them. do use first = new Form(ShowInTaskbar=false) @@ -331,10 +388,12 @@ let main() = check (attempts=0 && (handles |> List.forall(fun hwnd -> not(TaskbarProxies.ownsWindow hwnd)))) "Unknown desktop attempted placement or adopted an application" check (not(onGroup fixture (fun _ -> plugin.isAwaitingDesktopPlacement))) "Unknown desktop started an automatic retry loop" + check (onGroup fixture (fun _ -> plugin.combineState)=DesktopUnknown) "An unknown desktop was not reported as the reason" onGroup fixture (fun _ -> knownDesktop <- Some desk1;plugin.onZorderChanged()) check (attempts=1 && (handles |> List.forall(fun hwnd -> not(TaskbarProxies.ownsWindow hwnd)))) "Failed desktop placement adopted an application" check (onGroup fixture (fun _ -> plugin.isAwaitingDesktopPlacement)) "Failed placement was marked complete" + check (onGroup fixture (fun _ -> plugin.combineState)=PlacementFailed) "A failed placement was not reported as the reason" onGroup fixture (fun _ -> placeSucceeded <- true) pumpUntil "Failed placement did not retry and resume combining" (fun () -> handles |> List.forall TaskbarProxies.ownsWindow) @@ -356,6 +415,7 @@ let main() = check (attempts-failedStart=3 && not(onGroup fixture (fun _ -> plugin.isAwaitingDesktopPlacement))) "Title/focus/z-order refresh restarted exhausted placement retries" check (handles |> List.forall(fun hwnd -> not(TaskbarProxies.ownsWindow hwnd))) "Exhausted retries retained native owners" + check (onGroup fixture (fun _ -> plugin.combineState)=PlacementFailed) "Exhausted retries were not reported as the reason" onGroup fixture (fun _ -> knownDesktop <- Some desk2;placeSucceeded <- true;plugin.onZorderChanged()) check (attempts-failedStart=4 && (handles |> List.forall TaskbarProxies.ownsWindow)) "A changed confirmed desktop did not resume combining" @@ -760,6 +820,14 @@ let main() = reloaded.selected <- reloaded.workspaces.head let result = reloaded.restore() check (result |> Option.exists(fun result -> result.restored=2 && result.missing.IsEmpty)) (sprintf "Workspace restore result miscounted: %A" result) + // #82: the restore shortcut brings back the workspace last saved or restored, and the + // newest one when that one is gone or renamed. + check (reloaded.lastUsed |> Option.exists(fun ws -> ws.name=saved.name)) "A restored workspace was not the one the shortcut restores" + let root = api.root + root.setString("lastWorkspace","No such workspace") + api.root <- root + check (reloaded.lastUsed |> Option.exists(fun ws -> obj.ReferenceEquals(ws,List.last reloaded.workspaces.list))) + "A renamed last workspace left the restore shortcut with nothing to restore" let restoredGroup hwnd = match desktopApi.groups.list |> List.filter(fun group -> group.windows.contains((=)hwnd)) with | [group] -> Some(group :?> GroupInfo) diff --git a/tests/PopupRendering.fsx b/tests/PopupRendering.fsx index 5d1b32c..68c287d 100644 --- a/tests/PopupRendering.fsx +++ b/tests/PopupRendering.fsx @@ -128,6 +128,50 @@ let main() = popup.Close() Application.DoEvents() owner.Close() - printfn "PASS: dark/light popup themes before visibility, composited HWNDs and repeated show/hide rendering." + // The notice comes to rest at the bottom right of the work area, where Windows shows its + // own notifications, whole once it has slid in; a failure takes it away at once. + use notice = new NoticeWindow() + notice.show("Workspace 1","Saved with 2 windows",Info) + let pumpFor (milliseconds:int64) = + let clock = Diagnostics.Stopwatch.StartNew() + while clock.ElapsedMilliseconds.GetMethod(name,Reflection.BindingFlags.Instance ||| Reflection.BindingFlags.NonPublic).Invoke(notice,[|box args|]) |> ignore + raise "OnMouseEnter" EventArgs.Empty + check (not notice.isCountingDown) "A notice under the pointer kept counting down" + raise "OnMouseLeave" EventArgs.Empty + check notice.isCountingDown "A notice did not count down again once the pointer left" + // Its close button takes an error away, which would otherwise stay. + notice.show("Workspace 1","Could not restore.",Error) + pumpFor 300L + raise "OnMouseEnter" EventArgs.Empty + let close = notice.closeBounds + let centre = Point(close.X+close.Width/2,close.Y+close.Height/2) + raise "OnMouseUp" (new MouseEventArgs(MouseButtons.Left,1,centre.X,centre.Y,0)) + pumpFor 400L + check (not notice.Visible) "The close button did not take the notice away" + notice.hideNow() + check (not notice.Visible) "A notice taken away at once stayed visible" + printfn "PASS: dark/light popup themes before visibility, composited HWNDs, repeated show/hide rendering and the notice's place, kinds and close button." finally Environment.CurrentDirectory <- original TestInit.run main diff --git a/tests/SettingsEditors.fsx b/tests/SettingsEditors.fsx index 26c8673..cdc0f8c 100644 --- a/tests/SettingsEditors.fsx +++ b/tests/SettingsEditors.fsx @@ -395,6 +395,13 @@ let main() = let restore = form.Controls.Find("restore-shortcuts",true).[0] Application.DoEvents() let next = view.control.Controls.Find("next-tab",true).[0] :?> SettingsShortcutInput + // #82: the workspace shortcuts were in the catalog but not on the page. + for item in SettingsCatalog.all do + match item.binding with + | Shortcut _ -> + assertTrue (view.control.Controls.Find(item.id,true) |> Array.exists(fun control -> control :? SettingsShortcutInput)) + (sprintf "The shortcut %s has no editor on the Shortcuts page" item.id) + | _ -> () let previous = view.control.Controls.Find("previous-tab",true).[0] :?> SettingsShortcutInput // Shortcut boxes start from the other editors' width and grow only for longer shortcuts. do diff --git a/tests/SettingsTheme.fsx b/tests/SettingsTheme.fsx index 4d081aa..6f71b3d 100644 --- a/tests/SettingsTheme.fsx +++ b/tests/SettingsTheme.fsx @@ -852,6 +852,10 @@ let main() = WinUserApi.ShowWindow(lazyForm.Handle,ShowWindowCommands.SW_SHOWNOACTIVATE) |> ignore Application.DoEvents() check (generalLoads=1 && appearanceLoads=0 && unusedLoads=0) "Opening the frame eagerly loads pages" + // The session choice's options are short, so its description keeps to one line beside it. + let sessionNote = controls lazyForm |> Seq.find(fun control -> control :? Label && control.Text=tr Strings.Settings.restoreSession.description) + let oneLine = TextRenderer.MeasureText("Xg",sessionNote.Font).Height + check (sessionNote.Height Seq.choose(function :? TextBox as input when input.AccessibleName="Search settings" -> Some input | _ -> None) |> Seq.head lazySearch.Text <- "标签高度" Application.DoEvents() @@ -922,6 +926,15 @@ let main() = check ((tree.Roots |> Seq.map(fun row -> row.Tag) |> Seq.toList)=(workspaces.wm.workspaces.list |> List.map box)) "Workspace rows are not in the order saved" check (not(isNull tree.SelectedItem) && obj.ReferenceEquals(tree.SelectedItem,Seq.last tree.Roots)) "A newly saved workspace was not the last row, selected" + // #82: a workspace saved by shortcut, through a model of its own, shows on the open page, + // so the page's next save keeps it. + let byShortcut = WorkspaceModel() + byShortcut.loadSettings() + let mutable shortcutSaved = None + byShortcut.create(fun ws -> shortcutSaved <- Some ws.name) + check (shortcutSaved.IsSome && tree.Roots.Count=byShortcut.workspaces.length && + (tree.Roots |> Seq.last).Text=shortcutSaved.Value) + "A workspace saved by shortcut did not show on the open Workspaces page" // Opened again, the saved workspaces start collapsed with none selected. let reopened = WorkspaceView() use reopenedHost = new Panel(Size=Size(900,600)) @@ -931,7 +944,7 @@ let main() = let kind (button:Button) = (button :?> SettingsActionButton).Kind check (kind reopened.restoreButton=SettingsButtonKind.Primary && kind reopened.newButton<>SettingsButtonKind.Primary) "Save and Restore look alike" - for _ in 1..2 do + for _ in 1..3 do workspaces.wm.selected <- box (workspaces.wm.workspaces.list |> List.last) workspaces.wm.remove() recoveryForm.Close()