dsh-mini 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -2
- package/app/HudWindow.cs +1278 -0
- package/app/RegionCapture.cs +552 -0
- package/app/SelectionWatcher.cs +850 -0
- package/app/Service.cs +412 -0
- package/app/app.manifest +22 -0
- package/app/bin/DSH/345/260/217/347/252/227.exe +0 -0
- package/app/bin/Microsoft.Web.WebView2.Core.dll +0 -0
- package/app/bin/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/app/bin/WebView2Loader.dll +0 -0
- package/app/build-app.ps1 +112 -0
- package/app/dev.ps1 +63 -0
- package/app/hud-page.g.cs +164 -0
- package/app/hud-page.js +157 -0
- package/app/probe/clear-composer.js +8 -0
- package/app/probe/dom-probe.js +40 -0
- package/app/probe/drag.ps1 +35 -0
- package/app/probe/dump-composer.js +5 -0
- package/app/probe/frame-probe.js +21 -0
- package/app/probe/selection-sample.txt +4 -0
- package/app/probe/top-probe.js +39 -0
- package/app/vendor/Microsoft.Web.WebView2.Core.dll +0 -0
- package/app/vendor/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/app/vendor/WebView2Loader.dll +0 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +660 -0
- package/lib/index.js +521 -0
- package/package.json +69 -4
- package/scripts/hud-shot.ps1 +168 -0
- package/scripts/native-status.ps1 +63 -0
- package/scripts/win-hud.ps1 +264 -0
- package//345/274/200/345/217/221/346/226/271/346/241/210-WebView2/345/216/237/347/224/237/345/260/217/347/252/227.md +216 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Drawing;
|
|
3
|
+
using System.Drawing.Drawing2D;
|
|
4
|
+
using System.Runtime.InteropServices;
|
|
5
|
+
using System.Text;
|
|
6
|
+
using System.Threading;
|
|
7
|
+
using System.Windows.Automation;
|
|
8
|
+
using System.Windows.Automation.Text;
|
|
9
|
+
using System.Windows.Forms;
|
|
10
|
+
|
|
11
|
+
namespace DshHud
|
|
12
|
+
{
|
|
13
|
+
/// <summary>Small Win32 surface used by the selection watcher.</summary>
|
|
14
|
+
internal static class ShellWin
|
|
15
|
+
{
|
|
16
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
17
|
+
internal struct POINT
|
|
18
|
+
{
|
|
19
|
+
public int X;
|
|
20
|
+
public int Y;
|
|
21
|
+
|
|
22
|
+
internal POINT(int x, int y)
|
|
23
|
+
{
|
|
24
|
+
X = x;
|
|
25
|
+
Y = y;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
[DllImport("user32.dll")]
|
|
30
|
+
internal static extern short GetAsyncKeyState(int vKey);
|
|
31
|
+
|
|
32
|
+
[DllImport("user32.dll")]
|
|
33
|
+
internal static extern IntPtr WindowFromPoint(POINT p);
|
|
34
|
+
|
|
35
|
+
[DllImport("user32.dll")]
|
|
36
|
+
internal static extern IntPtr GetForegroundWindow();
|
|
37
|
+
|
|
38
|
+
[DllImport("user32.dll")]
|
|
39
|
+
internal static extern IntPtr GetAncestor(IntPtr hWnd, uint flags);
|
|
40
|
+
|
|
41
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
42
|
+
internal static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max);
|
|
43
|
+
|
|
44
|
+
[DllImport("user32.dll")]
|
|
45
|
+
internal static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
|
|
46
|
+
|
|
47
|
+
[DllImport("user32.dll")]
|
|
48
|
+
internal static extern uint GetClipboardSequenceNumber();
|
|
49
|
+
|
|
50
|
+
[DllImport("user32.dll")]
|
|
51
|
+
internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
52
|
+
|
|
53
|
+
internal const uint GA_ROOT = 2;
|
|
54
|
+
internal const int VK_LBUTTON = 0x01;
|
|
55
|
+
internal const byte VK_CONTROL = 0x11;
|
|
56
|
+
internal const byte VK_C = 0x43;
|
|
57
|
+
internal const uint KEYEVENTF_KEYUP = 0x0002;
|
|
58
|
+
|
|
59
|
+
internal static bool IsMouseDown()
|
|
60
|
+
{
|
|
61
|
+
return (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// <summary>
|
|
65
|
+
/// Terminals map Ctrl+C to an interrupt rather than to "copy", so the clipboard
|
|
66
|
+
/// fallback must never fire over one of them.
|
|
67
|
+
/// </summary>
|
|
68
|
+
internal static bool IsTerminalAt(Point p)
|
|
69
|
+
{
|
|
70
|
+
try
|
|
71
|
+
{
|
|
72
|
+
IntPtr h = WindowFromPoint(new POINT(p.X, p.Y));
|
|
73
|
+
if (h == IntPtr.Zero) return false;
|
|
74
|
+
StringBuilder sb = new StringBuilder(256);
|
|
75
|
+
if (GetClassName(h, sb, sb.Capacity) <= 0) return false;
|
|
76
|
+
string cls = sb.ToString().ToLowerInvariant();
|
|
77
|
+
return cls.IndexOf("console") >= 0
|
|
78
|
+
|| cls.IndexOf("cascadia") >= 0
|
|
79
|
+
|| cls.IndexOf("mintty") >= 0
|
|
80
|
+
|| cls.IndexOf("putty") >= 0
|
|
81
|
+
|| cls.IndexOf("terminal") >= 0;
|
|
82
|
+
}
|
|
83
|
+
catch
|
|
84
|
+
{
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// <summary>Same terminal test as <see cref="IsTerminalAt"/> but for a known window handle.</summary>
|
|
90
|
+
internal static bool IsTerminalWindow(IntPtr h)
|
|
91
|
+
{
|
|
92
|
+
try
|
|
93
|
+
{
|
|
94
|
+
if (h == IntPtr.Zero) return false;
|
|
95
|
+
StringBuilder sb = new StringBuilder(256);
|
|
96
|
+
if (GetClassName(h, sb, sb.Capacity) <= 0) return false;
|
|
97
|
+
string cls = sb.ToString().ToLowerInvariant();
|
|
98
|
+
return cls.IndexOf("console") >= 0
|
|
99
|
+
|| cls.IndexOf("cascadia") >= 0
|
|
100
|
+
|| cls.IndexOf("mintty") >= 0
|
|
101
|
+
|| cls.IndexOf("putty") >= 0
|
|
102
|
+
|| cls.IndexOf("terminal") >= 0;
|
|
103
|
+
}
|
|
104
|
+
catch
|
|
105
|
+
{
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
internal static IntPtr RootWindowAt(Point p)
|
|
110
|
+
{
|
|
111
|
+
try
|
|
112
|
+
{
|
|
113
|
+
return GetAncestor(WindowFromPoint(new POINT(p.X, p.Y)), GA_ROOT);
|
|
114
|
+
}
|
|
115
|
+
catch
|
|
116
|
+
{
|
|
117
|
+
return IntPtr.Zero;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// <summary>
|
|
122
|
+
/// Explorer, the desktop and the taskbar read Ctrl+C as "copy the selected file",
|
|
123
|
+
/// so probing them would litter the clipboard history with file copies. Those
|
|
124
|
+
/// surfaces are skipped outright.
|
|
125
|
+
/// </summary>
|
|
126
|
+
internal static bool IsShellSurfaceAt(Point p)
|
|
127
|
+
{
|
|
128
|
+
try
|
|
129
|
+
{
|
|
130
|
+
IntPtr root = RootWindowAt(p);
|
|
131
|
+
if (root == IntPtr.Zero) return true;
|
|
132
|
+
StringBuilder sb = new StringBuilder(256);
|
|
133
|
+
if (GetClassName(root, sb, sb.Capacity) <= 0) return false;
|
|
134
|
+
string cls = sb.ToString().ToLowerInvariant();
|
|
135
|
+
return cls == "progman"
|
|
136
|
+
|| cls == "workerw"
|
|
137
|
+
|| cls == "shell_traywnd"
|
|
138
|
+
|| cls == "shell_secondarytraywnd"
|
|
139
|
+
|| cls == "cabinetwclass"
|
|
140
|
+
|| cls == "explorewclass"
|
|
141
|
+
|| cls == "desktopwindowxamlsource";
|
|
142
|
+
}
|
|
143
|
+
catch
|
|
144
|
+
{
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// <summary>
|
|
150
|
+
/// Game clients (Unity/Unreal shells) paint their own UI and expose no text, so the
|
|
151
|
+
/// Ctrl+C fallback below would be delivered to them as a stray "C". In 原神 "C" is the
|
|
152
|
+
/// character screen, so a double-click anywhere on the game silently opened it. Game
|
|
153
|
+
/// surfaces are never probed: there is no selection to read there anyway.
|
|
154
|
+
/// </summary>
|
|
155
|
+
internal static bool IsGameWindow(IntPtr h)
|
|
156
|
+
{
|
|
157
|
+
try
|
|
158
|
+
{
|
|
159
|
+
if (h == IntPtr.Zero) return false;
|
|
160
|
+
IntPtr root = GetAncestor(h, GA_ROOT);
|
|
161
|
+
if (root == IntPtr.Zero) root = h;
|
|
162
|
+
|
|
163
|
+
StringBuilder sb = new StringBuilder(256);
|
|
164
|
+
if (GetClassName(root, sb, sb.Capacity) > 0)
|
|
165
|
+
{
|
|
166
|
+
string cls = sb.ToString().ToLowerInvariant();
|
|
167
|
+
if (cls == "unitywndclass" || cls == "unrealwindow") return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
uint pid;
|
|
171
|
+
GetWindowThreadProcessId(root, out pid);
|
|
172
|
+
if (pid != 0)
|
|
173
|
+
{
|
|
174
|
+
string name = "";
|
|
175
|
+
try { name = System.Diagnostics.Process.GetProcessById((int)pid).ProcessName.ToLowerInvariant(); }
|
|
176
|
+
catch { }
|
|
177
|
+
string[] games = { "yuanshen", "genshinimpact", "starrail", "zenlesszonezero", "bh3" };
|
|
178
|
+
foreach (string g in games)
|
|
179
|
+
{
|
|
180
|
+
if (name.IndexOf(g) >= 0) return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch
|
|
185
|
+
{
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
internal static void SendCopy()
|
|
191
|
+
{
|
|
192
|
+
keybd_event(VK_CONTROL, 0, 0, UIntPtr.Zero);
|
|
193
|
+
keybd_event(VK_C, 0, 0, UIntPtr.Zero);
|
|
194
|
+
keybd_event(VK_C, 0, KEYEVENTF_KEYUP, UIntPtr.Zero);
|
|
195
|
+
keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, UIntPtr.Zero);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// <summary>
|
|
200
|
+
/// Grabs the text of a live selection. Windows has no global "give me the selection"
|
|
201
|
+
/// API, so the shell sends Ctrl+C, watches the clipboard sequence number to know when
|
|
202
|
+
/// the copy actually landed, then puts the previous clipboard back.
|
|
203
|
+
/// </summary>
|
|
204
|
+
/// <summary>What was selected and where its last line sits (physical screen pixels).</summary>
|
|
205
|
+
internal struct SelectionHit
|
|
206
|
+
{
|
|
207
|
+
internal string Text;
|
|
208
|
+
internal Rectangle Rect;
|
|
209
|
+
internal bool HasRect;
|
|
210
|
+
|
|
211
|
+
/// <summary>Where to hang the pill when we have no real rectangle.</summary>
|
|
212
|
+
internal Rectangle AnchorPoint(Point fallback)
|
|
213
|
+
{
|
|
214
|
+
if (HasRect) return Rect;
|
|
215
|
+
return new Rectangle(fallback.X, fallback.Y, 0, 0);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
internal static class SelectionReader
|
|
220
|
+
{
|
|
221
|
+
/// <summary>
|
|
222
|
+
/// Read the current selection. UIA first: it is passive (no keystrokes, no clipboard
|
|
223
|
+
/// churn) and it also tells us where the selection ends, which is what the pill
|
|
224
|
+
/// anchors to. Ctrl+C is only the fallback for apps that do not expose TextPattern.
|
|
225
|
+
///
|
|
226
|
+
/// `hwnd` is the window the user dragged in. Targeting it beats asking for the
|
|
227
|
+
/// focused element: the helper process often is not the foreground app by the
|
|
228
|
+
/// time we look, and a window can keep a selection while unfocused.
|
|
229
|
+
/// </summary>
|
|
230
|
+
internal static SelectionHit ReadHit(IntPtr hwnd)
|
|
231
|
+
{
|
|
232
|
+
SelectionHit hit = new SelectionHit();
|
|
233
|
+
hit.Text = "";
|
|
234
|
+
|
|
235
|
+
try
|
|
236
|
+
{
|
|
237
|
+
AutomationElement el = hwnd != IntPtr.Zero ? AutomationElement.FromHandle(hwnd) : null;
|
|
238
|
+
if (el == null) el = AutomationElement.FocusedElement;
|
|
239
|
+
AutomationElement text = FindTextElement(el, 0);
|
|
240
|
+
if (text != null)
|
|
241
|
+
{
|
|
242
|
+
object pattern;
|
|
243
|
+
if (text.TryGetCurrentPattern(TextPattern.Pattern, out pattern))
|
|
244
|
+
{
|
|
245
|
+
TextPattern tp = (TextPattern)pattern;
|
|
246
|
+
TextPatternRange[] ranges = tp.GetSelection();
|
|
247
|
+
if (ranges != null && ranges.Length > 0)
|
|
248
|
+
{
|
|
249
|
+
string text2 = ranges[0].GetText(-1);
|
|
250
|
+
if (!string.IsNullOrEmpty(text2))
|
|
251
|
+
{
|
|
252
|
+
hit.Text = Normalize(text2);
|
|
253
|
+
// UIA hands back one rect per line in physical screen pixels;
|
|
254
|
+
// the last one is the end of the selection, which is where
|
|
255
|
+
// the pill wants to sit.
|
|
256
|
+
System.Windows.Rect[] rects = ranges[0].GetBoundingRectangles();
|
|
257
|
+
if (rects != null && rects.Length > 0)
|
|
258
|
+
{
|
|
259
|
+
System.Windows.Rect last = rects[rects.Length - 1];
|
|
260
|
+
if (last.Width > 0.5 && last.Height > 0.5)
|
|
261
|
+
{
|
|
262
|
+
hit.Rect = Rectangle.Round(new RectangleF(
|
|
263
|
+
(float)last.X, (float)last.Y,
|
|
264
|
+
(float)last.Width, (float)last.Height));
|
|
265
|
+
hit.HasRect = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return hit;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch
|
|
275
|
+
{
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
hit.Text = Read();
|
|
279
|
+
return hit;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
internal static SelectionHit ReadHit()
|
|
283
|
+
{
|
|
284
|
+
return ReadHit(IntPtr.Zero);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// <summary>
|
|
288
|
+
/// Breadth-first walk for the first element that supports TextPattern. Editors are
|
|
289
|
+
/// usually one or two levels below the top window (Notepad: window → pane →
|
|
290
|
+
/// document), so a shallow bound is enough and keeps this fast.
|
|
291
|
+
/// </summary>
|
|
292
|
+
private static AutomationElement FindTextElement(AutomationElement root, int depth)
|
|
293
|
+
{
|
|
294
|
+
if (root == null || depth > 4) return null;
|
|
295
|
+
try
|
|
296
|
+
{
|
|
297
|
+
object pattern;
|
|
298
|
+
if (root.TryGetCurrentPattern(TextPattern.Pattern, out pattern)) return root;
|
|
299
|
+
|
|
300
|
+
TreeWalker walker = TreeWalker.RawViewWalker;
|
|
301
|
+
AutomationElement child = walker.GetFirstChild(root);
|
|
302
|
+
int guard = 0;
|
|
303
|
+
while (child != null && guard < 40)
|
|
304
|
+
{
|
|
305
|
+
guard += 1;
|
|
306
|
+
AutomationElement found = FindTextElement(child, depth + 1);
|
|
307
|
+
if (found != null) return found;
|
|
308
|
+
child = walker.GetNextSibling(child);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch
|
|
312
|
+
{
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private static string Normalize(string text)
|
|
318
|
+
{
|
|
319
|
+
if (string.IsNullOrEmpty(text)) return "";
|
|
320
|
+
text = text.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
|
|
321
|
+
if (text.Length > 4000) text = text.Substring(0, 4000);
|
|
322
|
+
return text;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
internal static string Read()
|
|
326
|
+
{
|
|
327
|
+
string result = "";
|
|
328
|
+
Thread worker = new Thread(delegate()
|
|
329
|
+
{
|
|
330
|
+
result = ReadOnStaThread();
|
|
331
|
+
});
|
|
332
|
+
worker.SetApartmentState(ApartmentState.STA);
|
|
333
|
+
worker.IsBackground = true;
|
|
334
|
+
worker.Start();
|
|
335
|
+
if (!worker.Join(2000)) return "";
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private static string ReadOnStaThread()
|
|
340
|
+
{
|
|
341
|
+
IDataObject backup = null;
|
|
342
|
+
try
|
|
343
|
+
{
|
|
344
|
+
backup = Clipboard.GetDataObject();
|
|
345
|
+
}
|
|
346
|
+
catch
|
|
347
|
+
{
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
uint seq = ShellWin.GetClipboardSequenceNumber();
|
|
351
|
+
try
|
|
352
|
+
{
|
|
353
|
+
ShellWin.SendCopy();
|
|
354
|
+
}
|
|
355
|
+
catch
|
|
356
|
+
{
|
|
357
|
+
return "";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
string text = null;
|
|
361
|
+
int waited = 0;
|
|
362
|
+
while (waited < 900)
|
|
363
|
+
{
|
|
364
|
+
Thread.Sleep(30);
|
|
365
|
+
waited += 30;
|
|
366
|
+
if (ShellWin.GetClipboardSequenceNumber() == seq) continue;
|
|
367
|
+
try
|
|
368
|
+
{
|
|
369
|
+
if (Clipboard.ContainsText()) text = Clipboard.GetText();
|
|
370
|
+
}
|
|
371
|
+
catch
|
|
372
|
+
{
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try
|
|
378
|
+
{
|
|
379
|
+
if (backup != null) Clipboard.SetDataObject(backup, false);
|
|
380
|
+
}
|
|
381
|
+
catch
|
|
382
|
+
{
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (string.IsNullOrEmpty(text)) return "";
|
|
386
|
+
text = text.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
|
|
387
|
+
if (text.Length == 0) return "";
|
|
388
|
+
if (text.Length > 4000) text = text.Substring(0, 4000);
|
|
389
|
+
return text;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/// <summary>The little pill that follows a selection: "问大肥鱼".</summary>
|
|
394
|
+
internal sealed class AskButton : Form
|
|
395
|
+
{
|
|
396
|
+
internal event EventHandler Clicked;
|
|
397
|
+
|
|
398
|
+
private static readonly Color BaseBg = Color.FromArgb(255, 255, 255);
|
|
399
|
+
private static readonly Color HoverBg = Color.FromArgb(243, 244, 246);
|
|
400
|
+
private static readonly Color TextColor = Color.FromArgb(24, 24, 27);
|
|
401
|
+
private static readonly Color BorderColor = Color.FromArgb(206, 208, 214);
|
|
402
|
+
|
|
403
|
+
private readonly Label label;
|
|
404
|
+
private float scale;
|
|
405
|
+
|
|
406
|
+
internal AskButton(float scale)
|
|
407
|
+
{
|
|
408
|
+
this.scale = scale > 0f ? scale : 1f;
|
|
409
|
+
|
|
410
|
+
FormBorderStyle = FormBorderStyle.None;
|
|
411
|
+
StartPosition = FormStartPosition.Manual;
|
|
412
|
+
ShowInTaskbar = false;
|
|
413
|
+
TopMost = true;
|
|
414
|
+
AutoScaleMode = AutoScaleMode.None;
|
|
415
|
+
BackColor = BaseBg;
|
|
416
|
+
DoubleBuffered = true;
|
|
417
|
+
KeyPreview = false;
|
|
418
|
+
|
|
419
|
+
Fingerprint();
|
|
420
|
+
|
|
421
|
+
label = new Label();
|
|
422
|
+
label.Text = "\u95EE\u5927\u80A5\u9C7C";
|
|
423
|
+
label.AutoSize = false;
|
|
424
|
+
label.Dock = DockStyle.Fill;
|
|
425
|
+
label.TextAlign = ContentAlignment.MiddleCenter;
|
|
426
|
+
label.ForeColor = TextColor;
|
|
427
|
+
label.BackColor = Color.Transparent;
|
|
428
|
+
label.Cursor = Cursors.Hand;
|
|
429
|
+
label.Click += delegate { Fire(); };
|
|
430
|
+
label.MouseEnter += delegate { SetHover(true); };
|
|
431
|
+
label.MouseLeave += delegate { SetHover(false); };
|
|
432
|
+
Controls.Add(label);
|
|
433
|
+
|
|
434
|
+
MouseEnter += delegate { SetHover(true); };
|
|
435
|
+
MouseLeave += delegate { SetHover(false); };
|
|
436
|
+
Click += delegate { Fire(); };
|
|
437
|
+
|
|
438
|
+
ApplyMetrics();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
protected override void OnHandleCreated(EventArgs e)
|
|
442
|
+
{
|
|
443
|
+
base.OnHandleCreated(e);
|
|
444
|
+
// The monitor DPI is only reliable through the window itself; take it from here
|
|
445
|
+
// so the pill is never a shrunken 96-dpi rectangle on a 150% screen.
|
|
446
|
+
int dpi = DeviceDpi;
|
|
447
|
+
if (dpi >= 96)
|
|
448
|
+
{
|
|
449
|
+
float s = dpi / 96f;
|
|
450
|
+
if (Math.Abs(s - scale) > 0.01f)
|
|
451
|
+
{
|
|
452
|
+
scale = s;
|
|
453
|
+
ApplyMetrics();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private void ApplyMetrics()
|
|
459
|
+
{
|
|
460
|
+
label.Font = new Font("Microsoft YaHei UI", 8.5f, FontStyle.Bold);
|
|
461
|
+
Width = TextRenderer.MeasureText(label.Text, label.Font).Width + U(28);
|
|
462
|
+
Height = U(32);
|
|
463
|
+
ApplyRoundedRegion();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
internal void Fingerprint()
|
|
467
|
+
{
|
|
468
|
+
SelectionWatcher.Log("pill scale=" + scale.ToString("0.00"));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/// <summary>Never steal focus from the document the user is selecting in.</summary>
|
|
472
|
+
protected override bool ShowWithoutActivation
|
|
473
|
+
{
|
|
474
|
+
get { return true; }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
protected override CreateParams CreateParams
|
|
478
|
+
{
|
|
479
|
+
get
|
|
480
|
+
{
|
|
481
|
+
CreateParams cp = base.CreateParams;
|
|
482
|
+
cp.ExStyle |= 0x08000000; // WS_EX_NOACTIVATE
|
|
483
|
+
cp.ExStyle |= 0x00000080; // WS_EX_TOOLWINDOW
|
|
484
|
+
return cp;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
internal void ShowNear(Point cursor)
|
|
489
|
+
{
|
|
490
|
+
Rectangle wa = Native.WorkArea();
|
|
491
|
+
int x = cursor.X + U(14);
|
|
492
|
+
int y = cursor.Y + U(18);
|
|
493
|
+
if (x + Width > wa.Right) x = cursor.X - Width - U(10);
|
|
494
|
+
if (y + Height > wa.Bottom) y = cursor.Y - Height - U(10);
|
|
495
|
+
if (x < wa.Left) x = wa.Left + U(4);
|
|
496
|
+
if (y < wa.Top) y = wa.Top + U(4);
|
|
497
|
+
|
|
498
|
+
Location = new Point(x, y);
|
|
499
|
+
if (!Visible) Show();
|
|
500
|
+
BringToFront();
|
|
501
|
+
SelectionWatcher.Log("pill at " + x + "," + y + " " + Width + "x" + Height);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/// <summary>
|
|
505
|
+
/// Hangs the pill at the end of the last selected line: to its right when there is
|
|
506
|
+
/// room, otherwise right-aligned underneath the selection. Clamped to the work area.
|
|
507
|
+
/// </summary>
|
|
508
|
+
internal void ShowNearSelection(SelectionHit hit, Point fallback)
|
|
509
|
+
{
|
|
510
|
+
Rectangle wa = Native.WorkArea();
|
|
511
|
+
int x;
|
|
512
|
+
int y;
|
|
513
|
+
string mode;
|
|
514
|
+
|
|
515
|
+
if (hit.HasRect && hit.Rect.Width > 0)
|
|
516
|
+
{
|
|
517
|
+
// bottom-right of the last selected rect: keeps the text itself uncovered
|
|
518
|
+
Rectangle sel = hit.Rect;
|
|
519
|
+
x = sel.Right + U(10);
|
|
520
|
+
y = sel.Bottom + U(8);
|
|
521
|
+
mode = "right-below";
|
|
522
|
+
if (x + Width > wa.Right - U(6))
|
|
523
|
+
{
|
|
524
|
+
// not enough room to the right: slide left, still under the last line
|
|
525
|
+
x = sel.Right - Width;
|
|
526
|
+
y = sel.Bottom + U(8);
|
|
527
|
+
mode = "below";
|
|
528
|
+
}
|
|
529
|
+
if (y + Height > wa.Bottom - U(6))
|
|
530
|
+
{
|
|
531
|
+
// bottom of the screen: flip above the last line
|
|
532
|
+
y = sel.Top - Height - U(8);
|
|
533
|
+
mode = "above";
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
else
|
|
537
|
+
{
|
|
538
|
+
x = fallback.X + U(14);
|
|
539
|
+
y = fallback.Y + U(18);
|
|
540
|
+
mode = "cursor";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (x < wa.Left + U(6)) x = wa.Left + U(6);
|
|
544
|
+
if (x + Width > wa.Right - U(6)) x = wa.Right - Width - U(6);
|
|
545
|
+
if (y < wa.Top + U(6)) y = wa.Top + U(6);
|
|
546
|
+
if (y + Height > wa.Bottom - U(6)) y = wa.Bottom - Height - U(6);
|
|
547
|
+
|
|
548
|
+
Location = new Point(x, y);
|
|
549
|
+
if (!Visible) Show();
|
|
550
|
+
BringToFront();
|
|
551
|
+
SelectionWatcher.Log("pill at " + x + "," + y + " " + Width + "x" + Height +
|
|
552
|
+
" mode=" + mode + " sel=" + (hit.HasRect ? hit.Rect.ToString() : "none"));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
internal void Dismiss()
|
|
556
|
+
{
|
|
557
|
+
if (Visible) Hide();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private int U(int v)
|
|
561
|
+
{
|
|
562
|
+
return (int)Math.Round(v * scale);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private void SetHover(bool on)
|
|
566
|
+
{
|
|
567
|
+
BackColor = on ? HoverBg : BaseBg;
|
|
568
|
+
label.ForeColor = TextColor;
|
|
569
|
+
Invalidate();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private void Fire()
|
|
573
|
+
{
|
|
574
|
+
EventHandler handler = Clicked;
|
|
575
|
+
if (handler != null) handler(this, EventArgs.Empty);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/// <summary>Capsule outline: the corner radius is half the height, so both ends are fully round.</summary>
|
|
579
|
+
private GraphicsPath BuildPath(Rectangle b)
|
|
580
|
+
{
|
|
581
|
+
GraphicsPath path = new GraphicsPath();
|
|
582
|
+
int d = Math.Max(2, Math.Min(b.Height, b.Width));
|
|
583
|
+
path.AddArc(b.X, b.Y, d, d, 180, 90);
|
|
584
|
+
path.AddArc(b.Right - d, b.Y, d, d, 270, 90);
|
|
585
|
+
path.AddArc(b.Right - d, b.Bottom - d, d, d, 0, 90);
|
|
586
|
+
path.AddArc(b.X, b.Bottom - d, d, d, 90, 90);
|
|
587
|
+
path.CloseFigure();
|
|
588
|
+
return path;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private void ApplyRoundedRegion()
|
|
592
|
+
{
|
|
593
|
+
using (GraphicsPath path = BuildPath(new Rectangle(0, 0, Width, Height)))
|
|
594
|
+
{
|
|
595
|
+
Region = new Region(path);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/// <summary>A hairline border keeps the white pill readable on white pages.</summary>
|
|
600
|
+
protected override void OnPaint(PaintEventArgs e)
|
|
601
|
+
{
|
|
602
|
+
base.OnPaint(e);
|
|
603
|
+
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
|
604
|
+
int inset = Math.Max(1, U(1));
|
|
605
|
+
Rectangle b = new Rectangle(inset, inset, Width - inset * 2 - 1, Height - inset * 2 - 1);
|
|
606
|
+
using (GraphicsPath path = BuildPath(b))
|
|
607
|
+
using (Pen pen = new Pen(BorderColor, Math.Max(1f, U(1))))
|
|
608
|
+
{
|
|
609
|
+
e.Graphics.DrawPath(pen, path);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/// <summary>
|
|
615
|
+
/// Polls the mouse button (a low-level hook buys nothing here), notices drag-selects
|
|
616
|
+
/// and double-clicks anywhere on the desktop, reads the selection and offers the pill.
|
|
617
|
+
/// </summary>
|
|
618
|
+
internal sealed class SelectionWatcher : IDisposable
|
|
619
|
+
{
|
|
620
|
+
internal event Action<string> QuoteRequested;
|
|
621
|
+
|
|
622
|
+
private readonly Form host;
|
|
623
|
+
private readonly AskButton button;
|
|
624
|
+
private readonly System.Windows.Forms.Timer timer;
|
|
625
|
+
private readonly float scale;
|
|
626
|
+
|
|
627
|
+
private bool down;
|
|
628
|
+
private Point downPt;
|
|
629
|
+
private Point lastClickPt;
|
|
630
|
+
private int downTick;
|
|
631
|
+
private int lastClickTick;
|
|
632
|
+
private int shownTick;
|
|
633
|
+
private int probeId;
|
|
634
|
+
private bool enabled = true;
|
|
635
|
+
private string text = "";
|
|
636
|
+
|
|
637
|
+
internal SelectionWatcher(Form host)
|
|
638
|
+
{
|
|
639
|
+
this.host = host;
|
|
640
|
+
scale = Native.EffectiveScale();
|
|
641
|
+
|
|
642
|
+
button = new AskButton(scale);
|
|
643
|
+
button.Clicked += OnButtonClicked;
|
|
644
|
+
|
|
645
|
+
timer = new System.Windows.Forms.Timer();
|
|
646
|
+
timer.Interval = 90;
|
|
647
|
+
timer.Tick += Poll;
|
|
648
|
+
timer.Start();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
internal bool Enabled
|
|
652
|
+
{
|
|
653
|
+
get { return enabled; }
|
|
654
|
+
set
|
|
655
|
+
{
|
|
656
|
+
enabled = value;
|
|
657
|
+
if (!value) HideButton();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
public void Dispose()
|
|
662
|
+
{
|
|
663
|
+
try
|
|
664
|
+
{
|
|
665
|
+
timer.Stop();
|
|
666
|
+
timer.Tick -= Poll;
|
|
667
|
+
timer.Dispose();
|
|
668
|
+
}
|
|
669
|
+
catch
|
|
670
|
+
{
|
|
671
|
+
}
|
|
672
|
+
try
|
|
673
|
+
{
|
|
674
|
+
button.Clicked -= OnButtonClicked;
|
|
675
|
+
button.Dispose();
|
|
676
|
+
}
|
|
677
|
+
catch
|
|
678
|
+
{
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private int U(int v)
|
|
683
|
+
{
|
|
684
|
+
return (int)Math.Round(v * scale);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private bool OverOwnWindows(Point p)
|
|
688
|
+
{
|
|
689
|
+
if (host != null && host.Visible && host.Bounds.Contains(p)) return true;
|
|
690
|
+
if (button != null && button.Visible && button.Bounds.Contains(p)) return true;
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private void Poll(object sender, EventArgs e)
|
|
695
|
+
{
|
|
696
|
+
int now = Environment.TickCount;
|
|
697
|
+
Point p = Cursor.Position;
|
|
698
|
+
bool isDown = ShellWin.IsMouseDown();
|
|
699
|
+
|
|
700
|
+
if (isDown && !down)
|
|
701
|
+
{
|
|
702
|
+
down = true;
|
|
703
|
+
downPt = p;
|
|
704
|
+
downTick = now;
|
|
705
|
+
if (button.Visible && !button.Bounds.Contains(p)) HideButton();
|
|
706
|
+
}
|
|
707
|
+
else if (!isDown && down)
|
|
708
|
+
{
|
|
709
|
+
down = false;
|
|
710
|
+
int elapsed = now - downTick;
|
|
711
|
+
int dist = Math.Abs(p.X - downPt.X) + Math.Abs(p.Y - downPt.Y);
|
|
712
|
+
|
|
713
|
+
if (elapsed < 8000 && dist >= U(6))
|
|
714
|
+
{
|
|
715
|
+
if (!OverOwnWindows(p)) Kick(p, 130);
|
|
716
|
+
}
|
|
717
|
+
else if (dist < U(4))
|
|
718
|
+
{
|
|
719
|
+
// double-click / triple-click selects a word or a paragraph
|
|
720
|
+
bool second = now - lastClickTick < 520
|
|
721
|
+
&& Math.Abs(p.X - lastClickPt.X) < U(24)
|
|
722
|
+
&& Math.Abs(p.Y - lastClickPt.Y) < U(24);
|
|
723
|
+
if (second && !OverOwnWindows(p)) Kick(p, 320);
|
|
724
|
+
lastClickTick = now;
|
|
725
|
+
lastClickPt = p;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (button.Visible && now - shownTick > 9000) HideButton();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
internal static void Log(string line)
|
|
733
|
+
{
|
|
734
|
+
try
|
|
735
|
+
{
|
|
736
|
+
string dir = System.IO.Path.Combine(
|
|
737
|
+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "dsh-hud");
|
|
738
|
+
System.IO.Directory.CreateDirectory(dir);
|
|
739
|
+
System.IO.File.AppendAllText(System.IO.Path.Combine(dir, "hud.log"),
|
|
740
|
+
DateTime.Now.ToString("HH:mm:ss") + " [pill] " + line + "\r\n",
|
|
741
|
+
System.Text.Encoding.UTF8);
|
|
742
|
+
}
|
|
743
|
+
catch
|
|
744
|
+
{
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private void Kick(Point pt, int delay)
|
|
749
|
+
{
|
|
750
|
+
if (!enabled) { Log("skip: disabled"); return; }
|
|
751
|
+
|
|
752
|
+
if (ShellWin.IsShellSurfaceAt(pt)) { Log("skip: shell surface"); return; }
|
|
753
|
+
int id = ++probeId;
|
|
754
|
+
bool terminal = ShellWin.IsTerminalAt(pt);
|
|
755
|
+
IntPtr root = ShellWin.RootWindowAt(pt);
|
|
756
|
+
if (ShellWin.IsGameWindow(root)) { Log("skip: game window"); return; }
|
|
757
|
+
Log("kick at " + pt.X + "," + pt.Y + " terminal=" + terminal + " root=" + root);
|
|
758
|
+
|
|
759
|
+
ThreadPool.QueueUserWorkItem(delegate(object state)
|
|
760
|
+
{
|
|
761
|
+
try
|
|
762
|
+
{
|
|
763
|
+
Thread.Sleep(delay);
|
|
764
|
+
if (id != probeId) return;
|
|
765
|
+
if (terminal) { Log("skip: terminal"); return; }
|
|
766
|
+
// the user may have switched apps while we waited; never copy there
|
|
767
|
+
// prefer whatever the user is actually looking at; the window under the
|
|
768
|
+
// cursor may be a stale/overlapped one (explorer behind a maximized app)
|
|
769
|
+
IntPtr fg = ShellWin.GetForegroundWindow();
|
|
770
|
+
if (fg != IntPtr.Zero && ShellWin.IsTerminalWindow(fg)) { Log("skip: foreground terminal"); return; }
|
|
771
|
+
if (ShellWin.IsGameWindow(fg)) { Log("skip: foreground game"); return; }
|
|
772
|
+
|
|
773
|
+
SelectionHit hit = SelectionReader.ReadHit(fg != IntPtr.Zero ? fg : root);
|
|
774
|
+
if (string.IsNullOrEmpty(hit.Text) && fg != IntPtr.Zero && fg != root)
|
|
775
|
+
{
|
|
776
|
+
hit = SelectionReader.ReadHit(root);
|
|
777
|
+
}
|
|
778
|
+
string got = hit.Text;
|
|
779
|
+
Log("read " + (got == null ? -1 : got.Length) + " chars" +
|
|
780
|
+
(hit.HasRect ? " rect=" + hit.Rect.ToString() : " rect=none"));
|
|
781
|
+
if (string.IsNullOrEmpty(got)) return;
|
|
782
|
+
if (id != probeId) return;
|
|
783
|
+
|
|
784
|
+
host.BeginInvoke(new MethodInvoker(delegate
|
|
785
|
+
{
|
|
786
|
+
ShowButton(hit, pt);
|
|
787
|
+
}));
|
|
788
|
+
}
|
|
789
|
+
catch
|
|
790
|
+
{
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/// <summary>Diagnostic hook: show the pill for a sample text at the cursor.</summary>
|
|
796
|
+
internal void DebugPill()
|
|
797
|
+
{
|
|
798
|
+
SelectionHit hit = new SelectionHit();
|
|
799
|
+
hit.Text = "\u8C03\u8BD5\uFF1A\u9884\u89C8\u836F\u4E38\u5916\u89C2\u7528\u7684\u4E00\u6BB5\u6587\u5B57";
|
|
800
|
+
ShowButton(hit, Cursor.Position);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/// <summary>Diagnostic hook: place the pill as if <paramref name="rect"/> were the last selected rect.</summary>
|
|
804
|
+
internal void DebugPillAt(Rectangle rect)
|
|
805
|
+
{
|
|
806
|
+
SelectionHit hit = new SelectionHit();
|
|
807
|
+
hit.HasRect = true;
|
|
808
|
+
hit.Rect = rect;
|
|
809
|
+
hit.Text = "debug";
|
|
810
|
+
shownTick = Environment.TickCount;
|
|
811
|
+
button.ShowNearSelection(hit, Cursor.Position);
|
|
812
|
+
}
|
|
813
|
+
/// <summary>Diagnostic hook: read the current selection without any mouse work.</summary>
|
|
814
|
+
internal void TestProbe()
|
|
815
|
+
{
|
|
816
|
+
ThreadPool.QueueUserWorkItem(delegate(object state)
|
|
817
|
+
{
|
|
818
|
+
SelectionHit hit = SelectionReader.ReadHit();
|
|
819
|
+
string got = hit.Text;
|
|
820
|
+
int len = got == null ? -1 : got.Length;
|
|
821
|
+
string head = len > 0 ? got.Substring(0, Math.Min(40, got.Length)) : "";
|
|
822
|
+
Log("test read " + len + " chars" + (hit.HasRect ? " rect=" + hit.Rect.ToString() : " rect=none") +
|
|
823
|
+
": " + head.Replace("\n", " ").Replace("\r", " "));
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
private void ShowButton(SelectionHit hit, Point pt)
|
|
828
|
+
{
|
|
829
|
+
Log("show " + (hit.Text == null ? 0 : hit.Text.Length) + " chars");
|
|
830
|
+
text = hit.Text;
|
|
831
|
+
shownTick = Environment.TickCount;
|
|
832
|
+
button.ShowNearSelection(hit, pt);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private void HideButton()
|
|
836
|
+
{
|
|
837
|
+
text = "";
|
|
838
|
+
button.Dismiss();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private void OnButtonClicked(object sender, EventArgs e)
|
|
842
|
+
{
|
|
843
|
+
string got = text;
|
|
844
|
+
HideButton();
|
|
845
|
+
if (string.IsNullOrEmpty(got)) return;
|
|
846
|
+
Action<string> handler = QuoteRequested;
|
|
847
|
+
if (handler != null) handler(got);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|