basalt-win32 0.2.0 → 0.2.2

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  #include "DirectWriteLayout.h"
4
4
  #include "TextChecking.h"
5
+ #include "Win32PeerClip.h"
5
6
  #include "Win32InputScopes.h"
6
7
  #include "Win32Strings.h"
7
8
 
@@ -64,61 +65,37 @@ COLORREF toColorRef(const float components[4]) {
64
65
  return RGB(channel(components[0]), channel(components[1]), channel(components[2]));
65
66
  }
66
67
 
67
- // A view's rectangle in the surface root's coordinates, and whether any of it is
68
- // still visible.
68
+ // Where a peer is placed and what of it is still the peer's: see
69
+ // win32/Win32PeerClip.h, which is the whole of the geometry. It lives there
70
+ // rather than here so that it can be tested against a view tree with no window
71
+ // and no React Native, the same split Win32TitleBarLayout made.
69
72
  //
70
73
  // Translation only: an EDIT is a window and a window cannot be rotated, so a
71
74
  // transformed field's peer sits where the untransformed one would. That is a
72
75
  // real difference from the painted view behind it, and there is no cheap fix --
73
76
  // see the header.
74
- //
75
- // Visibility is the other half. A child window is not clipped by anything in
76
- // the React tree, so a field scrolled out of its list would otherwise go on
77
- // being drawn over the list's neighbours. Any clipping ancestor that the
78
- // rectangle has left entirely hides it.
79
- bool peerRect(RnWin32View *view, RnWin32View *root, RECT &out) {
80
- if (view == nullptr || root == nullptr) {
81
- return false;
82
- }
83
77
 
84
- double x = 0.0;
85
- double y = 0.0;
86
- const double width = view->frame().width;
87
- const double height = view->frame().height;
78
+ RECT intersectRects(const RECT &a, const RECT &b) {
79
+ return RECT{std::max(a.left, b.left),
80
+ std::max(a.top, b.top),
81
+ std::min(a.right, b.right),
82
+ std::min(a.bottom, b.bottom)};
83
+ }
88
84
 
89
- for (RnWin32View *current = view; current != nullptr; current = current->parent()) {
90
- if (current->hidden()) {
91
- return false;
92
- }
93
- if (current == root) {
94
- break;
95
- }
96
- x += current->frame().x;
97
- y += current->frame().y;
85
+ RECT offsetBy(const RECT &rect, LONG dx, LONG dy) {
86
+ return RECT{rect.left + dx, rect.top + dy, rect.right + dx, rect.bottom + dy};
87
+ }
98
88
 
99
- RnWin32View *parent = current->parent();
100
- if (parent == nullptr) {
101
- // Detached: between a Remove and its Delete, or never inserted. Not on
102
- // screen either way.
89
+ bool sameShape(const std::vector<RECT> &a, const std::vector<RECT> &b) {
90
+ if (a.size() != b.size()) {
91
+ return false;
92
+ }
93
+ for (size_t i = 0; i < a.size(); i++) {
94
+ if (a[i].left != b[i].left || a[i].top != b[i].top || a[i].right != b[i].right ||
95
+ a[i].bottom != b[i].bottom) {
103
96
  return false;
104
97
  }
105
- x -= parent->scrollX();
106
- y -= parent->scrollY();
107
-
108
- if (parent->clipsChildren()) {
109
- // In the parent's own coordinates the box is 0..width, 0..height, and
110
- // (x, y) is where this view now sits relative to it.
111
- if (x + width <= 0 || y + height <= 0 || x >= parent->frame().width ||
112
- y >= parent->frame().height) {
113
- return false;
114
- }
115
- }
116
98
  }
117
-
118
- out.left = static_cast<LONG>(std::lround(x));
119
- out.top = static_cast<LONG>(std::lround(y));
120
- out.right = out.left + static_cast<LONG>(std::lround(width));
121
- out.bottom = out.top + static_cast<LONG>(std::lround(height));
122
99
  return true;
123
100
  }
124
101
 
@@ -596,6 +573,9 @@ void Win32TextInputManager::destroyPeer(Entry &entry) {
596
573
  DestroyWindow(entry.control);
597
574
  entry.control = nullptr;
598
575
  }
576
+ // The window took the region with it, and a rebuilt peer -- which is what a
577
+ // field switching to multiline gets -- starts without one.
578
+ entry.regionShape.clear();
599
579
  if (entry.font != nullptr) {
600
580
  DeleteObject(entry.font);
601
581
  entry.font = nullptr;
@@ -630,11 +610,12 @@ void Win32TextInputManager::syncBounds(RnWin32View *root) {
630
610
  if (entry.control == nullptr) {
631
611
  continue;
632
612
  }
633
- RECT rect{};
634
- if (!peerRect(entry.view, root, rect)) {
613
+ const win32::PeerClip placement = win32::peerClip(entry.view, root);
614
+ if (!placement.visible) {
635
615
  ShowWindow(entry.control, SW_HIDE);
636
616
  continue;
637
617
  }
618
+ RECT rect = placement.bounds;
638
619
 
639
620
  // Inside the content insets, so that `paddingHorizontal` on a field means
640
621
  // what it means on a <View>. GTK allocates its GtkText inside the same
@@ -673,6 +654,74 @@ void Win32TextInputManager::syncBounds(RnWin32View *root) {
673
654
  continue;
674
655
  }
675
656
 
657
+ // What of the control is still the control's, in its own coordinates: the
658
+ // part of it inside every clipping ancestor, less everything painted over
659
+ // it. See Win32PeerClip.h -- this is the half that turns those rectangles
660
+ // into the one thing USER32 understands, which is a window region.
661
+ //
662
+ // The first piece is what is left of the control, and each one after it is
663
+ // taken out.
664
+ std::vector<RECT> shape;
665
+ const RECT visible = offsetBy(intersectRects(placement.clip, rect), -rect.left, -rect.top);
666
+ if (visible.right > visible.left && visible.bottom > visible.top) {
667
+ shape.push_back(visible);
668
+ for (const RECT &occluder : placement.occluders) {
669
+ const RECT over = offsetBy(intersectRects(occluder, rect), -rect.left, -rect.top);
670
+ if (over.right > over.left && over.bottom > over.top) {
671
+ shape.push_back(over);
672
+ }
673
+ }
674
+ }
675
+
676
+ // Whole and unobstructed is the common case and gets no region at all:
677
+ // `SetWindowRgn` redraws the control, and this runs after every wheel
678
+ // notch, so a field in a list being scrolled would otherwise be told to
679
+ // redraw sixty times a second for a shape that has not changed.
680
+ HRGN region = nullptr;
681
+ const bool whole = shape.size() == 1 && shape[0].left == 0 && shape[0].top == 0 &&
682
+ shape[0].right == rect.right - rect.left && shape[0].bottom == rect.bottom - rect.top;
683
+ if (whole) {
684
+ shape.clear();
685
+ } else if (!shape.empty()) {
686
+ // The system owns the region once it is handed over, so the pieces taken
687
+ // out of it are deleted here and the result is not.
688
+ region = CreateRectRgn(shape[0].left, shape[0].top, shape[0].right, shape[0].bottom);
689
+ for (size_t i = 1; i < shape.size(); i++) {
690
+ HRGN piece = CreateRectRgn(shape[i].left, shape[i].top, shape[i].right, shape[i].bottom);
691
+ CombineRgn(region, region, piece, RGN_DIFF);
692
+ DeleteObject(piece);
693
+ }
694
+ RECT box{};
695
+ if (GetRgnBox(region, &box) == NULLREGION) {
696
+ // The pieces swallowed it: the field is wholly behind something rather
697
+ // than partly, which is what a modal over a pane looks like.
698
+ DeleteObject(region);
699
+ region = nullptr;
700
+ shape.clear();
701
+ ShowWindow(entry.control, SW_HIDE);
702
+ entry.regionShape.clear();
703
+ SetWindowRgn(entry.control, nullptr, FALSE);
704
+ // And it does not keep the keyboard. A field left focused behind a
705
+ // modal swallows every key the modal wanted, Escape included, so the
706
+ // sheet that covered it cannot be closed -- the same bug as the field
707
+ // being visible over the modal, one layer further in. The question does
708
+ // not arise on the other two desktops: a field there is a widget, and a
709
+ // widget that is not drawn is not in the focus chain.
710
+ if (GetFocus() == entry.control) {
711
+ moveFocusToHost();
712
+ }
713
+ continue;
714
+ }
715
+ } else {
716
+ // Out of sight behind a clipping ancestor, which `placement.visible`
717
+ // catches when it is complete and this catches when the control's own
718
+ // strip is what fell outside.
719
+ ShowWindow(entry.control, SW_HIDE);
720
+ entry.regionShape.clear();
721
+ SetWindowRgn(entry.control, nullptr, FALSE);
722
+ continue;
723
+ }
724
+
676
725
  SetWindowPos(entry.control,
677
726
  nullptr,
678
727
  rect.left,
@@ -680,6 +729,17 @@ void Win32TextInputManager::syncBounds(RnWin32View *root) {
680
729
  rect.right - rect.left,
681
730
  rect.bottom - rect.top,
682
731
  SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);
732
+
733
+ if (sameShape(shape, entry.regionShape)) {
734
+ if (region != nullptr) {
735
+ DeleteObject(region);
736
+ }
737
+ continue;
738
+ }
739
+ entry.regionShape = shape;
740
+ // A null region removes the one the control had, which is what a field
741
+ // that has come back into the clear needs.
742
+ SetWindowRgn(entry.control, region, TRUE);
683
743
  }
684
744
  }
685
745
 
@@ -29,11 +29,13 @@
29
29
  // mutation arrives at all. `syncBounds` is that, and the host calls it after
30
30
  // every transaction and every wheel.
31
31
  //
32
- // - **It is always on top.** Child windows paint above whatever Direct2D drew,
33
- // in an order the React tree has no say in. A field cannot be covered by a
34
- // later sibling, and it does not clip to a scrolled ancestor -- it is hidden
35
- // when it leaves the ancestor's box instead, which is close enough to look
36
- // right and is not the same thing.
32
+ // - **It would be on top of everything.** Child windows paint above whatever
33
+ // Direct2D drew, in an order the React tree has no say in, so a field
34
+ // neither clips to a scrolled ancestor nor can be covered by a later
35
+ // sibling. `SetWindowRgn` is where the missing order goes, and working the
36
+ // region out from the tree is `Win32PeerClip.h`; `syncBounds` installs it.
37
+ // What is still not possible is a transform, because a window cannot be
38
+ // rotated -- see docs/backlog/platform-windows.md.
37
39
  //
38
40
  // - **Its events arrive at the parent.** EN_CHANGE, EN_SETFOCUS and
39
41
  // EN_KILLFOCUS are WM_COMMAND notifications to the host window, and the
@@ -110,9 +112,11 @@ class Win32TextInputManager {
110
112
  const std::string &name,
111
113
  const folly::dynamic &args);
112
114
 
113
- // Moves every peer to where its view now is, in client coordinates, and hides
114
- // the ones that have gone out of sight. Called after each transaction and
115
- // each scroll: a child window does not follow a view that has no widget.
115
+ // Moves every peer to where its view now is, in client coordinates, clips it
116
+ // to what the tree leaves of it, and hides the ones that have gone out of
117
+ // sight or behind something. Called after each transaction and each scroll:
118
+ // a child window does not follow a view that has no widget, and the region
119
+ // it is clipped to changes with every one of them. See Win32PeerClip.h.
116
120
  void syncBounds(win32::RnWin32View *root);
117
121
 
118
122
  // WM_COMMAND from the host: EN_CHANGE, EN_SETFOCUS, EN_KILLFOCUS. True when
@@ -273,6 +277,17 @@ class Win32TextInputManager {
273
277
  // is set to so that the text sits in the middle of the field.
274
278
  LONG lineHeight{0};
275
279
 
280
+ // The window region last installed, as the rectangles it was built from in
281
+ // the control's own coordinates: the visible box first, then each piece
282
+ // taken out of it. Empty means no region, which is a whole unobstructed
283
+ // field -- the common case, and the one worth not touching.
284
+ //
285
+ // Held because `SetWindowRgn` redraws the control, and `syncBounds` runs
286
+ // after every transaction and every wheel notch: a field in a list being
287
+ // scrolled would be told to redraw on every one of them for a region that
288
+ // has not changed. See `syncBounds` and Win32PeerClip.h.
289
+ std::vector<RECT> regionShape;
290
+
276
291
  // The input scope last installed for `keyboardType`, and whether one has
277
292
  // been. `SetInputScope` replaces the window's whole scope list, so it is
278
293
  // called on a change rather than on every props update -- a controlled
@@ -24,4 +24,15 @@ void installUiThread();
24
24
  // Whether `postToUiThread` will actually marshal, rather than running inline.
25
25
  bool hasUiThread();
26
26
 
27
+ // Takes it down again, and runs anything still queued on the calling thread so
28
+ // that nothing posted is lost. For tests, and this is the one that needs it:
29
+ // react-native-skia 3.x records a frame on a thread pool of its own and asks
30
+ // the UI thread to present it, and the producer holds a lock across that ask.
31
+ // Run inline -- which is what posting with no window installed means on a
32
+ // worker, exactly as the note above warns -- it re-enters the producer on the
33
+ // thread that already holds the lock, and the suite dies with no message. So
34
+ // the canvas test installs a real loop, pumps it, and puts things back for the
35
+ // tests after it, which are written against the inline behaviour.
36
+ void uninstallUiThread();
37
+
27
38
  } // namespace basalt
@@ -0,0 +1,139 @@
1
+ #include "Win32UiTimer.h"
2
+
3
+ #include <windows.h>
4
+
5
+ #include "PlatformServices.h"
6
+ #include "Win32UiThread.h"
7
+
8
+ #include <mutex>
9
+ #include <unordered_map>
10
+ #include <utility>
11
+
12
+ namespace basalt {
13
+ namespace {
14
+
15
+ struct Entry {
16
+ std::function<void()> callback;
17
+ PTP_TIMER handle = nullptr;
18
+ bool repeating = false;
19
+ };
20
+
21
+ struct Registry {
22
+ std::mutex mutex;
23
+ std::unordered_map<UiTimerId, Entry> timers;
24
+ UiTimerId next = 1;
25
+ };
26
+
27
+ Registry &registry() {
28
+ static Registry instance;
29
+ return instance;
30
+ }
31
+
32
+ // Closing a thread-pool timer is three calls and the order is not optional:
33
+ // stop it, wait out a callback that may already be running on a pool thread,
34
+ // and only then release the handle. Skipping the wait is the classic way to
35
+ // free a timer out from under its own callback.
36
+ //
37
+ // Called with the registry lock *not* held: the wait blocks until a pool
38
+ // thread leaves `onExpiry`, and `onExpiry` takes that lock.
39
+ void closeTimer(PTP_TIMER handle) {
40
+ if (handle == nullptr) {
41
+ return;
42
+ }
43
+ SetThreadpoolTimer(handle, nullptr, 0, 0);
44
+ WaitForThreadpoolTimerCallbacks(handle, TRUE);
45
+ CloseThreadpoolTimer(handle);
46
+ }
47
+
48
+ // On the UI thread, one delay later. Everything about the timer is re-read
49
+ // here rather than captured, because the gap between the pool thread posting
50
+ // this and the loop running it is exactly when a cancel arrives.
51
+ void deliver(UiTimerId id) {
52
+ std::function<void()> callback;
53
+ PTP_TIMER finished = nullptr;
54
+ {
55
+ std::lock_guard<std::mutex> lock(registry().mutex);
56
+ const auto found = registry().timers.find(id);
57
+ if (found == registry().timers.end()) {
58
+ return;
59
+ }
60
+ callback = found->second.callback;
61
+ if (!found->second.repeating) {
62
+ // Out of the table before the callback runs, so that a one-shot which
63
+ // schedules its successor cannot collide with itself, and so that a
64
+ // `cancelUiTimer` on this id from inside the callback is the no-op the
65
+ // header promises.
66
+ finished = found->second.handle;
67
+ registry().timers.erase(found);
68
+ }
69
+ }
70
+ closeTimer(finished);
71
+ callback();
72
+ }
73
+
74
+ VOID CALLBACK onExpiry(PTP_CALLBACK_INSTANCE /*instance*/, PVOID context, PTP_TIMER /*timer*/) {
75
+ const auto id = static_cast<UiTimerId>(reinterpret_cast<std::uintptr_t>(context));
76
+ // Nothing is read here: the pool thread's only job is to say when. Which is
77
+ // also why the id travels rather than a pointer -- the entry may be gone by
78
+ // the time this lands.
79
+ postToUiThread([id] { deliver(id); });
80
+ }
81
+
82
+ } // namespace
83
+
84
+ UiTimerId scheduleUiTimer(unsigned delayMs, bool repeating, std::function<void()> callback) {
85
+ // See the header: with no loop to post to, a timer has nowhere to arrive,
86
+ // and pretending otherwise would run the callback on a pool thread.
87
+ if (!hasUiThread() || callback == nullptr) {
88
+ return 0;
89
+ }
90
+
91
+ UiTimerId id = 0;
92
+ {
93
+ std::lock_guard<std::mutex> lock(registry().mutex);
94
+ id = registry().next++;
95
+ }
96
+
97
+ PTP_TIMER handle = CreateThreadpoolTimer(
98
+ onExpiry, reinterpret_cast<PVOID>(static_cast<std::uintptr_t>(id)), nullptr);
99
+ if (handle == nullptr) {
100
+ return 0;
101
+ }
102
+
103
+ {
104
+ std::lock_guard<std::mutex> lock(registry().mutex);
105
+ registry().timers[id] = Entry{std::move(callback), handle, repeating};
106
+ }
107
+
108
+ // Negative is relative, in hundred-nanosecond units, which is the one piece
109
+ // of this API that reads like a mistake and is not.
110
+ LARGE_INTEGER relative{};
111
+ relative.QuadPart = -static_cast<LONGLONG>(delayMs) * 10000;
112
+ FILETIME due{};
113
+ due.dwLowDateTime = relative.LowPart;
114
+ due.dwHighDateTime = static_cast<DWORD>(relative.HighPart);
115
+ SetThreadpoolTimer(handle, &due, repeating ? static_cast<DWORD>(delayMs) : 0, 0);
116
+ return id;
117
+ }
118
+
119
+ void cancelUiTimer(UiTimerId id) {
120
+ if (id == 0) {
121
+ return;
122
+ }
123
+ PTP_TIMER handle = nullptr;
124
+ {
125
+ std::lock_guard<std::mutex> lock(registry().mutex);
126
+ const auto found = registry().timers.find(id);
127
+ if (found == registry().timers.end()) {
128
+ return;
129
+ }
130
+ handle = found->second.handle;
131
+ registry().timers.erase(found);
132
+ }
133
+ // Outside the lock, because the wait inside waits for `onExpiry` to finish
134
+ // and `onExpiry` wants the lock. A delivery already posted to the loop is
135
+ // harmless: `deliver` looks the id up and finds nothing.
136
+ closeTimer(handle);
137
+ }
138
+
139
+ } // namespace basalt
@@ -0,0 +1,65 @@
1
+ // A delay that arrives, in an app that is busy drawing.
2
+ //
3
+ // `SetTimer` is the obvious way to wait on Windows and it is not a reliable
4
+ // one. `WM_TIMER` is not posted: it is *synthesised* by `GetMessage` and
5
+ // `PeekMessage`, and only when the queue holds nothing else at all. It ranks
6
+ // below posted messages, below input, and -- the part that matters here --
7
+ // below `WM_PAINT`. An app that repaints whenever something changes is fine.
8
+ // An app that repaints continuously never gives the queue the empty moment a
9
+ // `WM_TIMER` needs, and its timers simply stop.
10
+ //
11
+ // That is not hypothetical and it is not rare. Measured on kino, an editor
12
+ // whose preview is a Skia `<Canvas>`: 120 `WM_PAINT` and 250 marshalled work
13
+ // items a second while *idle*, and six `WM_TIMER` in twenty-five seconds. A
14
+ // 250ms poll fired four times and then never again; a one-shot quit never
15
+ // fired at all, so every automated run of that app had to be killed. The
16
+ // other two desktops have no such ordering -- a CFRunLoop timer and a GLib
17
+ // source are both ordinary sources with no paint above them -- so this is a
18
+ // place where the Windows host was quietly worse than its peers rather than
19
+ // differently spelled.
20
+ //
21
+ // So the waiting happens off the UI thread, on a thread-pool timer, and the
22
+ // delivery is `postToUiThread` -- a posted message, which outranks both paint
23
+ // and timer, and which is the path every mount already takes. The callback
24
+ // still runs on the UI thread, which is the only property any caller wanted
25
+ // from `SetTimer` in the first place.
26
+ //
27
+ // ## Without a UI thread
28
+ //
29
+ // Nothing is delivered. A host installs the message-only window before React
30
+ // Native starts; the unit tests do not, because they have no loop. Under
31
+ // `SetTimer` that already meant "never fires" -- a `WM_TIMER` with nobody to
32
+ // dispatch it -- and keeping it that way is what lets the suite go on testing
33
+ // the code around these timers without growing a scheduler. A test that wants
34
+ // one installs a loop and pumps it, as the Skia canvas test does.
35
+
36
+ #pragma once
37
+
38
+ #include <cstdint>
39
+ #include <functional>
40
+
41
+ namespace basalt {
42
+
43
+ // What `scheduleUiTimer` hands back. Zero is "nothing was scheduled", which
44
+ // `cancelUiTimer` accepts, so a caller can keep one of these in a field and
45
+ // cancel it without asking whether it ever started.
46
+ using UiTimerId = std::uint64_t;
47
+
48
+ // Runs `callback` on the UI thread after `delayMs`, and every `delayMs`
49
+ // after that when `repeating`.
50
+ //
51
+ // The period is a floor rather than a promise, exactly as `SetTimer`'s was:
52
+ // the work is posted, so it arrives after whatever the loop is already
53
+ // holding. What is different is that it arrives.
54
+ UiTimerId scheduleUiTimer(unsigned delayMs, bool repeating, std::function<void()> callback);
55
+
56
+ // Stops it. Safe on an id that already fired, on one that was never
57
+ // scheduled, and on the running timer's own id from inside its callback --
58
+ // which is how a one-shot that reschedules itself is written.
59
+ //
60
+ // Returns having waited for any delivery already in flight on a pool thread,
61
+ // so a caller may destroy what the callback captured. It must be called from
62
+ // the UI thread, which every caller here is on.
63
+ void cancelUiTimer(UiTimerId id);
64
+
65
+ } // namespace basalt