From 715052911599fc665dbf5e8241a8864674f42808 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 12:44:05 +0800 Subject: [PATCH 01/13] Read long dropped paths in full and free each drop's data Each path's buffer is sized to the path instead of 512 characters, and the dropped file list is released after reading. --- Win32/Ole2.cs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/Win32/Ole2.cs b/Win32/Ole2.cs index 5e6c660..080a12e 100755 --- a/Win32/Ole2.cs +++ b/Win32/Ole2.cs @@ -182,17 +182,29 @@ public static IEnumerable QueryFiles(IDataObject dataObject) fr.lindex = -1; fr.tymed = TYMED.TYMED_HGLOBAL; dataObject.GetData(ref fr, out td); - var hdrop = td.unionmember; - uint count = ShellApi.DragQueryFile(hdrop, -1, IntPtr.Zero, 0); - var files = new List(); - for (int i = 0; i < count; i++) + try { - var size = 512; - var sb = new StringBuilder(size); - ShellApi.DragQueryFile(hdrop, i, sb, size); - files.Add(sb.ToString()); + var hdrop = td.unionmember; + uint count = ShellApi.DragQueryFile(hdrop, -1, IntPtr.Zero, 0); + var files = new List(); + for (int i = 0; i < count; i++) + { + // Sized to each path, so long paths are not cut short. + var size = (int)ShellApi.DragQueryFile(hdrop, i, IntPtr.Zero, 0) + 1; + var sb = new StringBuilder(size); + ShellApi.DragQueryFile(hdrop, i, sb, size); + files.Add(sb.ToString()); + } + return files; + } + finally + { + // The data object handed this medium over; it is ours to free. + ReleaseStgMedium(ref td); } - return files; } + + [DllImport("ole32.dll")] + private static extern void ReleaseStgMedium(ref STGMEDIUM medium); } } From 42d0d9821f034f7c7f174e2e9f10941d7a7affb3 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 12:44:06 +0800 Subject: [PATCH 02/13] Drop files on Explorer tabs without interop DLLs, and allow undo Explorer folders are found late bound through Shell.Application, so the two interop assemblies are gone. Windows are matched by handle before their view is read, so an Explorer view without a folder no longer breaks every drop. The folder is looked up once per hovered tab instead of on every drag move. Drops can be undone in Explorer, and the source is told the shell did the work, so it cannot act on the files again. --- Interop.SHDocVw.dll | Bin 143360 -> 0 bytes Interop.Shell32.dll | Bin 49152 -> 0 bytes WtProgram/OleDropTarget.fs | 35 ++++++++++++------------- WtProgram/Shared/Shell.fs | 51 +++++++++++++++++++++++++++---------- WtProgram/WtProgram.fsproj | 4 +-- tests/Architecture.fsx | 7 +++++ 6 files changed, 64 insertions(+), 33 deletions(-) delete mode 100755 Interop.SHDocVw.dll delete mode 100755 Interop.Shell32.dll diff --git a/Interop.SHDocVw.dll b/Interop.SHDocVw.dll deleted file mode 100755 index a10ff26fd07dd082a4ff9050a92d86431ba86af6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 143360 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
xjSV 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 let registered = initialized && Ole2Api.RegisterDragDrop(window.hwnd, this)>=0 @@ -37,6 +40,7 @@ type OleDropTarget(ts:TabStrip) as this= member this.dragEnd() = rButtonDown.value <- false lastTabHwndCell.set(None) + hoveredFolder <- None interface IOleDropTarget with member x.OleDragEnter(pDataObj, grfKeyState, pt, pdwEffect) = @@ -50,25 +54,20 @@ type OleDropTarget(ts:TabStrip) as this= let pt = window.ptToClient(Pt(pt.x, pt.y)) let tabHwnd = ts.tryHit(pt).map(fun(Tab(hwnd),part) -> hwnd) - let effect = tabHwnd.map <| fun hwnd -> - let shellFolder = Shell.getShellFolder hwnd - if shellFolder.IsSome then - if grfKeyState.hasFlag(MouseMessageKeyStateMask.MK_CONTROL) then - int(DragDropEffects.Copy) - else - int(DragDropEffects.Move) - else - int(DragDropEffects.None) - let effect = effect.def(int(DragDropEffects.None)) if lastTabHwndCell.value <> tabHwnd then - lastTabHwndCell.set(tabHwnd) + hoveredFolder <- tabHwnd |> Option.bind Shell.getShellFolder tabHwnd.iter <| fun hwnd -> let window = os.windowFromHwnd(hwnd) //setForegroundWindow will fail sometimes if a key is pressed during DragOver //like CTRL. we will be unable to call setForeground until a new DragEnter is generated window.setForegroundOrRestore(false) window.bringToTop() - pdwEffect <- effect + lastTabHwndCell.set(tabHwnd) + pdwEffect <- + match hoveredFolder with + | Some _ when grfKeyState.hasFlag(MouseMessageKeyStateMask.MK_CONTROL) -> int(DragDropEffects.Copy) + | Some _ -> int(DragDropEffects.Move) + | None -> int(DragDropEffects.None) 0 member x.OleDragLeave() = @@ -76,18 +75,17 @@ type OleDropTarget(ts:TabStrip) as this= 0 member x.OleDrop(pDataObj, grfKeyState, pt, pdwEffect) = - let initialEffect = pdwEffect let ptScreen = Pt(pt.x, pt.y) let pt = window.ptToClient(ptScreen) ts.tryHit(pt).iter <| fun(Tab(hwnd),part) -> - let shellFolder = Shell.getShellFolder hwnd - Shell.getShellFolder(hwnd).iter <| fun shellFolder -> + let folder = if lastTabHwndCell.value=Some hwnd then hoveredFolder else Shell.getShellFolder hwnd + folder.iter <| fun shellFolder -> let files = List2(OleHelper.QueryFiles(pDataObj)) let shellOp op = fun() -> files.iter <| fun file -> op(file) - let copy = shellOp <| fun file -> shellFolder.CopyHere(file, null) - let move = shellOp <| fun file -> shellFolder.MoveHere(file, null) + let copy = shellOp <| fun file -> shellFolder.CopyHere(file) + let move = shellOp <| fun file -> shellFolder.MoveHere(file) if rButtonDown.value then Win32Menu.show window.hwnd ptScreen (List2([ CmiRegular({ @@ -114,5 +112,8 @@ type OleDropTarget(ts:TabStrip) as this= copy() else move() + // The shell has done the work. Any other effect would let the source act on it + // again; after a move it could delete what a skipped name conflict left behind. + pdwEffect <- int(DragDropEffects.None) this.dragEnd() 0 diff --git a/WtProgram/Shared/Shell.fs b/WtProgram/Shared/Shell.fs index 44b7620..de1aab8 100755 --- a/WtProgram/Shared/Shell.fs +++ b/WtProgram/Shared/Shell.fs @@ -1,17 +1,42 @@ -namespace Bemo +namespace Bemo open System -open System.IO +open System.Reflection +/// Explorer windows through the Shell.Application automation object, late bound so no +/// interop assemblies ship with the app. module Shell = + let private call (target:obj) name flags (args:obj[]) = + target.GetType().InvokeMember(name,flags,null,target,args) + let private get target name = call target name BindingFlags.GetProperty [||] + let private invoke target name args = call target name BindingFlags.InvokeMethod args + + /// FOF_ALLOWUNDO, so Ctrl+Z in Explorer takes a drop back. + let private allowUndo = 0x40 + + type ShellFolder(folder:obj) = + member _.CopyHere(path:string) = invoke folder "CopyHere" [|box path;box allowUndo|] |> ignore + member _.MoveHere(path:string) = invoke folder "MoveHere" [|box path;box allowUndo|] |> ignore + + /// The folder an Explorer window shows; None for any other window. Each window is + /// matched by its handle first, so a view without a folder elsewhere cannot fail the search. let getShellFolder (hwnd:IntPtr) = - let shellWindows = SHDocVw.ShellWindowsClass() - let windows = List2(Seq.fromEnumerable shellWindows) - windows.tryPick <| fun window -> - match window with - | :? SHDocVw.ShellBrowserWindow as shellWindow -> - let folderView = shellWindow.Document :?> Shell32.IShellFolderViewDual - if shellWindow.HWND = int(hwnd) then - Some(folderView.Folder) - else - None - | _ -> None + let folderOf (windows:obj) index = + try + match invoke windows "Item" [|box index|] with + | null -> None + | window when Convert.ToInt64(get window "HWND")=hwnd.ToInt64() -> + match get (get window "Document") "Folder" with + | null -> None + | folder -> Some(ShellFolder(folder)) + | _ -> None + with _ -> None + if hwnd=IntPtr.Zero then None + else + try + match Type.GetTypeFromProgID("Shell.Application") with + | null -> None + | shellType -> + let windows = invoke (Activator.CreateInstance(shellType)) "Windows" [||] + let count = Convert.ToInt32(get windows "Count") + List.init count id |> List.tryPick (folderOf windows) + with _ -> None diff --git a/WtProgram/WtProgram.fsproj b/WtProgram/WtProgram.fsproj index 4d47ff0..1bf4649 100755 --- a/WtProgram/WtProgram.fsproj +++ b/WtProgram/WtProgram.fsproj @@ -31,7 +31,7 @@ true - $(OtherFlags) --standalone --staticlink:Win32 --staticlink:Newtonsoft.Json --staticlink:Interop.SHDocVw --staticlink:Interop.Shell32 + $(OtherFlags) --standalone --staticlink:Win32 --staticlink:Newtonsoft.Json $(NoWarn);2009;2011 @@ -136,8 +136,6 @@ 13.0.1 already has the deep-nesting security fix. --> - - diff --git a/tests/Architecture.fsx b/tests/Architecture.fsx index 88be083..41e2b38 100644 --- a/tests/Architecture.fsx +++ b/tests/Architecture.fsx @@ -376,6 +376,13 @@ let main() = check (not (NumericShortcutTarget.available [IntPtr(1)] (IntPtr(1)) 0)) "Current/only tab must pass through" check (NumericShortcutTarget.available [IntPtr(1);IntPtr(2)] (IntPtr(1)) 1) "Other tab must remain available" check (not (NumericShortcutTarget.available [IntPtr(1)] IntPtr.Zero 1)) "Missing tab must pass through" + // Dropping on tabs reaches Explorer late bound: no interop assembly ships, and a window + // that is not an Explorer folder is simply no target. + check (typeof.Assembly.GetReferencedAssemblies() |> Array.forall(fun name -> not (name.Name.StartsWith("Interop.")))) + "The app still references an interop assembly" + do + use plain = new Form(ShowInTaskbar=false) + check (Shell.getShellFolder IntPtr.Zero=None && Shell.getShellFolder plain.Handle=None) "A window without an Explorer folder was taken as a drop folder" let mask = AltMenuMask.inputs() check (mask.Length=2 && mask.[0].mkhi.ki.wVk=0xE8s && mask.[1].mkhi.ki.dwFlags=SendInputConstants.KEYEVENTF_KEYUP && mask.[0].mkhi.ki.dwExtraInfo=IntPtr(AltMenuMask.marker)) "Alt menu mask must pair and mark injected events" check (capture.handle(WindowMessages.WM_KEYDOWN,0x31,Some 0)=(true,Some 0)) "Matched digit must be swallowed and activated" From cda8f36ddb42310158b3e83d64921d9206905c7e Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 15:29:55 +0800 Subject: [PATCH 03/13] Show a cut-short tab title in full on hover Resting the pointer on a tab whose title is cut by the ellipsis, or hidden beside its icon, shows the whole title under the strip in the app's theme, wrapped past 470px. Moving to another cut title shows it at once; a click, leaving the strip or the strip hiding puts it away. --- .../ManagerViewService/SettingsControls.fs | 32 +------ WtProgram/Shared/SettingsColors.fs | 30 +++++++ WtProgram/TabStrip.fs | 46 ++++++++++ WtProgram/TabStripSprite.fs | 9 ++ WtProgram/TabTitleTip.fs | 83 +++++++++++++++++++ WtProgram/WtProgram.fsproj | 3 +- tests/TabInteraction.fsx | 34 ++++++++ 7 files changed, 205 insertions(+), 32 deletions(-) create mode 100644 WtProgram/TabTitleTip.fs diff --git a/WtProgram/ManagerViewService/SettingsControls.fs b/WtProgram/ManagerViewService/SettingsControls.fs index 01284ea..4f48c1f 100644 --- a/WtProgram/ManagerViewService/SettingsControls.fs +++ b/WtProgram/ManagerViewService/SettingsControls.fs @@ -1,4 +1,4 @@ -namespace Bemo +namespace Bemo open System open System.Drawing open System.Windows.Forms @@ -87,36 +87,6 @@ type SettingsInfoButton() as this = override this.OnMouseEnter(e) = base.OnMouseEnter(e); this.Invalidate() override this.OnMouseLeave(e) = base.OnMouseLeave(e); this.Invalidate() -module SettingsTextWrap = - /// The text as lines no wider than width: broken at spaces, after a path separator inside a - /// long word such as a file path (WordBreak would clip one), else between characters. - let lines (text:string) (font:Font) (width:int) = - let measure (part:string) = TextRenderer.MeasureText(part,font,Size(Int32.MaxValue,Int32.MaxValue),TextFormatFlags.NoPrefix).Width - let fits (part:string) = measure (part.TrimEnd()) <= width - // Pieces that each end where a line may break. - let pieces (paragraph:string) = - let result = ResizeArray() - let mutable start = 0 - for index in 0..paragraph.Length-1 do - if paragraph.[index]=' ' || paragraph.[index]='\\' || paragraph.[index]='/' then - result.Add(paragraph.Substring(start,index+1-start)) - start <- index+1 - if start Array.collect(fun paragraph -> - let line,lines = - pieces paragraph |> List.fold(fun (line:string,lines) piece -> - if fits (line+piece) then line+piece,lines - elif fits piece then piece,(if line="" then lines else line.TrimEnd()::lines) - else characters line piece lines) ("",[]) - line.TrimEnd()::lines |> List.rev |> Array.ofList) - |> List.ofArray - type private SettingsHelpPopup(message:string,font:Font) as this = inherit Form() // As wide as a short message needs, wrapping only past 470px. diff --git a/WtProgram/Shared/SettingsColors.fs b/WtProgram/Shared/SettingsColors.fs index c145d8b..c5b7130 100644 --- a/WtProgram/Shared/SettingsColors.fs +++ b/WtProgram/Shared/SettingsColors.fs @@ -22,3 +22,33 @@ module SettingsColors = { background=Color.White; surface=Color.White; text=Color.FromRGB(0x191919) muted=Color.FromRGB(0x898883); disabledText=Color.FromRGB(0xBDBCB8); border=Color.FromRGB(0xE5E5E5); hover=Color.FromRGB(0xF3F3F3) accent=Color.FromRGB(0x2D79D7); selection=Color.FromRGB(0xE3E3E3) } + +module SettingsTextWrap = + /// The text as lines no wider than width: broken at spaces, after a path separator inside a + /// long word such as a file path (WordBreak would clip one), else between characters. + let lines (text:string) (font:Font) (width:int) = + let measure (part:string) = TextRenderer.MeasureText(part,font,Size(Int32.MaxValue,Int32.MaxValue),TextFormatFlags.NoPrefix).Width + let fits (part:string) = measure (part.TrimEnd()) <= width + // Pieces that each end where a line may break. + let pieces (paragraph:string) = + let result = ResizeArray() + let mutable start = 0 + for index in 0..paragraph.Length-1 do + if paragraph.[index]=' ' || paragraph.[index]='\\' || paragraph.[index]='/' then + result.Add(paragraph.Substring(start,index+1-start)) + start <- index+1 + if start Array.collect(fun paragraph -> + let line,lines = + pieces paragraph |> List.fold(fun (line:string,lines) piece -> + if fits (line+piece) then line+piece,lines + elif fits piece then piece,(if line="" then lines else line.TrimEnd()::lines) + else characters line piece lines) ("",[]) + line.TrimEnd()::lines |> List.rev |> Array.ofList) + |> List.ofArray diff --git a/WtProgram/TabStrip.fs b/WtProgram/TabStrip.fs index 8030b53..6520ef2 100755 --- a/WtProgram/TabStrip.fs +++ b/WtProgram/TabStrip.fs @@ -80,6 +80,10 @@ type TabStrip(monitor:ITabStripMonitor) as this = /// Waits out a press that handed the capture to the drag check, see WM_MOUSELEAVE. let captureWatch = new Timer(Interval=30) let mutable pointerSource : unit -> Point = fun () -> Cursor.Position + /// A cut-short title shows in full once the pointer rests on its tab, see pointTitle. + let titleTimer = new Timer(Interval=500) + let mutable titleTab : Tab option = None + let mutable titleTip : TabTitleTip option = None /// The full strip and the collapsed bar while an expand or collapse runs, so a frame only /// blends them; dropped whenever what they show changes. let mutable revealImages : (Img*Img) option = None @@ -116,6 +120,7 @@ type TabStrip(monitor:ITabStripMonitor) as this = showInsideExport.init() revealTimer.Tick.Add(fun _ -> this.stepReveal()) + titleTimer.Tick.Add(fun _ -> titleTimer.Stop(); this.showTitle()) captureWatch.Tick.Add(fun _ -> if WinUserApi.GetCapture()=IntPtr.Zero then captureWatch.Stop() @@ -201,11 +206,13 @@ type TabStrip(monitor:ITabStripMonitor) as this = this.window.trackMouseLeave() let hit = this.hit hoverCell.set(hit) + this.pointTitle(hit |> Option.map fst) let enableHoverActivate = Services.settings.getValue("enableHoverActivate").cast() if enableHoverActivate then hit.iter <| fun(hitTab, hitPart) -> monitor.tabActivate(hitTab) | MouseClick(pt, btn, action) -> this.setPt(Some(pt)) + this.hideTitle() this.hit.iter <| fun(hitTab, hitPart) -> match action with | MouseDown -> @@ -223,11 +230,46 @@ type TabStrip(monitor:ITabStripMonitor) as this = this.onMouse(action, pt, btn, (hitTab, hitPart)) hoverCell.set(this.hit) | MouseLeave -> + titleTab <- None + this.hideTitle() this.setPt(None) capturedCell.set(None) hoverCell.set(None) heldLayoutCell.set(None)) + member private this.hideTitle() = + titleTimer.Stop() + titleTip |> Option.iter(fun tip -> tip.hide()) + + /// From tab to tab, a title already showing moves straight to the next cut one; otherwise + /// it waits for the pointer to rest. A click puts it away until the pointer moves on. + member private this.pointTitle(tab:Tab option) = + if tab<>titleTab then + titleTab <- tab + let showing = titleTip |> Option.exists(fun tip -> tip.Visible) + this.hideTitle() + if tab.IsSome then + if showing then this.showTitle() else titleTimer.Start() + + member private this.showTitle() = + titleTab |> Option.iter(fun tab -> + let sprites : List2> = this.tabSprites + match sprites.list |> List.tryFind(fun (_,sprite) -> sprite.id=tab) with + | Some(_,sprite) when this.visible && not this.isShrunk && sprite.titleCut -> + let tip = + match titleTip with + | Some tip -> tip + | None -> + let tip = new TabTitleTip() + titleTip <- Some tip + tip + let strip : Rect = this.bounds + tip.show(sprite.displayInfo.text,sprite.displayInfo.textFont, + Rectangle(strip.x,strip.y,strip.size.width,strip.size.height),pointerSource().X) + | _ -> ()) + + member internal _.titleTipShown = titleTip |> Option.exists(fun tip -> tip.Visible) + member private this.wndProc(msg:Win32Message) = // Where the frame on screen sits in the full strip: collapsed, expanded or part way. let mousePt() = msg.lParam.location.add(Pt(0,renderedOffset)) @@ -290,6 +332,7 @@ type TabStrip(monitor:ITabStripMonitor) as this = member internal _.renderCount = renderedFrames member private this.update() = + if not this.visible then this.hideTitle() let target = if this.isShrunk then 0.0 else 1.0 dropRevealImages() if this.visible then @@ -585,6 +628,9 @@ type TabStrip(monitor:ITabStripMonitor) as this = destroyingEvent.Trigger() revealTimer.Dispose() captureWatch.Dispose() + titleTimer.Dispose() + titleTip |> Option.iter(fun tip -> tip.Dispose()) + titleTip <- None dropRevealImages() normalFont.Dispose() renamedFont.Dispose() diff --git a/WtProgram/TabStripSprite.fs b/WtProgram/TabStripSprite.fs index 041787a..279b36a 100755 --- a/WtProgram/TabStripSprite.fs +++ b/WtProgram/TabStripSprite.fs @@ -466,6 +466,15 @@ type TabSprite<'id> = { let width = max 1 width Sz(width, this.contentHeight) + /// Whether the title shows only in part: cut by the ellipsis, or not drawn beside the icon. + member this.titleCut = + if this.onlyIcon then this.displayInfo.text<>"" + else + use image = new Bitmap(1,1) + use g = Graphics.FromImage(image) + use format = new StringFormat(FormatFlags=StringFormatFlags.NoWrap) + g.MeasureString(this.displayInfo.text,this.displayInfo.textFont,PointF.Empty,format).Width > float32 this.textSize.width + member this.tabTextBrush = new SolidBrush(this.textColor) diff --git a/WtProgram/TabTitleTip.fs b/WtProgram/TabTitleTip.fs new file mode 100644 index 0000000..06f8705 --- /dev/null +++ b/WtProgram/TabTitleTip.fs @@ -0,0 +1,83 @@ +namespace Bemo +open System +open System.Drawing +open System.Drawing.Drawing2D +open System.Windows.Forms + +/// A cut-short tab title in full, under the strip while the pointer rests on its tab. Themed +/// like the settings window's help, as the system tooltip stays light in a dark theme. +type TabTitleTip() = + inherit Form() + let mutable wrapped = "" + let mutable font : Font = null + let rounded (rect:RectangleF) radius = + let path = new GraphicsPath() + let d = min (radius*2.0f) (min rect.Width rect.Height) + path.AddArc(rect.Left,rect.Top,d,d,180.0f,90.0f) + path.AddArc(rect.Right-d,rect.Top,d,d,270.0f,90.0f) + path.AddArc(rect.Right-d,rect.Bottom-d,d,d,0.0f,90.0f) + path.AddArc(rect.Left,rect.Bottom-d,d,d,90.0f,90.0f) + path.CloseFigure() + path + let format = TextFormatFlags.NoPrefix + // No "as this": Form's constructor reads CreateParams before F# initialization finishes. + let mutable styled = false + + /// Above the strip and every window, never taking focus or the pointer. + override _.CreateParams = + let cp = base.CreateParams + cp.ExStyle <- cp.ExStyle ||| 0x08000000 ||| 0x00000080 ||| 0x00000008 ||| 0x00000020 + cp + override _.ShowWithoutActivation = true + + override this.OnPaintBackground(e) = e.Graphics.Clear((SettingsColors.current()).surface) + override this.OnPaint(e) = + let p = SettingsColors.current() + e.Graphics.SmoothingMode <- SmoothingMode.AntiAlias + use outline = rounded (RectangleF(0.0f,0.0f,float32(this.Width-1),float32(this.Height-1))) (float32(Dpi.scale 6)) + use pen = new Pen(p.border) + e.Graphics.DrawPath(pen,outline) + let inset = Dpi.scale 8 + TextRenderer.DrawText(e.Graphics,wrapped,this.Font,Rectangle(inset,Dpi.scale 5,this.Width-2*inset,this.Height-Dpi.scale 10),p.text,format) + + /// Below the strip at the pointer, or above it where the screen ends; wrapped past 470px. + member this.show(text:string,tabFont:Font,strip:Rectangle,pointerX:int) = + if not styled then + styled <- true + this.FormBorderStyle <- FormBorderStyle.None + this.StartPosition <- FormStartPosition.Manual + this.ShowInTaskbar <- false + this.DoubleBuffered <- true + if isNull font || font.Name<>tabFont.Name || font.Size<>tabFont.Size then + let previous = font + font <- new Font(tabFont,FontStyle.Regular) + this.Font <- font + if not (isNull previous) then previous.Dispose() + let inset = Dpi.scale 8 + let oneLine = TextRenderer.MeasureText(text,font,Size(Int32.MaxValue,Int32.MaxValue),format).Width + let width = min (Dpi.scale 470) (oneLine+2*inset) + wrapped <- String.Join("\n",SettingsTextWrap.lines text font (width-2*inset)) + let height = TextRenderer.MeasureText(wrapped,font,Size(Int32.MaxValue,Int32.MaxValue),format).Height+Dpi.scale 10 + // Kept on the strip's screen; a strip off every screen keeps its tip with it. + let area = + let screen = Screen.FromRectangle(strip).WorkingArea + if screen.IntersectsWith(strip) then screen else Rectangle(Int32.MinValue/2,Int32.MinValue/2,Int32.MaxValue,Int32.MaxValue) + let gap = Dpi.scale 4 + let below = strip.Bottom+gap + let y = if below+height<=area.Bottom then below else max area.Top (strip.Top-gap-height) + let x = max area.Left (min (pointerX-Dpi.scale 12) (area.Right-width)) + this.Bounds <- Rectangle(x,y,width,height) + use shape = rounded (RectangleF(0.0f,0.0f,float32 width,float32 height)) (float32(Dpi.scale 6)) + let previousRegion = this.Region + this.Region <- new Region(shape) + if not (isNull previousRegion) then previousRegion.Dispose() + this.Invalidate() + if not this.Visible then this.Show() + + member this.hide() = if this.Visible then this.Hide() + + override this.Dispose(disposing) = + if disposing && not (isNull font) then + font.Dispose() + font <- null + base.Dispose(disposing) diff --git a/WtProgram/WtProgram.fsproj b/WtProgram/WtProgram.fsproj index 1bf4649..d5e9f71 100755 --- a/WtProgram/WtProgram.fsproj +++ b/WtProgram/WtProgram.fsproj @@ -1,4 +1,4 @@ - + WinExe @@ -82,6 +82,7 @@ + diff --git a/tests/TabInteraction.fsx b/tests/TabInteraction.fsx index 4c83927..ef22df6 100644 --- a/tests/TabInteraction.fsx +++ b/tests/TabInteraction.fsx @@ -461,6 +461,40 @@ let main() = let count = frames strip send WindowMessages.WM_MOUSELEAVE Pt.empty check (frames strip=count) "Repeated mouse leave repainted" + // A title cut short shows in full once the pointer rests on its tab; a whole title + // does not, and a click puts the tip away until the pointer moves to another tab. + do + let titled (id:int) text = strip.setTabInfo(Tab(IntPtr(id)),{ strip.tabInfo(Tab(IntPtr(id))) with text=text }) + titled 101 (String.replicate 8 "Quarterly report draft ") + titled 102 "Notes" + titled 103 (String.replicate 8 "Meeting notes and actions ") + // Off screen the real pointer is elsewhere, so pumping messages would deliver the + // leave TrackMouseEvent reports; the rest is stepped by hand instead. + let flags = BindingFlags.Instance ||| BindingFlags.NonPublic ||| BindingFlags.Public + let shown () = typeof.GetProperty("titleTipShown",flags).GetValue(strip) :?> bool + let waiting () = (typeof.GetField("titleTimer",flags).GetValue(strip) :?> Timer).Enabled + let rest () = typeof.GetMethod("showTitle",flags).Invoke(strip,[||]) |> ignore + hover 60 + check (waiting() && not (shown())) "A cut-short title did not wait for the pointer to rest" + rest() + check (shown()) "A cut-short title did not show in full after a rest" + hover 460 + check (shown()) "Moving between cut-short titles did not show the next one at once" + hover 260 + rest() + check (not (shown())) "A title that fits showed a tip" + hover 60 + check (waiting()) "Returning to a cut-short title did not wait to show it again" + rest() + send WindowMessages.WM_LBUTTONDOWN (Pt(Dpi.scale 60,Dpi.scale 14)) + send WindowMessages.WM_LBUTTONUP (Pt(Dpi.scale 60,Dpi.scale 14)) + hover 61 + check (not (shown()) && not (waiting())) "The tip came back after a click on its tab" + hover 460 + rest() + send WindowMessages.WM_MOUSELEAVE Pt.empty + check (not (shown()) && not (waiting())) "The tip stayed after the pointer left the strip" + for id in [101;102;103] do titled id "" let os = OS() let shadow = os.windowsInZorder.list |> List.find(fun window -> window.parent.hwnd=strip.hwnd) From 789385f2483b2e90efba73a0b0b956d0a5c03e97 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 15:42:23 +0800 Subject: [PATCH 04/13] Drop files on Explorer tabs as Explorer would, with its drag image Within one drive a drop moves, across drives it copies; Ctrl copies, Shift moves, and files dropped back on their own folder's tab stay put. All files go in one shell operation on its own thread: one progress dialog, one round of name conflicts, one step for Ctrl+Z, and the tabs stay responsive. The drag image stays over the strip with a line such as "Move to Downloads", and a tab's window comes forward only once the pointer rests on it for 300 ms. --- Win32/ShellDrop.cs | 143 ++++++++++++++++++++++++++ WtProgram/OleDropTarget.fs | 200 ++++++++++++++++++++++-------------- WtProgram/Shared/Shell.fs | 48 +++++++-- WtProgram/Shared/Strings.fs | 3 + tests/Architecture.fsx | 33 ++++++ 5 files changed, 340 insertions(+), 87 deletions(-) create mode 100644 Win32/ShellDrop.cs diff --git a/Win32/ShellDrop.cs b/Win32/ShellDrop.cs new file mode 100644 index 0000000..656f80b --- /dev/null +++ b/Win32/ShellDrop.cs @@ -0,0 +1,143 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Bemo +{ + // Shows the source's drag image over a drop target, as Explorer does. + [ComImport, Guid("4657278B-411B-11D2-839A-00C04FD918D0"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IDropTargetHelper + { + void DragEnter(IntPtr hwndTarget, IDataObject dataObject, ref POINTL pt, int effect); + void DragLeave(); + void DragOver(ref POINTL pt, int effect); + void Drop(IDataObject dataObject, ref POINTL pt, int effect); + void Show([MarshalAs(UnmanagedType.Bool)] bool show); + } + + public static class DropImages + { + public const int Invalid = -1, None = 0, Copy = 1, Move = 2; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct DROPDESCRIPTION + { + public int type; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string message; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string insert; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern uint RegisterClipboardFormat(string format); + + // Null where the shell offers no helper; drops then work without the image. + public static IDropTargetHelper CreateHelper() + { + try + { + var type = Type.GetTypeFromCLSID(new Guid("4657278A-411B-11D2-839A-00C04FD918D0")); + return type == null ? null : (IDropTargetHelper)Activator.CreateInstance(type); + } + catch (COMException) { return null; } + catch (InvalidCastException) { return null; } + } + + // The line under the drag image, such as "Move to Downloads"; %1 stands for insert. + // Invalid hands the line back to the source. + public static void Describe(IDataObject data, int type, string message, string insert) + { + var format = new FORMATETC + { + cfFormat = (CLIPFORMAT)unchecked((short)RegisterClipboardFormat("DropDescription")), + ptd = IntPtr.Zero, + dwAspect = DVASPECT.DVASPECT_CONTENT, + lindex = -1, + tymed = TYMED.TYMED_HGLOBAL + }; + var description = new DROPDESCRIPTION { type = type, message = message ?? "", insert = insert ?? "" }; + var memory = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(DROPDESCRIPTION))); + Marshal.StructureToPtr(description, memory, false); + var medium = new STGMEDIUM { tymed = TYMED.TYMED_HGLOBAL, unionmember = memory, pUnkForRelease = null }; + try { data.SetData(ref format, ref medium, true); } + catch (COMException) { Marshal.FreeHGlobal(memory); } + catch (NotImplementedException) { Marshal.FreeHGlobal(memory); } + } + } + + [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IShellItem + { + } + + [ComImport, Guid("947aab5f-0a5c-4c13-b4d6-4bf7836fc9f8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IFileOperation + { + uint Advise(IntPtr sink); + void Unadvise(uint cookie); + void SetOperationFlags(uint flags); + void SetProgressMessage([MarshalAs(UnmanagedType.LPWStr)] string message); + void SetProgressDialog(IntPtr dialog); + void SetProperties(IntPtr properties); + void SetOwnerWindow(IntPtr owner); + void ApplyPropertiesToItem(IShellItem item); + void ApplyPropertiesToItems([MarshalAs(UnmanagedType.IUnknown)] object items); + void RenameItem(IShellItem item, [MarshalAs(UnmanagedType.LPWStr)] string newName, IntPtr sink); + void RenameItems([MarshalAs(UnmanagedType.IUnknown)] object items, [MarshalAs(UnmanagedType.LPWStr)] string newName); + void MoveItem(IShellItem item, IShellItem folder, [MarshalAs(UnmanagedType.LPWStr)] string newName, IntPtr sink); + void MoveItems([MarshalAs(UnmanagedType.IUnknown)] object items, IShellItem folder); + void CopyItem(IShellItem item, IShellItem folder, [MarshalAs(UnmanagedType.LPWStr)] string copyName, IntPtr sink); + void CopyItems([MarshalAs(UnmanagedType.IUnknown)] object items, IShellItem folder); + void DeleteItem(IShellItem item, IntPtr sink); + void DeleteItems([MarshalAs(UnmanagedType.IUnknown)] object items); + uint NewItem(IShellItem folder, uint attributes, [MarshalAs(UnmanagedType.LPWStr)] string name, [MarshalAs(UnmanagedType.LPWStr)] string template, IntPtr sink); + void PerformOperations(); + [return: MarshalAs(UnmanagedType.Bool)] bool GetAnyOperationsAborted(); + } + + // Copies or moves dropped files as one shell operation: one progress dialog, one round of + // name conflicts, one step for Ctrl+Z in Explorer. + public static class FileOperation + { + private const uint FOF_ALLOWUNDO = 0x40, FOFX_ADDUNDORECORD = 0x20000000; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern void SHCreateItemFromParsingName(string path, IntPtr bindContext, [In] ref Guid riid, out IShellItem item); + + private static IShellItem Item(string path) + { + var riid = typeof(IShellItem).GUID; + SHCreateItemFromParsingName(path, IntPtr.Zero, ref riid, out var item); + return item; + } + + // Runs on the calling thread until the shell is done; false when cancelled or failed. + public static bool Run(string[] files, string folder, bool move, IntPtr owner) + { + try + { + var operation = (IFileOperation)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("3ad05575-8857-4850-9277-11b85bdb8e09"))); + operation.SetOperationFlags(FOF_ALLOWUNDO | FOFX_ADDUNDORECORD); + if (owner != IntPtr.Zero) operation.SetOwnerWindow(owner); + var destination = Item(folder); + foreach (var file in files) + { + if (move) operation.MoveItem(Item(file), destination, null, IntPtr.Zero); + else operation.CopyItem(Item(file), destination, null, IntPtr.Zero); + } + operation.PerformOperations(); + return !operation.GetAnyOperationsAborted(); + } + catch (COMException) { return false; } + catch (ArgumentException) { return false; } + } + + // A copy can take minutes; its own STA thread keeps the tabs responsive meanwhile. + public static Thread Start(string[] files, string folder, bool move, IntPtr owner) + { + var thread = new Thread(() => Run(files, folder, move, owner)) { Name = "WindowTabs file drop" }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return thread; + } + } +} diff --git a/WtProgram/OleDropTarget.fs b/WtProgram/OleDropTarget.fs index 7181a31..23eaec9 100755 --- a/WtProgram/OleDropTarget.fs +++ b/WtProgram/OleDropTarget.fs @@ -1,118 +1,162 @@ -namespace Bemo +namespace Bemo open System -open System.Collections -open System.Drawing -open System.Drawing.Drawing2D -open System.Drawing.Imaging -open System.Reflection -open System.IO +open System.Runtime.InteropServices open System.Windows.Forms open Bemo.Win32.Forms module OleDropLifetime = - [] + [] extern int RevokeDragDrop(IntPtr hwnd) - [] + [] extern void OleUninitialize() +/// Files dragged onto a tab: resting on a tab brings its window forward, and letting go on an +/// Explorer tab moves or copies them into that folder, with the drag image and a line saying +/// which, as over Explorer itself. type OleDropTarget(ts:TabStrip) as this= - let Cell = CellScope() let os = OS() let window = os.windowFromHwnd(ts.hwnd) - let rButtonDown = Cell.create(false) - let lastTabHwndCell = Cell.create(None) - /// Looked up once per hovered tab: DragOver repeats while the pointer moves, and each - /// lookup asks every Explorer window across processes. - let mutable hoveredFolder : Shell.ShellFolder option = None let initialized = Ole2Api.OleInitialize(IntPtr.Zero)>=0 let registered = initialized && Ole2Api.RegisterDragDrop(window.hwnd, this)>=0 let mutable disposed = false + /// The drag's data and its files, from DragEnter until the drag ends. + let mutable data : Bemo.IDataObject option = None + let mutable files : string list = [] + let mutable rButtonDown = false + let mutable hoveredTab : IntPtr option = None + /// Looked up once per hovered tab: DragOver repeats while the pointer moves, and each + /// lookup asks every Explorer window across processes. + let mutable hoveredFolder : Shell.ShellFolder option = None + let mutable described = (DropImages.Invalid,"") + let mutable helper : IDropTargetHelper option = None + /// The helper once it has been told of this drag; only then does it hear the rest. + let mutable imaging : IDropTargetHelper option = None + /// A tab's window comes forward once the pointer rests on it, so sweeping a drag across + /// the strip does not flash every window it passes. + let activateTimer = new Timer(Interval=300) + + let image() = + if helper.IsNone then helper <- Option.ofObj(DropImages.CreateHelper()) + helper + + let describe (effect:DragDropEffects) = + let wanted = + match effect,hoveredFolder with + | DragDropEffects.Move,Some folder -> DropImages.Move,tr Strings.DropMenu.moveTo,folder.title + | DragDropEffects.Copy,Some folder -> DropImages.Copy,tr Strings.DropMenu.copyTo,folder.title + | _ -> DropImages.Invalid,"","" + let kind,message,insert = wanted + if (kind,insert)<>described then + described <- (kind,insert) + data |> Option.iter(fun data -> DropImages.Describe(data,kind,message,insert)) + + let effectAt (keys:int) (pt:POINTL) (allowed:int) = + // Captured here: by OleDrop the right button is already up. + rButtonDown <- keys.hasFlag(MouseMessageKeyStateMask.MK_RBUTTON) + let tab = ts.tryHit(window.ptToClient(Pt(pt.x,pt.y))) |> Option.map(fun (Tab(hwnd),_) -> hwnd) + if tab<>hoveredTab then + hoveredTab <- tab + hoveredFolder <- tab |> Option.bind Shell.getShellFolder + activateTimer.Stop() + if tab.IsSome then activateTimer.Start() + let effect = + match hoveredFolder with + | Some folder -> + DropRules.effect (enum allowed) (keys.hasFlag(MouseMessageKeyStateMask.MK_CONTROL)) + (keys.hasFlag(MouseMessageKeyStateMask.MK_SHIFT)) files folder.path + | None -> DragDropEffects.None + describe effect + int effect + + do + activateTimer.Tick.Add(fun _ -> + activateTimer.Stop() + hoveredTab |> Option.iter(fun hwnd -> + let window = os.windowFromHwnd(hwnd) + // setForegroundWindow can fail while a key such as Ctrl is held during the drag, + // and then keeps failing until a new DragEnter. + window.setForegroundOrRestore(false) + window.bringToTop())) + interface IDisposable with member _.Dispose() = if not disposed then disposed <- true + activateTimer.Dispose() + helper |> Option.iter(fun helper -> Marshal.ReleaseComObject(helper) |> ignore) + helper <- None if registered then OleDropLifetime.RevokeDragDrop(window.hwnd) |> ignore if initialized then OleDropLifetime.OleUninitialize() - member this.dragEnd() = - rButtonDown.value <- false - lastTabHwndCell.set(None) + activateTimer.Stop() + imaging <- None + describe DragDropEffects.None + rButtonDown <- false + hoveredTab <- None hoveredFolder <- None + data <- None + files <- [] interface IOleDropTarget with member x.OleDragEnter(pDataObj, grfKeyState, pt, pdwEffect) = + data <- match pDataObj with :? Bemo.IDataObject as data -> Some data | _ -> None + files <- data |> Option.map(fun data -> try List.ofSeq(OleHelper.QueryFiles(data)) with _ -> []) |> Option.defaultValue [] + described <- (DropImages.Invalid,"") + let effect = effectAt grfKeyState pt pdwEffect + pdwEffect <- effect + match image(),data with + | Some helper,Some data -> + let mutable point = pt + helper.DragEnter(ts.hwnd,data,&point,effect) + imaging <- Some helper + | _ -> () 0 - member x.OleDragOver(grfKeyState, pt, pdwEffect) = - //need to capture this here because it will be already released in the OleDrop handler - rButtonDown.value <- grfKeyState.hasFlag(MouseMessageKeyStateMask.MK_RBUTTON) - - let ptScreen = Pt(pt.x, pt.y) - let pt = window.ptToClient(Pt(pt.x, pt.y)) - - let tabHwnd = ts.tryHit(pt).map(fun(Tab(hwnd),part) -> hwnd) - if lastTabHwndCell.value <> tabHwnd then - hoveredFolder <- tabHwnd |> Option.bind Shell.getShellFolder - tabHwnd.iter <| fun hwnd -> - let window = os.windowFromHwnd(hwnd) - //setForegroundWindow will fail sometimes if a key is pressed during DragOver - //like CTRL. we will be unable to call setForeground until a new DragEnter is generated - window.setForegroundOrRestore(false) - window.bringToTop() - lastTabHwndCell.set(tabHwnd) - pdwEffect <- - match hoveredFolder with - | Some _ when grfKeyState.hasFlag(MouseMessageKeyStateMask.MK_CONTROL) -> int(DragDropEffects.Copy) - | Some _ -> int(DragDropEffects.Move) - | None -> int(DragDropEffects.None) + member x.OleDragOver(grfKeyState, pt, pdwEffect) = + let effect = effectAt grfKeyState pt pdwEffect + pdwEffect <- effect + imaging |> Option.iter(fun helper -> + let mutable point = pt + helper.DragOver(&point,effect)) 0 - member x.OleDragLeave() = + member x.OleDragLeave() = + imaging |> Option.iter(fun helper -> helper.DragLeave()) this.dragEnd() 0 - member x.OleDrop(pDataObj, grfKeyState, pt, pdwEffect) = + member x.OleDrop(pDataObj, grfKeyState, pt, pdwEffect) = + let allowed = enum pdwEffect let ptScreen = Pt(pt.x, pt.y) - let pt = window.ptToClient(ptScreen) - ts.tryHit(pt).iter <| fun(Tab(hwnd),part) -> - let folder = if lastTabHwndCell.value=Some hwnd then hoveredFolder else Shell.getShellFolder hwnd - folder.iter <| fun shellFolder -> - let files = List2(OleHelper.QueryFiles(pDataObj)) - let shellOp op = fun() -> - files.iter <| fun file -> - op(file) - let copy = shellOp <| fun file -> shellFolder.CopyHere(file) - let move = shellOp <| fun file -> shellFolder.MoveHere(file) - if rButtonDown.value then - Win32Menu.show window.hwnd ptScreen (List2([ - CmiRegular({ - text = tr Strings.DropMenu.copy - image = None - flags = List2() - click = copy - }) - CmiRegular({ - text = tr Strings.DropMenu.move - image = None - flags = List2() - click = move - }) - CmiSeparator - CmiRegular({ - text = tr Strings.Common.cancel - image = None - flags = List2() - click = fun() -> () - }) - ])) - elif grfKeyState.hasFlag(MouseMessageKeyStateMask.MK_CONTROL) then - copy() + let effect = effectAt grfKeyState pt pdwEffect + imaging |> Option.iter(fun helper -> + let mutable point = pt + helper.Drop(pDataObj,&point,effect)) + let menuFor = rButtonDown + ts.tryHit(window.ptToClient(ptScreen)).iter <| fun(Tab(hwnd),part) -> + let folder = if hoveredTab=Some hwnd then hoveredFolder else Shell.getShellFolder hwnd + folder.iter <| fun folder -> + let dropped = Array.ofList files + let run move = FileOperation.Start(dropped,folder.path,move,hwnd) |> ignore + if menuFor then + // The menu offers what the source allows, once there is something to do. + if DropRules.effect allowed false false files folder.path<>DragDropEffects.None then + let item text click = CmiRegular({ text=text; image=None; flags=List2(); click=click }) + Win32Menu.show window.hwnd ptScreen (List2([ + if allowed.HasFlag(DragDropEffects.Copy) then item (tr Strings.DropMenu.copy) (fun () -> run false) + if allowed.HasFlag(DragDropEffects.Move) then item (tr Strings.DropMenu.move) (fun () -> run true) + CmiSeparator + item (tr Strings.Common.cancel) ignore + ])) else - move() - // The shell has done the work. Any other effect would let the source act on it + match enum effect with + | DragDropEffects.Move -> run true + | DragDropEffects.Copy -> run false + | _ -> () + // The shell does the work. Any other effect would let the source act on the files // again; after a move it could delete what a skipped name conflict left behind. pdwEffect <- int(DragDropEffects.None) this.dragEnd() diff --git a/WtProgram/Shared/Shell.fs b/WtProgram/Shared/Shell.fs index de1aab8..7fee004 100755 --- a/WtProgram/Shared/Shell.fs +++ b/WtProgram/Shared/Shell.fs @@ -1,6 +1,8 @@ namespace Bemo open System +open System.IO open System.Reflection +open System.Windows.Forms /// Explorer windows through the Shell.Application automation object, late bound so no /// interop assemblies ship with the app. @@ -10,15 +12,12 @@ module Shell = let private get target name = call target name BindingFlags.GetProperty [||] let private invoke target name args = call target name BindingFlags.InvokeMethod args - /// FOF_ALLOWUNDO, so Ctrl+Z in Explorer takes a drop back. - let private allowUndo = 0x40 + /// A folder on disk an Explorer window shows, and the name it shows for it. + type ShellFolder = { path:string; title:string } - type ShellFolder(folder:obj) = - member _.CopyHere(path:string) = invoke folder "CopyHere" [|box path;box allowUndo|] |> ignore - member _.MoveHere(path:string) = invoke folder "MoveHere" [|box path;box allowUndo|] |> ignore - - /// The folder an Explorer window shows; None for any other window. Each window is - /// matched by its handle first, so a view without a folder elsewhere cannot fail the search. + /// The file system folder an Explorer window shows; None for any other window, and for + /// views such as This PC or the Recycle Bin. Each window is matched by its handle first, + /// so a view without a folder elsewhere cannot fail the search. let getShellFolder (hwnd:IntPtr) = let folderOf (windows:obj) index = try @@ -27,7 +26,10 @@ module Shell = | window when Convert.ToInt64(get window "HWND")=hwnd.ToInt64() -> match get (get window "Document") "Folder" with | null -> None - | folder -> Some(ShellFolder(folder)) + | folder -> + let self = get folder "Self" + if unbox(get self "IsFileSystem") then Some { path=string(get self "Path"); title=string(get folder "Title") } + else None | _ -> None with _ -> None if hwnd=IntPtr.Zero then None @@ -40,3 +42,31 @@ module Shell = let count = Convert.ToInt32(get windows "Count") List.init count id |> List.tryPick (folderOf windows) with _ -> None + +/// What dropping files on an Explorer tab does, decided as Explorer decides: a move within one +/// drive and a copy across drives, Ctrl to copy and Shift to move. Files already in the folder, +/// such as those dragged back onto their own window's tab, are left where they are. +module DropRules = + let private same (a:string) (b:string) = String.Equals(a,b,StringComparison.OrdinalIgnoreCase) + let effect (allowed:DragDropEffects) ctrl shift (files:string list) (folder:string) = + try + let trimmed (path:string) = path.TrimEnd('\\') + let inFolder file = + match Path.GetDirectoryName(file:string) with + | null -> false + | parent -> same (trimmed parent) (trimmed folder) + let sameDrive = files |> List.forall(fun file -> same (Path.GetPathRoot(file:string)) (Path.GetPathRoot(folder))) + let wanted = + // A drive itself has no folder to leave; Explorer would only link to it. + let isRoot file = isNull (Path.GetDirectoryName(file:string)) + if files.IsEmpty || List.exists isRoot files || List.forall inFolder files || (ctrl && shift) then DragDropEffects.None + elif ctrl then DragDropEffects.Copy + elif shift || sameDrive then DragDropEffects.Move + else DragDropEffects.Copy + // A source that allows only one of the two gets that one, unless a key asked otherwise. + let other = if wanted=DragDropEffects.Move then DragDropEffects.Copy else DragDropEffects.Move + if wanted=DragDropEffects.None then DragDropEffects.None + elif allowed.HasFlag(wanted) then wanted + elif not ctrl && not shift && allowed.HasFlag(other) then other + else DragDropEffects.None + with _ -> DragDropEffects.None diff --git a/WtProgram/Shared/Strings.fs b/WtProgram/Shared/Strings.fs index 19332e5..41e3dda 100644 --- a/WtProgram/Shared/Strings.fs +++ b/WtProgram/Shared/Strings.fs @@ -67,6 +67,9 @@ module Strings = module DropMenu = let copy = { en="Copy"; zh="复制"; ja="コピー" } let move = { en="Move"; zh="移动"; ja="移動" } + /// Under the drag image; the shell puts the folder's name in place of %1. + let moveTo = { en="Move to %1"; zh="移动到 %1"; ja="%1 へ移動" } + let copyTo = { en="Copy to %1"; zh="复制到 %1"; ja="%1 へコピー" } /// Settings window chrome and page titles. module SettingsWindow = diff --git a/tests/Architecture.fsx b/tests/Architecture.fsx index 41e2b38..73af06f 100644 --- a/tests/Architecture.fsx +++ b/tests/Architecture.fsx @@ -383,6 +383,39 @@ let main() = do use plain = new Form(ShowInTaskbar=false) check (Shell.getShellFolder IntPtr.Zero=None && Shell.getShellFolder plain.Handle=None) "A window without an Explorer folder was taken as a drop folder" + // Dropping decides as Explorer does, and leaves files already in the folder alone. + do + let any = DragDropEffects.Copy ||| DragDropEffects.Move ||| DragDropEffects.Link + let effect allowed ctrl shift files folder = DropRules.effect allowed ctrl shift files folder + check (effect any false false [@"C:\Work\a.txt"] @"C:\Archive"=DragDropEffects.Move) "A drop within one drive did not move" + check (effect any false false [@"D:\Work\a.txt"] @"C:\Archive"=DragDropEffects.Copy) "A drop from another drive did not copy" + check (effect any false false [@"\\server\share\a.txt"] @"\\SERVER\Share\Docs"=DragDropEffects.Move) "A drop within one network share did not move" + check (effect any true false [@"C:\Work\a.txt"] @"C:\Archive"=DragDropEffects.Copy) "Ctrl did not copy" + check (effect any false true [@"D:\Work\a.txt"] @"C:\Archive"=DragDropEffects.Move) "Shift did not move" + check (effect any true true [@"C:\Work\a.txt"] @"C:\Archive"=DragDropEffects.None) "Ctrl+Shift, a link, was taken as a move or copy" + check (effect any false false [@"C:\Work\a.txt"] @"c:\work\"=DragDropEffects.None) "Files dropped back on their own folder's tab were moved" + check (effect any false false [] @"C:\Archive"=DragDropEffects.None && effect any false false [@"D:\"] @"C:\Archive"=DragDropEffects.None) + "A drag without files, or of a whole drive, was accepted" + check (effect DragDropEffects.Copy false false [@"C:\Work\a.txt"] @"C:\Archive"=DragDropEffects.Copy && + effect DragDropEffects.Copy false true [@"C:\Work\a.txt"] @"C:\Archive"=DragDropEffects.None) + "A source that allows only copying was moved from, or a key it cannot honour was ignored" + // Several files go in one shell operation: copied ones stay behind, moved ones do not. + do + let root = Path.Combine(Path.GetTempPath(),"wt-drop-"+Guid.NewGuid().ToString("N")) + let source,target = Path.Combine(root,"source"),Path.Combine(root,"target") + Directory.CreateDirectory(source) |> ignore + Directory.CreateDirectory(target) |> ignore + try + let files = [| for name in ["a.txt";"b.txt"] -> Path.Combine(source,name) |] + for file in files do File.WriteAllText(file,file) + let copied = FileOperation.Run(files,target,false,IntPtr.Zero) + check (copied && files |> Array.forall(fun file -> File.Exists(file) && File.Exists(Path.Combine(target,Path.GetFileName(file))))) + "Dropped files were not all copied, or the originals went" + let moving = Path.Combine(source,"c.txt") + File.WriteAllText(moving,"c") + let moved = FileOperation.Run([|moving|],target,true,IntPtr.Zero) + check (moved && not (File.Exists(moving)) && File.Exists(Path.Combine(target,"c.txt"))) "A dropped file was not moved" + finally Directory.Delete(root,true) let mask = AltMenuMask.inputs() check (mask.Length=2 && mask.[0].mkhi.ki.wVk=0xE8s && mask.[1].mkhi.ki.dwFlags=SendInputConstants.KEYEVENTF_KEYUP && mask.[0].mkhi.ki.dwExtraInfo=IntPtr(AltMenuMask.marker)) "Alt menu mask must pair and mark injected events" check (capture.handle(WindowMessages.WM_KEYDOWN,0x31,Some 0)=(true,Some 0)) "Matched digit must be swallowed and activated" From 2527094465b98e1d035d737fa9188b09fb80eaa8 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 15:42:23 +0800 Subject: [PATCH 05/13] Explain dropping files on tabs and the administrator limit --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 38eba12..cedb36b 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ 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. +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, +right-drag asks, and **Ctrl + Z** in Explorer undoes it. + ## Features - **Appearance:** light, dark or follow Windows; Joined, Folder or Floating tabs; @@ -72,6 +76,8 @@ To update, exit WindowTabs from the tray and replace the exe. Settings are kept. (your old settings are backed up first). - **Run as administrator?** Not needed and not recommended. Windows blocks some input between admin and normal windows. +- **Dropping files on a tab does nothing?** Windows blocks dragging from an app into + one running as administrator. Still stuck? [Open an issue](https://github.com/leafOfTree/WindowTabs/issues) and paste the troubleshooting report from **Settings → Support**. It leaves out window titles From 52dccaaecc8aa0f810cc76703e9bc93ecfa5930a Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 15:59:05 +0800 Subject: [PATCH 06/13] Drop into the Explorer tab on show and refresh Explorer after a drop A Windows 11 Explorer window holds a folder per tab; a drop on its WindowTabs tab now goes to the folder on show instead of the first one. Dropped files also appear and disappear in open Explorer windows at once: the shell's change notices are flushed before the operation's thread ends, where they were lost. --- Win32/ShellDrop.cs | 57 +++++++++++++++++++++++++++++++++++++++ WtProgram/Shared/Shell.fs | 31 ++++++++++++++------- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/Win32/ShellDrop.cs b/Win32/ShellDrop.cs index 656f80b..a8da68f 100644 --- a/Win32/ShellDrop.cs +++ b/Win32/ShellDrop.cs @@ -100,6 +100,9 @@ public static class FileOperation { private const uint FOF_ALLOWUNDO = 0x40, FOFX_ADDUNDORECORD = 0x20000000; + [DllImport("shell32.dll")] + private static extern void SHChangeNotify(int eventId, uint flags, IntPtr item1, IntPtr item2); + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] private static extern void SHCreateItemFromParsingName(string path, IntPtr bindContext, [In] ref Guid riid, out IShellItem item); @@ -129,6 +132,13 @@ public static bool Run(string[] files, string folder, bool move, IntPtr owner) } catch (COMException) { return false; } catch (ArgumentException) { return false; } + finally + { + // Explorer learns of shell operations only from their change notices, which wait + // in this thread's queue; flushed now, before the thread ends, or open windows + // never show the files arrive or leave. + SHChangeNotify(0, 0x1000, IntPtr.Zero, IntPtr.Zero); + } } // A copy can take minutes; its own STA thread keeps the tabs responsive meanwhile. @@ -141,3 +151,50 @@ public static Thread Start(string[] files, string folder, bool move, IntPtr owne } } } + +namespace Bemo +{ + // Explorer on Windows 11 keeps several folders in one window, one per tab; only the tab on + // show is where a drop on that window should go. + public static class ExplorerTabs + { + [ComImport, Guid("6d5140c1-7436-11ce-8034-00aa006009fa"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IOleServiceProvider + { + [PreserveSig] int QueryService([In] ref Guid service, [In] ref Guid riid, [MarshalAs(UnmanagedType.IUnknown)] out object found); + } + + // Only IOleWindow::GetWindow, the first method, is called. + [ComImport, Guid("000214E2-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellBrowser + { + [PreserveSig] int GetWindow(out IntPtr hwnd); + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string className, string title); + + // The tab on show is the first of the window's tab children. + public static IntPtr ActiveTab(IntPtr explorer) + { + return FindWindowEx(explorer, IntPtr.Zero, "ShellTabWindowClass", null); + } + + // The tab window behind one of Shell.Application's windows; zero when it cannot tell. + public static IntPtr TabOf(object browser) + { + try + { + var provider = browser as IOleServiceProvider; + if (provider == null) return IntPtr.Zero; + var service = new Guid("4C96BE40-915C-11CF-99D3-00AA004AE837"); + var riid = typeof(IShellBrowser).GUID; + if (provider.QueryService(ref service, ref riid, out var found) != 0) return IntPtr.Zero; + var shellBrowser = found as IShellBrowser; + return shellBrowser != null && shellBrowser.GetWindow(out var hwnd) == 0 ? hwnd : IntPtr.Zero; + } + catch (COMException) { return IntPtr.Zero; } + catch (InvalidCastException) { return IntPtr.Zero; } + } + } +} diff --git a/WtProgram/Shared/Shell.fs b/WtProgram/Shared/Shell.fs index 7fee004..fd2dca3 100755 --- a/WtProgram/Shared/Shell.fs +++ b/WtProgram/Shared/Shell.fs @@ -17,21 +17,25 @@ module Shell = /// The file system folder an Explorer window shows; None for any other window, and for /// views such as This PC or the Recycle Bin. Each window is matched by its handle first, - /// so a view without a folder elsewhere cannot fail the search. + /// so a view without a folder elsewhere cannot fail the search. A window holding several + /// Explorer tabs answers with the tab on show. let getShellFolder (hwnd:IntPtr) = - let folderOf (windows:obj) index = + let windowAt (windows:obj) index = try match invoke windows "Item" [|box index|] with | null -> None - | window when Convert.ToInt64(get window "HWND")=hwnd.ToInt64() -> - match get (get window "Document") "Folder" with - | null -> None - | folder -> - let self = get folder "Self" - if unbox(get self "IsFileSystem") then Some { path=string(get self "Path"); title=string(get folder "Title") } - else None + | window when Convert.ToInt64(get window "HWND")=hwnd.ToInt64() -> Some window | _ -> None with _ -> None + let folderOf window = + try + match get (get window "Document") "Folder" with + | null -> None + | folder -> + let self = get folder "Self" + if unbox(get self "IsFileSystem") then Some { path=string(get self "Path"); title=string(get folder "Title") } + else None + with _ -> None if hwnd=IntPtr.Zero then None else try @@ -40,7 +44,14 @@ module Shell = | shellType -> let windows = invoke (Activator.CreateInstance(shellType)) "Windows" [||] let count = Convert.ToInt32(get windows "Count") - List.init count id |> List.tryPick (folderOf windows) + match List.init count id |> List.choose (windowAt windows) with + | [] -> None + | [single] -> folderOf single + | tabs -> + let active = ExplorerTabs.ActiveTab(hwnd) + tabs |> List.tryFind(fun tab -> active<>IntPtr.Zero && ExplorerTabs.TabOf(tab)=active) + |> Option.orElse (List.tryHead tabs) + |> Option.bind folderOf with _ -> None /// What dropping files on an Explorer tab does, decided as Explorer decides: a move within one From aae01d0d7b367841041638df7c1e1538fc74d754 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 16:07:43 +0800 Subject: [PATCH 07/13] Refresh the folders a drop touched in open Explorer windows Flushing the shell's queued notices did nothing, as the flush flag cannot stand alone; each folder the operation touched is now told to refresh, delivered before the operation's thread ends. --- Win32/ShellDrop.cs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Win32/ShellDrop.cs b/Win32/ShellDrop.cs index a8da68f..3ae31f4 100644 --- a/Win32/ShellDrop.cs +++ b/Win32/ShellDrop.cs @@ -100,8 +100,8 @@ public static class FileOperation { private const uint FOF_ALLOWUNDO = 0x40, FOFX_ADDUNDORECORD = 0x20000000; - [DllImport("shell32.dll")] - private static extern void SHChangeNotify(int eventId, uint flags, IntPtr item1, IntPtr item2); + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern void SHChangeNotify(int eventId, uint flags, string item1, IntPtr item2); [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] private static extern void SHCreateItemFromParsingName(string path, IntPtr bindContext, [In] ref Guid riid, out IShellItem item); @@ -134,10 +134,18 @@ public static bool Run(string[] files, string folder, bool move, IntPtr owner) catch (ArgumentException) { return false; } finally { - // Explorer learns of shell operations only from their change notices, which wait - // in this thread's queue; flushed now, before the thread ends, or open windows - // never show the files arrive or leave. - SHChangeNotify(0, 0x1000, IntPtr.Zero, IntPtr.Zero); + // Open Explorer windows ignore the file system's own events for a shell operation + // and wait for its change notices, which did not reach them from this thread. Each + // folder touched is told to refresh, delivered before this returns. + const int SHCNE_UPDATEDIR = 0x1000; + const uint SHCNF_PATHW = 0x5, SHCNF_FLUSH = 0x1000; + var folders = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase) { folder }; + foreach (var file in files) + { + var parent = System.IO.Path.GetDirectoryName(file); + if (!string.IsNullOrEmpty(parent)) folders.Add(parent); + } + foreach (var changed in folders) SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW | SHCNF_FLUSH, changed, IntPtr.Zero); } } From c5a3f9009b3ed0f0b88852050958bcd6a339b736 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 16:07:43 +0800 Subject: [PATCH 08/13] Offer the crash log only when it holds a crash An emptied WindowTabsCrash.log still showed the Crash log link and opened a blank file; logs without an entry are now passed over, for the link and the tray notice alike. --- WtProgram/Shared/RuntimeDiagnostics.fs | 21 ++++++++++++--------- tests/Reliability.fsx | 3 +++ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/WtProgram/Shared/RuntimeDiagnostics.fs b/WtProgram/Shared/RuntimeDiagnostics.fs index 171a2f4..815a4e4 100644 --- a/WtProgram/Shared/RuntimeDiagnostics.fs +++ b/WtProgram/Shared/RuntimeDiagnostics.fs @@ -119,15 +119,6 @@ module RuntimeDiagnostics = let errorLogged = errorLoggedEvent.Publish let notifyErrorLogged() = errorLoggedEvent.Trigger() - /// The newest WindowTabsCrash.log, beside the exe or in AppData, where the crash handler writes it. - let crashLogPath() = - [AppDomain.CurrentDomain.BaseDirectory - IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),"WindowTabs")] - |> List.map(fun folder -> IO.Path.Combine(folder,"WindowTabsCrash.log")) - |> List.filter IO.File.Exists - |> List.sortByDescending IO.File.GetLastWriteTime - |> List.tryHead - /// WindowTabsCrash.log: entries newest first, each starting with a line of dashes, then /// Time, Source, Version and OS, then the exception as .NET prints it. module CrashLog = @@ -186,6 +177,18 @@ module RuntimeDiagnostics = |> Option.defaultValue ""), JProperty("stack",JArray(stack |> Array.map box))) + /// The newest WindowTabsCrash.log with a crash in it, beside the exe or in AppData, where the + /// crash handler writes it. A log left empty is none: Support would offer a blank file. + let crashLogPath() = + [AppDomain.CurrentDomain.BaseDirectory + IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),"WindowTabs")] + |> List.map(fun folder -> IO.Path.Combine(folder,"WindowTabsCrash.log")) + |> List.filter(fun path -> + try IO.File.Exists(path) && not (CrashLog.entries (IO.File.ReadAllText(path))).IsEmpty + with _ -> false) + |> List.sortByDescending IO.File.GetLastWriteTime + |> List.tryHead + /// The latest crashes, newest first, as far as they are safe to share; see CrashLog.summary. let private recentCrashes() = match crashLogPath() with diff --git a/tests/Reliability.fsx b/tests/Reliability.fsx index e8178f5..b130ea0 100644 --- a/tests/Reliability.fsx +++ b/tests/Reliability.fsx @@ -248,6 +248,9 @@ let main() = (sprintf "Crash log is not newest first: %A" times) let short = RuntimeDiagnostics.CrashLog.prepend (entry "2026-01-03 09:00:00" "System.NullReferenceException") (IO.File.ReadAllText(crashLog)) 800 check (short.Contains("2026-01-03") && not (short.Contains("2026-01-01"))) "Crash log limit did not drop the oldest entries" + // A log left empty offers nothing to open. + IO.File.WriteAllText(crashLog,"") + check (RuntimeDiagnostics.crashLogPath()<>Some crashLog) "An empty crash log was offered to open" finally match existingLog with | Some text -> IO.File.WriteAllText(crashLog,text) From f810a5496bbcd0b248e86b612475ad320bb8f774 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 16:07:43 +0800 Subject: [PATCH 09/13] Set the crash log link apart at the right of the Support links Files on this PC end the row flush right, apart from the web links on the left, and move to their own line, still at the right, when the row is too narrow. --- .../ManagerViewService/SettingsListPage.fs | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/WtProgram/ManagerViewService/SettingsListPage.fs b/WtProgram/ManagerViewService/SettingsListPage.fs index c2c7b67..a008a31 100644 --- a/WtProgram/ManagerViewService/SettingsListPage.fs +++ b/WtProgram/ManagerViewService/SettingsListPage.fs @@ -20,22 +20,32 @@ type SettingsListPage(title:string, description:string, list:Control, actions:Co let status = new Label(AutoSize=false,AutoEllipsis=true,TextAlign=ContentAlignment.MiddleRight,Tag="muted",UseMnemonic=false) let actionRow = new FlowLayoutPanel(AutoSize=true,WrapContents=false,FlowDirection=FlowDirection.LeftToRight) let card = new Panel(Tag="surface") + /// Web links start the row; files on this PC, such as the crash log, stand apart at its right end. let linkRow = links |> Option.filter(List.isEmpty >> not) |> Option.map(fun links -> - let row = new FlowLayoutPanel(AutoSize=true,WrapContents=true) + let group () = new FlowLayoutPanel(AutoSize=false,WrapContents=true,Margin=Padding.Empty,Padding=Padding.Empty) + let web,files = group(),group() for text,url in links do // A file on this PC opens in Explorer, and is marked with a folder, not an arrow. let kind = if url.StartsWith("http",StringComparison.OrdinalIgnoreCase) then WebLink else FileLink - // The left margin matches the inset a Label gives its text, so links line up with the headings. - let link = new SettingsLink(text,kind,Margin=Padding(Dpi.scale 3,Dpi.scale 3,Dpi.scale 15,Dpi.scale 3)) + // Web links keep the inset a Label gives its text, lining up with the headings; files + // end flush with the page's right edge. + let margin = if kind=WebLink then Padding(Dpi.scale 3,Dpi.scale 3,Dpi.scale 15,Dpi.scale 3) + else Padding(Dpi.scale 15,Dpi.scale 3,0,Dpi.scale 3) + let link = new SettingsLink(text,kind,Margin=margin) link.Click.Add(fun _ -> try if IO.File.Exists(url) then Diagnostics.Process.Start("explorer.exe",sprintf "/select,\"%s\"" url) |> ignore else Diagnostics.Process.Start(url) |> ignore with _ -> ()) SettingsHover(link,url) |> ignore - row.Controls.Add(link) - row) + (if kind=WebLink then web else files).Controls.Add(link) + let row = new Panel(Margin=Padding.Empty) + row.Controls.AddRange([|web :> Control;files|]) + row,web,files) + let allLinks() = + linkRow |> Option.toList |> List.collect(fun (_,web,files) -> + [web;files] |> List.collect(fun group -> group.Controls |> Seq.cast |> List.ofSeq)) do this.Dock <- DockStyle.Fill this.DoubleBuffered <- true @@ -58,22 +68,20 @@ type SettingsListPage(title:string, description:string, list:Control, actions:Co card.Resize.Add(fun _ -> card.Invalidate()) this.Controls.AddRange([|heading :> Control;detail;actionRow;status;card|]) helpButton |> Option.iter(fun button -> this.Controls.Add(button)) - linkRow |> Option.iter(fun row -> this.Controls.Add(row)) + linkRow |> Option.iter(fun (row,_,_) -> this.Controls.Add(row)) extra |> Option.iter(fun control -> this.Controls.Add(control)) footer |> Option.iter(fun control -> this.Controls.Add(control)) // The link row is measured in OnLayout. The extra and footer blocks size themselves: rows only settle // once they have their real width, which can be after the pass that placed it, so a size // change lays the page out again afterwards rather than inside that pass. - linkRow |> Option.iter(fun row -> row.AutoSize <- false) Option.toList extra @ Option.toList footer |> List.iter(fun control -> control.SizeChanged.Add(fun _ -> if this.IsHandleCreated && not relayoutPending then relayoutPending <- true this.BeginInvoke(Action(fun () -> relayoutPending <- false; this.PerformLayout())) |> ignore)) ThemeBinding.watch this (fun () -> - linkRow |> Option.iter(fun row -> - let p = SettingsColors.current() - for link in row.Controls |> Seq.cast do link.LinkColor <- p.accent) + let p = SettingsColors.current() + for link in allLinks() do link.LinkColor <- p.accent list.Invalidate() card.Invalidate()) /// Short note above the list (scan progress, counts, errors). @@ -99,9 +107,18 @@ type SettingsListPage(title:string, description:string, list:Control, actions:Co elif detail.Visible then detail.Bottom+Dpi.scale 16 else heading.Bottom+Dpi.scale 16 match linkRow with - | Some row -> - let size = row.GetPreferredSize(Size(width,0)) - row.Bounds <- Rectangle(left,y,min width size.Width,size.Height) + | Some(row,web,files) -> + // Side by side when they fit; otherwise the files take the next line, still at the right. + let filesSize = if files.Controls.Count=0 then Size.Empty else files.GetPreferredSize(Size.Empty) + let gap = Dpi.scale 16 + let webSize = web.GetPreferredSize(Size(width,0)) + let oneLine = web.GetPreferredSize(Size.Empty) + let besideWeb = oneLine.Width+gap+filesSize.Width <= width + web.Bounds <- Rectangle(0,0,min width (if besideWeb then oneLine.Width else webSize.Width),if besideWeb then oneLine.Height else webSize.Height) + let filesTop = if besideWeb || web.Controls.Count=0 then 0 else web.Bottom + files.Bounds <- Rectangle(width-filesSize.Width,filesTop,filesSize.Width,filesSize.Height) + files.Visible <- files.Controls.Count>0 + row.Bounds <- Rectangle(left,y,width,max web.Bottom (if files.Visible then files.Bottom else 0)) y <- row.Bottom+Dpi.scale 14 | None -> () match extra with @@ -118,9 +135,10 @@ type SettingsListPage(title:string, description:string, list:Control, actions:Co let top = if noActions then match linkRow with - | Some row -> - let x = row.Right+Dpi.scale 16 - status.Bounds <- Rectangle(x,row.Top,max 0 (left+width-x),row.Height) + | Some(row,web,files) -> + let x = row.Left+web.Right+Dpi.scale 16 + let right = if files.Visible then row.Left+files.Left-Dpi.scale 16 else left+width + status.Bounds <- Rectangle(x,row.Top,max 0 (right-x),web.Height) y | None -> status.Bounds <- Rectangle(left,y,width,Dpi.scale 24) From 70ee90cb14f6cb8fe9e3e1b831a02830eaedccac Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 16:27:58 +0800 Subject: [PATCH 10/13] Keep the folder's window in front after a drop The progress dialog took the foreground and, closing, could leave it to no window or another app's hidden one, so keys after a drop went nowhere. The folder's window gets it back then; a window the user moved on to keeps it. --- Win32/ShellDrop.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Win32/ShellDrop.cs b/Win32/ShellDrop.cs index 3ae31f4..284dc69 100644 --- a/Win32/ShellDrop.cs +++ b/Win32/ShellDrop.cs @@ -106,6 +106,25 @@ public static class FileOperation [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] private static extern void SHCreateItemFromParsingName(string path, IntPtr bindContext, [In] ref Guid riid, out IShellItem item); + [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hwnd); + [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId); + + // The progress dialog takes the foreground and, closing, can leave it to no window or to + // another app's hidden one, so keys after a drop went nowhere. The folder's window gets it + // back then; a window the user has moved on to keeps it. + private static void ReturnFocus(IntPtr owner) + { + if (owner == IntPtr.Zero) return; + var foreground = GetForegroundWindow(); + if (foreground == owner) return; + uint process; + GetWindowThreadProcessId(foreground, out process); + var ours = foreground != IntPtr.Zero && process == (uint)System.Diagnostics.Process.GetCurrentProcess().Id; + if (foreground == IntPtr.Zero || ours || !IsWindowVisible(foreground)) SetForegroundWindow(owner); + } + private static IShellItem Item(string path) { var riid = typeof(IShellItem).GUID; @@ -146,6 +165,7 @@ public static bool Run(string[] files, string folder, bool move, IntPtr owner) if (!string.IsNullOrEmpty(parent)) folders.Add(parent); } foreach (var changed in folders) SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW | SHCNF_FLUSH, changed, IntPtr.Zero); + ReturnFocus(owner); } } From 7daa78ef1e97c49206c58e01272428e15ceb0a7e Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 16:27:58 +0800 Subject: [PATCH 11/13] Offer the right-drag menu again, in the app's theme The drop recorded the released right button before reading it, so a right-drag moved or copied without asking. The menu is now the themed one the tab menu uses. --- WtProgram/OleDropTarget.fs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/WtProgram/OleDropTarget.fs b/WtProgram/OleDropTarget.fs index 23eaec9..2f128df 100755 --- a/WtProgram/OleDropTarget.fs +++ b/WtProgram/OleDropTarget.fs @@ -36,6 +36,8 @@ type OleDropTarget(ts:TabStrip) as this= /// A tab's window comes forward once the pointer rests on it, so sweeping a drag across /// the strip does not flash every window it passes. let activateTimer = new Timer(Interval=300) + /// The right-drag menu, themed like the tab menu; replaced, not disposed, from its own events. + let mutable dropMenu : ThemedContextMenu option = None let image() = if helper.IsNone then helper <- Option.ofObj(DropImages.CreateHelper()) @@ -85,6 +87,8 @@ type OleDropTarget(ts:TabStrip) as this= if not disposed then disposed <- true activateTimer.Dispose() + dropMenu |> Option.iter(fun menu -> menu.Dispose()) + dropMenu <- None helper |> Option.iter(fun helper -> Marshal.ReleaseComObject(helper) |> ignore) helper <- None if registered then OleDropLifetime.RevokeDragDrop(window.hwnd) |> ignore @@ -131,11 +135,12 @@ type OleDropTarget(ts:TabStrip) as this= member x.OleDrop(pDataObj, grfKeyState, pt, pdwEffect) = let allowed = enum pdwEffect let ptScreen = Pt(pt.x, pt.y) + // Read before effectAt, which records the keys of this call: the right button is up by now. + let menuFor = rButtonDown let effect = effectAt grfKeyState pt pdwEffect imaging |> Option.iter(fun helper -> let mutable point = pt helper.Drop(pDataObj,&point,effect)) - let menuFor = rButtonDown ts.tryHit(window.ptToClient(ptScreen)).iter <| fun(Tab(hwnd),part) -> let folder = if hoveredTab=Some hwnd then hoveredFolder else Shell.getShellFolder hwnd folder.iter <| fun folder -> @@ -145,12 +150,16 @@ type OleDropTarget(ts:TabStrip) as this= // The menu offers what the source allows, once there is something to do. if DropRules.effect allowed false false files folder.path<>DragDropEffects.None then let item text click = CmiRegular({ text=text; image=None; flags=List2(); click=click }) - Win32Menu.show window.hwnd ptScreen (List2([ - if allowed.HasFlag(DragDropEffects.Copy) then item (tr Strings.DropMenu.copy) (fun () -> run false) - if allowed.HasFlag(DragDropEffects.Move) then item (tr Strings.DropMenu.move) (fun () -> run true) - CmiSeparator - item (tr Strings.Common.cancel) ignore - ])) + let items = List2([ + if allowed.HasFlag(DragDropEffects.Copy) then item (tr Strings.DropMenu.copy) (fun () -> run false) + if allowed.HasFlag(DragDropEffects.Move) then item (tr Strings.DropMenu.move) (fun () -> run true) + CmiSeparator + item (tr Strings.Common.cancel) ignore ]) + dropMenu |> Option.iter(fun menu -> menu.Dispose()) + // Open over the folder's window, which the drag has brought forward. + let menu = new ThemedContextMenu(items,ignore,(fun foreground -> foreground=hwnd)) + dropMenu <- Some menu + menu.Show(window.hwnd,ptScreen.x,ptScreen.y) else match enum effect with | DragDropEffects.Move -> run true From ac7531dbb8b4e19f54872f7542a7e10404444fde Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 16:27:58 +0800 Subject: [PATCH 12/13] Keep title tips off open menus --- WtProgram/Shared/ThemedMenu.fs | 17 +++++++++++++++++ WtProgram/TabStrip.fs | 2 +- tests/TabInteraction.fsx | 10 ++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/WtProgram/Shared/ThemedMenu.fs b/WtProgram/Shared/ThemedMenu.fs index 8d42692..1040582 100644 --- a/WtProgram/Shared/ThemedMenu.fs +++ b/WtProgram/Shared/ThemedMenu.fs @@ -71,6 +71,12 @@ type ThemeMenuRenderer(p:SettingsPalette) = /// Own image copies until the menu loop has finished closing, then dispatch the command. /// ownsForeground: windows whose activation must not close the menu, such as the tab's own /// window that the click which opened it is still bringing forward. +/// Whether any themed menu is open, on any thread, so a tab's title tip does not cover it. +module OpenMenus = + let private count = ref 0 + let change delta = lock count (fun () -> count.Value <- count.Value+delta) + let any () = lock count (fun () -> count.Value>0) + type ThemedContextMenu(items:List2,closed:unit -> unit,?ownsForeground:IntPtr -> bool) as this = inherit ContextMenuStrip() let palette = SettingsColors.current() @@ -82,6 +88,7 @@ type ThemedContextMenu(items:List2,closed:unit -> unit,?ownsFor /// and only afterwards raises the item's Click that records its command. let mutable chosen = false let mutable restoreOwner : obj option = None + let mutable counted = false let timer = new Timer(Interval=1) // Submenus open on hover after the Windows menu delay (400 ms here) through a timer that // only fires while WinForms still counts the item selected, which a menu of an app not in @@ -164,6 +171,9 @@ type ThemedContextMenu(items:List2,closed:unit -> unit,?ownsFor this.Dispose() run |> Option.iter(fun invoke -> invoke())) this.Opened.Add(fun _ -> + if not counted then + counted <- true + OpenMenus.change 1 mouseHook <- WinUserApi.SetWindowsHookEx(WindowHookTypes.WH_MOUSE_LL,mouseProc,IntPtr.Zero,0) if mouseHook=IntPtr.Zero then this.Close() @@ -178,6 +188,9 @@ type ThemedContextMenu(items:List2,closed:unit -> unit,?ownsFor ownsForeground |> Option.iter(fun owns -> if owns (WinUserApi.GetForegroundWindow()) then e.Cancel <- true)) // Put back what WinForms had, so no later menu inherits a strip that may be gone. this.Closed.Add(fun _ -> + if counted then + counted <- false + OpenMenus.change -1 hoverOpen.Stop() stopMouseHook() restoreOwner |> Option.iter(fun previous -> @@ -236,6 +249,10 @@ type ThemedContextMenu(items:List2,closed:unit -> unit,?ownsFor timer.Stop() hoverOpen.Dispose() base.Dispose(disposing) + // A menu gone without its Closed must not keep every title tip away. + if counted then + counted <- false + OpenMenus.change -1 if disposing then timer.Dispose() for image in images do image.Dispose() diff --git a/WtProgram/TabStrip.fs b/WtProgram/TabStrip.fs index 6520ef2..eab1ac5 100755 --- a/WtProgram/TabStrip.fs +++ b/WtProgram/TabStrip.fs @@ -255,7 +255,7 @@ type TabStrip(monitor:ITabStripMonitor) as this = titleTab |> Option.iter(fun tab -> let sprites : List2> = this.tabSprites match sprites.list |> List.tryFind(fun (_,sprite) -> sprite.id=tab) with - | Some(_,sprite) when this.visible && not this.isShrunk && sprite.titleCut -> + | Some(_,sprite) when this.visible && not this.isShrunk && not (OpenMenus.any()) && sprite.titleCut -> let tip = match titleTip with | Some tip -> tip diff --git a/tests/TabInteraction.fsx b/tests/TabInteraction.fsx index ef22df6..8145064 100644 --- a/tests/TabInteraction.fsx +++ b/tests/TabInteraction.fsx @@ -80,6 +80,7 @@ let main() = dot.Dispose() menu.Show(Point(40,40)) Application.DoEvents() + check (OpenMenus.any()) "An open menu was not counted, so a title tip could cover it" // Pointing at an item with a submenu opens it after a short pause of our own, not the // Windows menu delay through WinForms' timer, which a menu of an app behind could lose. // The pointer entering an item, as WinForms reports it: HandleMouseEnter raises MouseEnter. @@ -111,6 +112,7 @@ let main() = first.PerformClick() menu.Close() check (closed && selected=0 && not menu.IsDisposed) "Menu command or disposal ran inside the close event" + check (not (OpenMenus.any())) "A closed menu still kept title tips away" let deadline = DateTime.UtcNow.AddSeconds(2.0) while not menu.IsDisposed && DateTime.UtcNow Date: Sat, 10 Oct 2026 11:50:27 +0800 Subject: [PATCH 13/13] Drop files with names outside the code page, and say under the strip what a drop does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DragQueryFile bound its ANSI entry point, which turned a name such as 小麦-0.1.24-x64-setup.exe into question marks, so the file was not found and the drop did nothing. The Unicode entry point reads it whole. The source's drag image covered the tabs being aimed at. A line under the strip, in the app's theme, now says what letting go does, such as "Move to Downloads". --- Win32/Ole2.cs | 28 ++++++++------- Win32/ShellApi.cs | 5 +-- Win32/ShellDrop.cs | 60 -------------------------------- WtProgram/OleDropTarget.fs | 69 ++++++++++++++++--------------------- WtProgram/Shared/Strings.fs | 2 +- tests/Architecture.fsx | 15 ++++++++ 6 files changed, 64 insertions(+), 115 deletions(-) diff --git a/Win32/Ole2.cs b/Win32/Ole2.cs index 080a12e..c581073 100755 --- a/Win32/Ole2.cs +++ b/Win32/Ole2.cs @@ -171,6 +171,21 @@ public sealed class Ole2Api } public static class OleHelper { + public static List FilesInDrop(IntPtr hdrop) + { + uint count = ShellApi.DragQueryFile(hdrop, -1, IntPtr.Zero, 0); + var files = new List(); + for (int i = 0; i < count; i++) + { + // Sized to each path, so long paths are not cut short. + var size = (int)ShellApi.DragQueryFile(hdrop, i, IntPtr.Zero, 0) + 1; + var sb = new StringBuilder(size); + ShellApi.DragQueryFile(hdrop, i, sb, size); + files.Add(sb.ToString()); + } + return files; + } + public static IEnumerable QueryFiles(IDataObject dataObject) { STGMEDIUM td = new STGMEDIUM(); @@ -184,18 +199,7 @@ public static IEnumerable QueryFiles(IDataObject dataObject) dataObject.GetData(ref fr, out td); try { - var hdrop = td.unionmember; - uint count = ShellApi.DragQueryFile(hdrop, -1, IntPtr.Zero, 0); - var files = new List(); - for (int i = 0; i < count; i++) - { - // Sized to each path, so long paths are not cut short. - var size = (int)ShellApi.DragQueryFile(hdrop, i, IntPtr.Zero, 0) + 1; - var sb = new StringBuilder(size); - ShellApi.DragQueryFile(hdrop, i, sb, size); - files.Add(sb.ToString()); - } - return files; + return FilesInDrop(td.unionmember); } finally { diff --git a/Win32/ShellApi.cs b/Win32/ShellApi.cs index ecf261f..026114a 100755 --- a/Win32/ShellApi.cs +++ b/Win32/ShellApi.cs @@ -352,9 +352,10 @@ public sealed class ShellApi public static extern bool Shel_NotifyIcon(int dwMesage, ref NOTIFYICONDATA lpData); [DllImport("shell32.dll", CharSet = CharSet.Auto)] public static extern void DragAcceptFiles(IntPtr hwnd, bool accept); - [DllImport("shell32.dll")] + // The Unicode entry point: the ANSI one turns names outside the code page into question marks. + [DllImport("shell32.dll", CharSet = CharSet.Unicode, EntryPoint = "DragQueryFileW")] public static extern uint DragQueryFile(IntPtr hDrop, int iFile, IntPtr lpszFile, int cch); - [DllImport("shell32.dll")] + [DllImport("shell32.dll", CharSet = CharSet.Unicode, EntryPoint = "DragQueryFileW")] public static extern uint DragQueryFile(IntPtr hDrop, int iFile, [Out] StringBuilder lpszFile, int cch); [DllImport("shell32.dll")] public static extern int SHGetPropertyStoreForWindow( diff --git a/Win32/ShellDrop.cs b/Win32/ShellDrop.cs index 284dc69..04f30c4 100644 --- a/Win32/ShellDrop.cs +++ b/Win32/ShellDrop.cs @@ -4,66 +4,6 @@ namespace Bemo { - // Shows the source's drag image over a drop target, as Explorer does. - [ComImport, Guid("4657278B-411B-11D2-839A-00C04FD918D0"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - public interface IDropTargetHelper - { - void DragEnter(IntPtr hwndTarget, IDataObject dataObject, ref POINTL pt, int effect); - void DragLeave(); - void DragOver(ref POINTL pt, int effect); - void Drop(IDataObject dataObject, ref POINTL pt, int effect); - void Show([MarshalAs(UnmanagedType.Bool)] bool show); - } - - public static class DropImages - { - public const int Invalid = -1, None = 0, Copy = 1, Move = 2; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct DROPDESCRIPTION - { - public int type; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string message; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string insert; - } - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern uint RegisterClipboardFormat(string format); - - // Null where the shell offers no helper; drops then work without the image. - public static IDropTargetHelper CreateHelper() - { - try - { - var type = Type.GetTypeFromCLSID(new Guid("4657278A-411B-11D2-839A-00C04FD918D0")); - return type == null ? null : (IDropTargetHelper)Activator.CreateInstance(type); - } - catch (COMException) { return null; } - catch (InvalidCastException) { return null; } - } - - // The line under the drag image, such as "Move to Downloads"; %1 stands for insert. - // Invalid hands the line back to the source. - public static void Describe(IDataObject data, int type, string message, string insert) - { - var format = new FORMATETC - { - cfFormat = (CLIPFORMAT)unchecked((short)RegisterClipboardFormat("DropDescription")), - ptd = IntPtr.Zero, - dwAspect = DVASPECT.DVASPECT_CONTENT, - lindex = -1, - tymed = TYMED.TYMED_HGLOBAL - }; - var description = new DROPDESCRIPTION { type = type, message = message ?? "", insert = insert ?? "" }; - var memory = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(DROPDESCRIPTION))); - Marshal.StructureToPtr(description, memory, false); - var medium = new STGMEDIUM { tymed = TYMED.TYMED_HGLOBAL, unionmember = memory, pUnkForRelease = null }; - try { data.SetData(ref format, ref medium, true); } - catch (COMException) { Marshal.FreeHGlobal(memory); } - catch (NotImplementedException) { Marshal.FreeHGlobal(memory); } - } - } - [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IShellItem { diff --git a/WtProgram/OleDropTarget.fs b/WtProgram/OleDropTarget.fs index 2f128df..d0facf4 100755 --- a/WtProgram/OleDropTarget.fs +++ b/WtProgram/OleDropTarget.fs @@ -11,8 +11,7 @@ module OleDropLifetime = extern void OleUninitialize() /// Files dragged onto a tab: resting on a tab brings its window forward, and letting go on an -/// Explorer tab moves or copies them into that folder, with the drag image and a line saying -/// which, as over Explorer itself. +/// Explorer tab moves or copies them into that folder, which a line under the strip says. type OleDropTarget(ts:TabStrip) as this= let os = OS() let window = os.windowFromHwnd(ts.hwnd) @@ -29,30 +28,36 @@ type OleDropTarget(ts:TabStrip) as this= /// Looked up once per hovered tab: DragOver repeats while the pointer moves, and each /// lookup asks every Explorer window across processes. let mutable hoveredFolder : Shell.ShellFolder option = None - let mutable described = (DropImages.Invalid,"") - let mutable helper : IDropTargetHelper option = None - /// The helper once it has been told of this drag; only then does it hear the rest. - let mutable imaging : IDropTargetHelper option = None + /// The source's drag image covered the tabs being aimed at, so a line under the strip says + /// what letting go does, in the app's theme. + let mutable label : TabTitleTip option = None + let mutable labelled = "" /// A tab's window comes forward once the pointer rests on it, so sweeping a drag across /// the strip does not flash every window it passes. let activateTimer = new Timer(Interval=300) /// The right-drag menu, themed like the tab menu; replaced, not disposed, from its own events. let mutable dropMenu : ThemedContextMenu option = None - let image() = - if helper.IsNone then helper <- Option.ofObj(DropImages.CreateHelper()) - helper - - let describe (effect:DragDropEffects) = - let wanted = + let describe (effect:DragDropEffects) (pointerX:int) = + let text = match effect,hoveredFolder with - | DragDropEffects.Move,Some folder -> DropImages.Move,tr Strings.DropMenu.moveTo,folder.title - | DragDropEffects.Copy,Some folder -> DropImages.Copy,tr Strings.DropMenu.copyTo,folder.title - | _ -> DropImages.Invalid,"","" - let kind,message,insert = wanted - if (kind,insert)<>described then - described <- (kind,insert) - data |> Option.iter(fun data -> DropImages.Describe(data,kind,message,insert)) + | DragDropEffects.Move,Some folder -> (tr Strings.DropMenu.moveTo).Replace("%1",folder.title) + | DragDropEffects.Copy,Some folder -> (tr Strings.DropMenu.copyTo).Replace("%1",folder.title) + | _ -> "" + if text<>labelled then + labelled <- text + if text="" then label |> Option.iter(fun tip -> tip.hide()) + else + let tip = + match label with + | Some tip -> tip + | None -> + let tip = new TabTitleTip() + label <- Some tip + tip + let strip : Rect = ts.bounds + use font = System.Drawing.SystemFonts.MessageBoxFont + tip.show(text,font,System.Drawing.Rectangle(strip.x,strip.y,strip.size.width,strip.size.height),pointerX) let effectAt (keys:int) (pt:POINTL) (allowed:int) = // Captured here: by OleDrop the right button is already up. @@ -69,7 +74,7 @@ type OleDropTarget(ts:TabStrip) as this= DropRules.effect (enum allowed) (keys.hasFlag(MouseMessageKeyStateMask.MK_CONTROL)) (keys.hasFlag(MouseMessageKeyStateMask.MK_SHIFT)) files folder.path | None -> DragDropEffects.None - describe effect + describe effect pt.x int effect do @@ -89,15 +94,14 @@ type OleDropTarget(ts:TabStrip) as this= activateTimer.Dispose() dropMenu |> Option.iter(fun menu -> menu.Dispose()) dropMenu <- None - helper |> Option.iter(fun helper -> Marshal.ReleaseComObject(helper) |> ignore) - helper <- None + label |> Option.iter(fun tip -> tip.Dispose()) + label <- None if registered then OleDropLifetime.RevokeDragDrop(window.hwnd) |> ignore if initialized then OleDropLifetime.OleUninitialize() member this.dragEnd() = activateTimer.Stop() - imaging <- None - describe DragDropEffects.None + describe DragDropEffects.None 0 rButtonDown <- false hoveredTab <- None hoveredFolder <- None @@ -108,27 +112,15 @@ type OleDropTarget(ts:TabStrip) as this= member x.OleDragEnter(pDataObj, grfKeyState, pt, pdwEffect) = data <- match pDataObj with :? Bemo.IDataObject as data -> Some data | _ -> None files <- data |> Option.map(fun data -> try List.ofSeq(OleHelper.QueryFiles(data)) with _ -> []) |> Option.defaultValue [] - described <- (DropImages.Invalid,"") let effect = effectAt grfKeyState pt pdwEffect pdwEffect <- effect - match image(),data with - | Some helper,Some data -> - let mutable point = pt - helper.DragEnter(ts.hwnd,data,&point,effect) - imaging <- Some helper - | _ -> () 0 member x.OleDragOver(grfKeyState, pt, pdwEffect) = - let effect = effectAt grfKeyState pt pdwEffect - pdwEffect <- effect - imaging |> Option.iter(fun helper -> - let mutable point = pt - helper.DragOver(&point,effect)) + pdwEffect <- effectAt grfKeyState pt pdwEffect 0 member x.OleDragLeave() = - imaging |> Option.iter(fun helper -> helper.DragLeave()) this.dragEnd() 0 @@ -138,9 +130,6 @@ type OleDropTarget(ts:TabStrip) as this= // Read before effectAt, which records the keys of this call: the right button is up by now. let menuFor = rButtonDown let effect = effectAt grfKeyState pt pdwEffect - imaging |> Option.iter(fun helper -> - let mutable point = pt - helper.Drop(pDataObj,&point,effect)) ts.tryHit(window.ptToClient(ptScreen)).iter <| fun(Tab(hwnd),part) -> let folder = if hoveredTab=Some hwnd then hoveredFolder else Shell.getShellFolder hwnd folder.iter <| fun folder -> diff --git a/WtProgram/Shared/Strings.fs b/WtProgram/Shared/Strings.fs index 41e3dda..778bc46 100644 --- a/WtProgram/Shared/Strings.fs +++ b/WtProgram/Shared/Strings.fs @@ -67,7 +67,7 @@ module Strings = module DropMenu = let copy = { en="Copy"; zh="复制"; ja="コピー" } let move = { en="Move"; zh="移动"; ja="移動" } - /// Under the drag image; the shell puts the folder's name in place of %1. + /// Under the strip while files are dragged over an Explorer tab; %1 is the folder's name. let moveTo = { en="Move to %1"; zh="移动到 %1"; ja="%1 へ移動" } let copyTo = { en="Copy to %1"; zh="复制到 %1"; ja="%1 へコピー" } diff --git a/tests/Architecture.fsx b/tests/Architecture.fsx index 73af06f..28e364f 100644 --- a/tests/Architecture.fsx +++ b/tests/Architecture.fsx @@ -415,6 +415,21 @@ let main() = File.WriteAllText(moving,"c") let moved = FileOperation.Run([|moving|],target,true,IntPtr.Zero) check (moved && not (File.Exists(moving)) && File.Exists(Path.Combine(target,"c.txt"))) "A dropped file was not moved" + // A drop's paths were read through DragQueryFileA, which turned 小麦 into ??, so the + // file was not found and nothing moved. + let chinese = Path.Combine(source,"小麦-0.1.24-x64-setup.exe") + File.WriteAllText(chinese,"setup") + let path = Text.Encoding.Unicode.GetBytes(chinese + "\u0000\u0000") + let drop = Runtime.InteropServices.Marshal.AllocHGlobal(20 + path.Length) + try + // DROPFILES: the paths' offset, a point, fNC, and fWide for UTF-16 paths. + for offset,value in [0,20; 4,0; 8,0; 12,0; 16,1] do Runtime.InteropServices.Marshal.WriteInt32(drop,offset,value) + Runtime.InteropServices.Marshal.Copy(path,0,drop+nativeint 20,path.Length) + let read = OleHelper.FilesInDrop(drop) |> List.ofSeq + check (read=[chinese]) (sprintf "A dropped file's name outside the code page was not read whole: %A" read) + finally Runtime.InteropServices.Marshal.FreeHGlobal(drop) + check (FileOperation.Run([|chinese|],target,true,IntPtr.Zero) && File.Exists(Path.Combine(target,Path.GetFileName(chinese)))) + "A dropped file named outside the code page was not moved" finally Directory.Delete(root,true) let mask = AltMenuMask.inputs() check (mask.Length=2 && mask.[0].mkhi.ki.wVk=0xE8s && mask.[1].mkhi.ki.dwFlags=SendInputConstants.KEYEVENTF_KEYUP && mask.[0].mkhi.ki.dwExtraInfo=IntPtr(AltMenuMask.marker)) "Alt menu mask must pair and mark injected events"