basalt-gtk 0.0.0-rc5
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/LICENSE +21 -0
- package/cli/runLinux.js +29 -0
- package/cli/shared.js +75 -0
- package/native/CMakeLists.txt +362 -0
- package/native/gtk/ColorSchemeGtk.cpp +58 -0
- package/native/gtk/ComponentRegistryGtk.cpp +101 -0
- package/native/gtk/FontRegistryFontconfig.cpp +127 -0
- package/native/gtk/GtkAnimationChoreographer.cpp +73 -0
- package/native/gtk/GtkAnimationChoreographer.h +49 -0
- package/native/gtk/GtkDropTarget.cpp +269 -0
- package/native/gtk/GtkDropTarget.h +40 -0
- package/native/gtk/GtkFocus.cpp +296 -0
- package/native/gtk/GtkFocus.h +113 -0
- package/native/gtk/GtkImageLoader.cpp +128 -0
- package/native/gtk/GtkImageLoader.h +87 -0
- package/native/gtk/GtkKeyEvents.cpp +91 -0
- package/native/gtk/GtkKeyEvents.h +21 -0
- package/native/gtk/GtkMenuBar.cpp +45 -0
- package/native/gtk/GtkMountingManager.cpp +1027 -0
- package/native/gtk/GtkMountingManager.h +146 -0
- package/native/gtk/GtkRunLoopObserver.cpp +77 -0
- package/native/gtk/GtkRunLoopObserver.h +41 -0
- package/native/gtk/GtkScrollView.cpp +626 -0
- package/native/gtk/GtkScrollView.h +209 -0
- package/native/gtk/GtkSkiaContext.cpp +248 -0
- package/native/gtk/GtkSkiaContext.h +62 -0
- package/native/gtk/GtkSkiaModule.cpp +80 -0
- package/native/gtk/GtkSkiaModule.h +51 -0
- package/native/gtk/GtkSkiaPeer.cpp +285 -0
- package/native/gtk/GtkSkiaPeer.h +63 -0
- package/native/gtk/GtkTextInput.cpp +428 -0
- package/native/gtk/GtkTextInput.h +123 -0
- package/native/gtk/GtkTextPeer.cpp +425 -0
- package/native/gtk/GtkTextPeer.h +87 -0
- package/native/gtk/GtkTitleBar.cpp +273 -0
- package/native/gtk/GtkTitleBar.h +96 -0
- package/native/gtk/GtkTitleBarLayout.cpp +45 -0
- package/native/gtk/GtkTitleBarLayout.h +36 -0
- package/native/gtk/GtkTouchDispatcher.cpp +671 -0
- package/native/gtk/GtkTouchDispatcher.h +139 -0
- package/native/gtk/GtkWindowControl.cpp +223 -0
- package/native/gtk/GtkWindowModule.cpp +194 -0
- package/native/gtk/GtkWindowModule.h +62 -0
- package/native/gtk/PangoTextLayout.cpp +295 -0
- package/native/gtk/PangoTextLayout.h +54 -0
- package/native/gtk/PangoTextLayoutManager.cpp +140 -0
- package/native/gtk/PlatformServicesGtk.cpp +583 -0
- package/native/gtk/RnView.cpp +1407 -0
- package/native/gtk/RnView.h +339 -0
- package/native/gtk/demo_layout_gtk.cpp +57 -0
- package/native/gtk/main_gtk.cpp +1981 -0
- package/native/gtk/mount_harness_gtk.cpp +143 -0
- package/native/tests/TestMainGtk.cpp +25 -0
- package/native/tests/notification_stub.cpp +103 -0
- package/native/tests/test_accessibility.cpp +251 -0
- package/native/tests/test_focus.cpp +194 -0
- package/native/tests/test_hittest.cpp +242 -0
- package/native/tests/test_image.cpp +135 -0
- package/native/tests/test_key_events_gtk.cpp +112 -0
- package/native/tests/test_mounting.cpp +415 -0
- package/native/tests/test_scrollview.cpp +258 -0
- package/native/tests/test_skia_texture.cpp +120 -0
- package/native/tests/test_text.cpp +147 -0
- package/native/tests/test_textinput.cpp +598 -0
- package/native/tests/test_titlebar.cpp +137 -0
- package/native/tests/test_titlebar_drag.cpp +138 -0
- package/native/tests/test_version.cpp +49 -0
- package/native/tests/test_view.cpp +162 -0
- package/native/tests/test_viewprops.cpp +254 -0
- package/package.json +26 -0
- package/react-native.config.js +21 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#include "GtkTextInput.h"
|
|
2
|
+
|
|
3
|
+
#include "GtkTextPeer.h"
|
|
4
|
+
|
|
5
|
+
#include "PangoTextLayout.h"
|
|
6
|
+
|
|
7
|
+
#include <react/renderer/components/iostextinput/TextInputProps.h>
|
|
8
|
+
#include <react/renderer/components/textinput/TextInputEventEmitter.h>
|
|
9
|
+
|
|
10
|
+
#include <cmath>
|
|
11
|
+
#include <cstdint>
|
|
12
|
+
#include <string_view>
|
|
13
|
+
|
|
14
|
+
namespace basalt {
|
|
15
|
+
|
|
16
|
+
using facebook::react::AttributedString;
|
|
17
|
+
using facebook::react::ShadowView;
|
|
18
|
+
using facebook::react::Tag;
|
|
19
|
+
using facebook::react::TextInputEventEmitter;
|
|
20
|
+
using facebook::react::TextInputProps;
|
|
21
|
+
|
|
22
|
+
GtkTextInputManager::GtkTextInputManager(EmitterLookup lookup) : lookup_(std::move(lookup)) {}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Mutations
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
void GtkTextInputManager::update(RnView *view, const ShadowView &shadowView) {
|
|
29
|
+
const Tag tag = shadowView.tag;
|
|
30
|
+
auto [it, inserted] = entries_.try_emplace(tag);
|
|
31
|
+
Entry &entry = it->second;
|
|
32
|
+
|
|
33
|
+
entry.view = view;
|
|
34
|
+
entry.tag = tag;
|
|
35
|
+
entry.owner = this;
|
|
36
|
+
|
|
37
|
+
const auto props = std::dynamic_pointer_cast<const TextInputProps>(shadowView.props);
|
|
38
|
+
const bool multiline = props != nullptr && props->multiline;
|
|
39
|
+
|
|
40
|
+
// Before the signals, because a field that switches between single and
|
|
41
|
+
// multiline is a different widget and the old one's handlers go with it.
|
|
42
|
+
GtkWidget *peer = rn_view_set_editable(view, TRUE, multiline ? TRUE : FALSE);
|
|
43
|
+
const bool rebuilt = peer != entry.editable;
|
|
44
|
+
entry.editable = peer;
|
|
45
|
+
|
|
46
|
+
if (rebuilt) {
|
|
47
|
+
|
|
48
|
+
GObject *signals = rn_peer_signal_source(entry.editable);
|
|
49
|
+
g_signal_connect(signals, "changed", G_CALLBACK(onChanged), &entry);
|
|
50
|
+
// "activate" is a GtkText signal and a single-line idea: Enter in a
|
|
51
|
+
// multiline field inserts a newline, which is what SubmitBehavior::Newline
|
|
52
|
+
// means and what BaseTextInputProps already reports for one.
|
|
53
|
+
if (!multiline) {
|
|
54
|
+
g_signal_connect(entry.editable, "activate", G_CALLBACK(onActivate), &entry);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Both ends, because either can move on its own: an arrow key moves the
|
|
58
|
+
// caret with the other end following, and shift-arrow moves one and not
|
|
59
|
+
// the other. GtkText has no single "the selection changed" signal.
|
|
60
|
+
g_signal_connect(signals, "notify::cursor-position",
|
|
61
|
+
G_CALLBACK(onSelectionChanged), &entry);
|
|
62
|
+
// A GtkTextBuffer has no "selection-bound" property; it reports both ends
|
|
63
|
+
// moving through "notify::has-selection" instead.
|
|
64
|
+
g_signal_connect(signals,
|
|
65
|
+
multiline ? "notify::has-selection" : "notify::selection-bound",
|
|
66
|
+
G_CALLBACK(onSelectionChanged), &entry);
|
|
67
|
+
|
|
68
|
+
// Capture phase, because onKeyPress has to fire *before* onChange -- which
|
|
69
|
+
// means before GtkText has handled the key and changed the text. In the
|
|
70
|
+
// bubble phase the edit has already happened and the two events arrive the
|
|
71
|
+
// wrong way round.
|
|
72
|
+
GtkEventController *keys = gtk_event_controller_key_new();
|
|
73
|
+
gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_CAPTURE);
|
|
74
|
+
g_signal_connect(keys, "key-pressed", G_CALLBACK(onKeyPressed), &entry);
|
|
75
|
+
gtk_widget_add_controller(entry.editable, keys);
|
|
76
|
+
|
|
77
|
+
GtkEventController *focus = gtk_event_controller_focus_new();
|
|
78
|
+
g_signal_connect(focus, "enter", G_CALLBACK(onFocusEnter), &entry);
|
|
79
|
+
g_signal_connect(focus, "leave", G_CALLBACK(onFocusLeave), &entry);
|
|
80
|
+
gtk_widget_add_controller(entry.editable, focus);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Yoga has resolved border and padding into the content inset; the GtkText is
|
|
84
|
+
// allocated inside it, so `paddingHorizontal` on a field means what it means
|
|
85
|
+
// on a <View>.
|
|
86
|
+
const auto &insets = shadowView.layoutMetrics.contentInsets;
|
|
87
|
+
const GtkBorder border = {
|
|
88
|
+
.left = static_cast<int16_t>(std::lround(insets.left)),
|
|
89
|
+
.right = static_cast<int16_t>(std::lround(insets.right)),
|
|
90
|
+
.top = static_cast<int16_t>(std::lround(insets.top)),
|
|
91
|
+
.bottom = static_cast<int16_t>(std::lround(insets.bottom)),
|
|
92
|
+
};
|
|
93
|
+
rn_view_set_peer_insets(view, &border);
|
|
94
|
+
|
|
95
|
+
if (props == nullptr || entry.editable == nullptr) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Controlled component: JavaScript owns the value. Three things have to be
|
|
100
|
+
// true at once, and each fails differently.
|
|
101
|
+
//
|
|
102
|
+
// Setting it must not look like typing, or the change we report provokes a
|
|
103
|
+
// re-render that sets it again and the two chase each other -- `applying`.
|
|
104
|
+
//
|
|
105
|
+
// A prop older than what the user has since typed must not be applied at all,
|
|
106
|
+
// or a fast typist watches characters reorder themselves. That is what
|
|
107
|
+
// React Native counts events for, and dropping such a value *without
|
|
108
|
+
// recording it* is deliberate: the next render, once JavaScript has caught
|
|
109
|
+
// up, applies it.
|
|
110
|
+
//
|
|
111
|
+
// And it is applied when the prop *changes*, not when it differs from the
|
|
112
|
+
// widget, which is the only thing that tells a controlled field from an
|
|
113
|
+
// uncontrolled one. See the header.
|
|
114
|
+
const bool stale = props->mostRecentEventCount < entry.eventCount;
|
|
115
|
+
const bool changed = !entry.sawProps || props->text != entry.lastPropText;
|
|
116
|
+
if (changed && !stale) {
|
|
117
|
+
entry.lastPropText = props->text;
|
|
118
|
+
entry.sawProps = true;
|
|
119
|
+
|
|
120
|
+
char *current = rn_peer_get_text(entry.editable);
|
|
121
|
+
const bool differs = props->text != (current != nullptr ? current : "");
|
|
122
|
+
g_free(current);
|
|
123
|
+
if (differs) {
|
|
124
|
+
entry.applying = true;
|
|
125
|
+
// Preserve the cursor: assigning the text resets it to the start, which
|
|
126
|
+
// sends the caret home on every keystroke of a controlled input.
|
|
127
|
+
const int cursor = rn_peer_get_position(entry.editable);
|
|
128
|
+
rn_peer_set_text(entry.editable, props->text.c_str());
|
|
129
|
+
rn_peer_set_position(entry.editable,
|
|
130
|
+
MIN(cursor, static_cast<int>(props->text.size())));
|
|
131
|
+
entry.applying = false;
|
|
132
|
+
entry.lastReportedText = props->text;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A controlled *selection*, under the same staleness rule the text is under:
|
|
137
|
+
// JavaScript that has not yet seen the last keystroke must not be allowed to
|
|
138
|
+
// drag the caret back to where it thought it was.
|
|
139
|
+
//
|
|
140
|
+
// Applied when it changes rather than whenever it differs, for the reason
|
|
141
|
+
// `text` is: a field that is merely uncontrolled sends no selection at all,
|
|
142
|
+
// and re-asserting one every render would fight the user's own arrow keys.
|
|
143
|
+
if (props->selection.has_value() && !stale) {
|
|
144
|
+
const auto &selection = *props->selection;
|
|
145
|
+
if (!entry.lastPropSelection.has_value() ||
|
|
146
|
+
entry.lastPropSelection->start != selection.start ||
|
|
147
|
+
entry.lastPropSelection->end != selection.end) {
|
|
148
|
+
entry.lastPropSelection = selection;
|
|
149
|
+
entry.applying = true;
|
|
150
|
+
rn_peer_select_region(entry.editable, selection.start, selection.end);
|
|
151
|
+
entry.applying = false;
|
|
152
|
+
entry.lastReportedSelection =
|
|
153
|
+
facebook::react::AttributedString::Range{selection.start, selection.end - selection.start};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
rn_peer_set_placeholder(entry.editable, props->placeholder.c_str());
|
|
158
|
+
|
|
159
|
+
// GtkText renders in the GTK theme's colour and font, which has nothing to do
|
|
160
|
+
// with the `style` this component was given -- on a dark field that is dark
|
|
161
|
+
// text on dark. A PangoAttrList is how GtkText takes the style instead.
|
|
162
|
+
//
|
|
163
|
+
// fontSizeMultiplier is 1: nothing on this platform scales text for
|
|
164
|
+
// accessibility settings yet, and passing 0 would multiply the size away.
|
|
165
|
+
PangoAttrList *attributes = buildTextAttributes(props->getEffectiveTextAttributes(1.0F));
|
|
166
|
+
rn_peer_set_attributes(entry.editable, attributes);
|
|
167
|
+
pango_attr_list_unref(attributes);
|
|
168
|
+
|
|
169
|
+
// `editable` is the prop; `readOnly` is the newer spelling of its inverse,
|
|
170
|
+
// and React Native honours both.
|
|
171
|
+
const bool writable = props->traits.editable && !props->readOnly;
|
|
172
|
+
rn_peer_set_editable(entry.editable, writable ? TRUE : FALSE);
|
|
173
|
+
|
|
174
|
+
rn_peer_set_visibility(entry.editable, props->traits.secureTextEntry ? FALSE : TRUE);
|
|
175
|
+
|
|
176
|
+
// Zero means no limit, and so does the absurd default React Native uses when
|
|
177
|
+
// the prop is absent -- passing that through would be a limit nobody asked
|
|
178
|
+
// for on a field that had none.
|
|
179
|
+
rn_peer_set_max_length(
|
|
180
|
+
entry.editable,
|
|
181
|
+
(props->maxLength > 0 && props->maxLength < 1000000) ? props->maxLength : 0);
|
|
182
|
+
|
|
183
|
+
if (inserted && props->autoFocus) {
|
|
184
|
+
gtk_widget_grab_focus(entry.editable);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
void GtkTextInputManager::remove(Tag tag) {
|
|
189
|
+
const auto it = entries_.find(tag);
|
|
190
|
+
if (it == entries_.end()) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// The signal handlers hold a pointer to the Entry. Dropping the editable
|
|
194
|
+
// first takes them with it.
|
|
195
|
+
if (it->second.view != nullptr && RN_IS_VIEW(it->second.view)) {
|
|
196
|
+
rn_view_set_editable(it->second.view, FALSE, FALSE);
|
|
197
|
+
}
|
|
198
|
+
entries_.erase(it);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Events
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
std::shared_ptr<const TextInputEventEmitter> GtkTextInputManager::emitterFor(Tag tag) const {
|
|
206
|
+
return std::dynamic_pointer_cast<const TextInputEventEmitter>(lookup_(tag));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
TextInputEventEmitter::Metrics GtkTextInputManager::metricsFor(const Entry &entry) const {
|
|
210
|
+
const char *text = entry.editable != nullptr
|
|
211
|
+
? rn_peer_get_text(entry.editable)
|
|
212
|
+
: "";
|
|
213
|
+
|
|
214
|
+
TextInputEventEmitter::Metrics metrics{};
|
|
215
|
+
metrics.text = text != nullptr ? text : "";
|
|
216
|
+
metrics.eventCount = entry.eventCount;
|
|
217
|
+
metrics.target = entry.tag;
|
|
218
|
+
|
|
219
|
+
// The whole selection, not only the caret. `get_selection_bounds` answers
|
|
220
|
+
// FALSE when nothing is selected, and leaves the out parameters alone -- so
|
|
221
|
+
// the caret position is the fallback and a zero length is the truth about it.
|
|
222
|
+
int start = 0;
|
|
223
|
+
int end = 0;
|
|
224
|
+
if (entry.editable != nullptr &&
|
|
225
|
+
!rn_peer_get_selection_bounds(entry.editable, &start, &end)) {
|
|
226
|
+
start = end = rn_peer_get_position(entry.editable);
|
|
227
|
+
}
|
|
228
|
+
metrics.selectionRange = AttributedString::Range{start, end - start};
|
|
229
|
+
|
|
230
|
+
// The scroll-shaped fields exist because iOS's text view is a scroll view.
|
|
231
|
+
// Nothing here scrolls yet, so they describe a viewport the size of the
|
|
232
|
+
// field, which is true and keeps JavaScript's arithmetic sane.
|
|
233
|
+
const auto width = static_cast<facebook::react::Float>(
|
|
234
|
+
entry.view != nullptr ? gtk_widget_get_width(GTK_WIDGET(entry.view)) : 0);
|
|
235
|
+
const auto height = static_cast<facebook::react::Float>(
|
|
236
|
+
entry.view != nullptr ? gtk_widget_get_height(GTK_WIDGET(entry.view)) : 0);
|
|
237
|
+
metrics.containerSize = {.width = width, .height = height};
|
|
238
|
+
metrics.contentSize = metrics.containerSize;
|
|
239
|
+
metrics.layoutMeasurement = metrics.containerSize;
|
|
240
|
+
metrics.zoomScale = 1.0F;
|
|
241
|
+
|
|
242
|
+
return metrics;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
void GtkTextInputManager::onChanged(GObject * /*source*/, gpointer userData) {
|
|
246
|
+
auto *entry = static_cast<Entry *>(userData);
|
|
247
|
+
if (entry->applying) {
|
|
248
|
+
// This is a prop being applied, not the user typing.
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
char *text = rn_peer_get_text(entry->editable);
|
|
253
|
+
const std::string value = text != nullptr ? text : "";
|
|
254
|
+
if (value == entry->lastReportedText) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
entry->lastReportedText = value;
|
|
258
|
+
entry->eventCount++;
|
|
259
|
+
|
|
260
|
+
const auto emitter = entry->owner->emitterFor(entry->tag);
|
|
261
|
+
if (emitter != nullptr) {
|
|
262
|
+
emitter->onChange(entry->owner->metricsFor(*entry));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
void GtkTextInputManager::onActivate(GtkText * /*editable*/, gpointer userData) {
|
|
267
|
+
auto *entry = static_cast<Entry *>(userData);
|
|
268
|
+
const auto emitter = entry->owner->emitterFor(entry->tag);
|
|
269
|
+
if (emitter != nullptr) {
|
|
270
|
+
// Enter. React Native calls this submitEditing, and follows it with
|
|
271
|
+
// endEditing on platforms where the field also gives up focus; GtkText
|
|
272
|
+
// keeps focus on activate, so only the submit is reported.
|
|
273
|
+
emitter->onSubmitEditing(entry->owner->metricsFor(*entry));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
gboolean GtkTextInputManager::onKeyPressed(GtkEventControllerKey * /*controller*/,
|
|
278
|
+
guint keyval,
|
|
279
|
+
guint /*keycode*/,
|
|
280
|
+
GdkModifierType /*state*/,
|
|
281
|
+
gpointer userData) {
|
|
282
|
+
auto *entry = static_cast<Entry *>(userData);
|
|
283
|
+
if (entry == nullptr || entry->owner == nullptr) {
|
|
284
|
+
return GDK_EVENT_PROPAGATE;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// React Native's contract: 'Enter' and 'Backspace' by name, and the typed
|
|
288
|
+
// character otherwise -- including ' ' for space. Keys that produce no
|
|
289
|
+
// character at all, the arrows and the modifiers, send nothing, which is what
|
|
290
|
+
// iOS does too.
|
|
291
|
+
std::string key;
|
|
292
|
+
switch (keyval) {
|
|
293
|
+
case GDK_KEY_Return:
|
|
294
|
+
case GDK_KEY_KP_Enter:
|
|
295
|
+
case GDK_KEY_ISO_Enter:
|
|
296
|
+
key = "Enter";
|
|
297
|
+
break;
|
|
298
|
+
case GDK_KEY_BackSpace:
|
|
299
|
+
key = "Backspace";
|
|
300
|
+
break;
|
|
301
|
+
default: {
|
|
302
|
+
const gunichar character = gdk_keyval_to_unicode(keyval);
|
|
303
|
+
if (character == 0 || !g_unichar_isprint(character)) {
|
|
304
|
+
return GDK_EVENT_PROPAGATE;
|
|
305
|
+
}
|
|
306
|
+
char utf8[7] = {0};
|
|
307
|
+
const gint length = g_unichar_to_utf8(character, utf8);
|
|
308
|
+
key.assign(utf8, static_cast<size_t>(length));
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (auto emitter = entry->owner->emitterFor(entry->tag)) {
|
|
314
|
+
TextInputEventEmitter::KeyPressMetrics metrics{};
|
|
315
|
+
metrics.text = key;
|
|
316
|
+
metrics.eventCount = entry->eventCount;
|
|
317
|
+
emitter->onKeyPress(metrics);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Never handled here: this observes the key on its way to GtkText, which
|
|
321
|
+
// still has to do the editing.
|
|
322
|
+
return GDK_EVENT_PROPAGATE;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
void GtkTextInputManager::onSelectionChanged(GObject * /*object*/,
|
|
326
|
+
GParamSpec * /*pspec*/,
|
|
327
|
+
gpointer userData) {
|
|
328
|
+
auto *entry = static_cast<Entry *>(userData);
|
|
329
|
+
if (entry == nullptr || entry->owner == nullptr) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Not while a prop is being pushed in. Applying `text` moves the caret, and
|
|
333
|
+
// reporting that as the user selecting something would make a controlled
|
|
334
|
+
// field fight its own render.
|
|
335
|
+
if (entry->applying) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const auto metrics = entry->owner->metricsFor(*entry);
|
|
340
|
+
// Both signals fire for one movement, so this collapses the pair into the
|
|
341
|
+
// one event JavaScript should see.
|
|
342
|
+
if (metrics.selectionRange.location == entry->lastReportedSelection.location &&
|
|
343
|
+
metrics.selectionRange.length == entry->lastReportedSelection.length) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
entry->lastReportedSelection = metrics.selectionRange;
|
|
347
|
+
|
|
348
|
+
if (auto emitter = entry->owner->emitterFor(entry->tag)) {
|
|
349
|
+
emitter->onSelectionChange(metrics);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
void GtkTextInputManager::onFocusEnter(GtkEventControllerFocus * /*controller*/, gpointer userData) {
|
|
354
|
+
auto *entry = static_cast<Entry *>(userData);
|
|
355
|
+
const auto emitter = entry->owner->emitterFor(entry->tag);
|
|
356
|
+
if (emitter != nullptr) {
|
|
357
|
+
emitter->onFocus(entry->owner->metricsFor(*entry));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
void GtkTextInputManager::onFocusLeave(GtkEventControllerFocus * /*controller*/, gpointer userData) {
|
|
362
|
+
auto *entry = static_cast<Entry *>(userData);
|
|
363
|
+
const auto emitter = entry->owner->emitterFor(entry->tag);
|
|
364
|
+
if (emitter != nullptr) {
|
|
365
|
+
emitter->onBlur(entry->owner->metricsFor(*entry));
|
|
366
|
+
emitter->onEndEditing(entry->owner->metricsFor(*entry));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Commands
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
bool GtkTextInputManager::dispatchCommand(Tag tag,
|
|
375
|
+
const std::string &name,
|
|
376
|
+
const folly::dynamic &args) {
|
|
377
|
+
const auto it = entries_.find(tag);
|
|
378
|
+
if (it == entries_.end()) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
Entry &entry = it->second;
|
|
382
|
+
if (entry.editable == nullptr) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (name == "focus") {
|
|
387
|
+
gtk_widget_grab_focus(GTK_WIDGET(entry.editable));
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (name == "blur") {
|
|
392
|
+
// GTK has no "unfocus this widget"; focus moves, it is not dropped. Handing
|
|
393
|
+
// it to the window's default is the closest thing, and it makes the focus
|
|
394
|
+
// controller report a leave, so JavaScript still sees onBlur.
|
|
395
|
+
GtkRoot *root = gtk_widget_get_root(GTK_WIDGET(entry.editable));
|
|
396
|
+
if (root != nullptr) {
|
|
397
|
+
gtk_widget_grab_focus(GTK_WIDGET(root));
|
|
398
|
+
}
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (name == "setTextAndSelection") {
|
|
403
|
+
// [eventCount, text, start, end]. An eventCount older than what the user
|
|
404
|
+
// has since typed means this command is stale and must be dropped, which is
|
|
405
|
+
// the whole reason React Native counts them.
|
|
406
|
+
if (args.isArray() && args.size() >= 2) {
|
|
407
|
+
const int eventCount = static_cast<int>(args[0].asInt());
|
|
408
|
+
if (eventCount < entry.eventCount) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
entry.applying = true;
|
|
412
|
+
const auto text = args[1].isString() ? args[1].asString() : std::string{};
|
|
413
|
+
rn_peer_set_text(entry.editable, text.c_str());
|
|
414
|
+
if (args.size() >= 4 && args[2].isInt()) {
|
|
415
|
+
const int start = static_cast<int>(args[2].asInt());
|
|
416
|
+
const int end = static_cast<int>(args[3].asInt());
|
|
417
|
+
rn_peer_select_region(entry.editable, start, end);
|
|
418
|
+
}
|
|
419
|
+
entry.applying = false;
|
|
420
|
+
entry.lastReportedText = text;
|
|
421
|
+
}
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
} // namespace basalt
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// <TextInput> on GTK4.
|
|
2
|
+
//
|
|
3
|
+
// The native half is React Native's own *iOS* TextInput: its shadow node,
|
|
4
|
+
// props, state and event emitter are pure C++ and measure through a
|
|
5
|
+
// TextLayoutManager, which on this platform is the Pango one. Android's variant
|
|
6
|
+
// includes fbjni and calls into a Java FabricUIManager, so it is unusable here;
|
|
7
|
+
// see docs/DECISIONS.md. The component name is therefore "TextInput", which is
|
|
8
|
+
// what packages/basalt-core' src/overrides/TextInput.js asks for.
|
|
9
|
+
//
|
|
10
|
+
// The editing itself is a real GtkText -- the widget behind GtkEntry -- rather
|
|
11
|
+
// than a cursor drawn on a PangoLayout. That brings input methods, selection,
|
|
12
|
+
// the clipboard, and every keybinding a Linux user expects, none of which is
|
|
13
|
+
// worth reimplementing and all of which is easy to get subtly wrong.
|
|
14
|
+
//
|
|
15
|
+
// The awkward part of a text field is not typing, it is that React Native's
|
|
16
|
+
// <TextInput> is a controlled component: JavaScript owns the value, and the
|
|
17
|
+
// widget must not fight it. Every `changed` signal is reported to JavaScript,
|
|
18
|
+
// which re-renders and sends the text back down as a prop -- so applying that
|
|
19
|
+
// prop must not itself look like the user typing, or the two chase each other.
|
|
20
|
+
// `applying_` is what breaks that loop.
|
|
21
|
+
|
|
22
|
+
#pragma once
|
|
23
|
+
|
|
24
|
+
#include "RnView.h"
|
|
25
|
+
|
|
26
|
+
#include <react/renderer/components/iostextinput/TextInputShadowNode.h>
|
|
27
|
+
#include <react/renderer/core/EventEmitter.h>
|
|
28
|
+
#include <react/renderer/mounting/ShadowView.h>
|
|
29
|
+
|
|
30
|
+
#include <functional>
|
|
31
|
+
#include <optional>
|
|
32
|
+
#include <string>
|
|
33
|
+
#include <unordered_map>
|
|
34
|
+
|
|
35
|
+
namespace basalt {
|
|
36
|
+
|
|
37
|
+
class GtkTextInputManager {
|
|
38
|
+
public:
|
|
39
|
+
using EmitterLookup = std::function<facebook::react::EventEmitter::Shared(facebook::react::Tag)>;
|
|
40
|
+
|
|
41
|
+
explicit GtkTextInputManager(EmitterLookup lookup);
|
|
42
|
+
|
|
43
|
+
// Called for every mutation touching a TextInput. Creates the editable the
|
|
44
|
+
// first time, then applies props.
|
|
45
|
+
void update(RnView *view, const facebook::react::ShadowView &shadowView);
|
|
46
|
+
|
|
47
|
+
void remove(facebook::react::Tag tag);
|
|
48
|
+
|
|
49
|
+
// focus, blur, and setTextAndSelection. Returns false for anything else.
|
|
50
|
+
bool dispatchCommand(facebook::react::Tag tag,
|
|
51
|
+
const std::string &name,
|
|
52
|
+
const folly::dynamic &args);
|
|
53
|
+
|
|
54
|
+
private:
|
|
55
|
+
struct Entry {
|
|
56
|
+
RnView *view{nullptr};
|
|
57
|
+
// A GtkText or a GtkTextView; see GtkTextPeer.h.
|
|
58
|
+
GtkWidget *editable{nullptr};
|
|
59
|
+
facebook::react::Tag tag{0};
|
|
60
|
+
GtkTextInputManager *owner{nullptr};
|
|
61
|
+
|
|
62
|
+
// React Native counts events so it can ignore a prop update that is older
|
|
63
|
+
// than what the user has since typed. Every emitted metric carries it.
|
|
64
|
+
int eventCount{0};
|
|
65
|
+
|
|
66
|
+
// True while a prop is being pushed into the widget, so the `changed`
|
|
67
|
+
// signal it provokes is not reported back as the user typing.
|
|
68
|
+
bool applying{false};
|
|
69
|
+
|
|
70
|
+
std::string lastReportedText;
|
|
71
|
+
|
|
72
|
+
// The selection as JavaScript last saw it. GtkText fires two notifies for
|
|
73
|
+
// one movement, so this is what keeps that from being two events.
|
|
74
|
+
facebook::react::AttributedString::Range lastReportedSelection{0, 0};
|
|
75
|
+
|
|
76
|
+
// The last `selection` prop seen, for the same reason lastPropText exists:
|
|
77
|
+
// applying on change rather than on difference is what leaves an
|
|
78
|
+
// uncontrolled field's own caret alone.
|
|
79
|
+
std::optional<facebook::react::Selection> lastPropSelection{};
|
|
80
|
+
|
|
81
|
+
// The last `text` prop actually seen, and whether one has been seen at all.
|
|
82
|
+
//
|
|
83
|
+
// This is what tells a *controlled* field from an uncontrolled one, which
|
|
84
|
+
// props alone cannot: React Native's TextInput.js sends
|
|
85
|
+
// `text={value ?? defaultValue}`, and an uncontrolled field with no default
|
|
86
|
+
// sends undefined, which arrives here as the empty string -- exactly what a
|
|
87
|
+
// controlled field that JavaScript has cleared sends. Applying it whenever
|
|
88
|
+
// it differs from the widget therefore wipes an uncontrolled field the
|
|
89
|
+
// moment anything else in the tree re-renders, because React Native
|
|
90
|
+
// re-sends `mostRecentEventCount` on every change and that alone produces
|
|
91
|
+
// an Update mutation.
|
|
92
|
+
//
|
|
93
|
+
// So the prop is applied when it *changes*, not when it differs from the
|
|
94
|
+
// widget. A controlled field's value changes as the user types; an
|
|
95
|
+
// uncontrolled one's never does. Found on Windows.
|
|
96
|
+
std::string lastPropText;
|
|
97
|
+
bool sawProps{false};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// The sender differs by peer -- the widget for a GtkText, the buffer for a
|
|
101
|
+
// GtkTextView -- so it arrives untyped and goes unused.
|
|
102
|
+
static void onChanged(GObject *source, gpointer userData);
|
|
103
|
+
static void onActivate(GtkText *editable, gpointer userData);
|
|
104
|
+
static gboolean onKeyPressed(GtkEventControllerKey *controller,
|
|
105
|
+
guint keyval,
|
|
106
|
+
guint keycode,
|
|
107
|
+
GdkModifierType state,
|
|
108
|
+
gpointer userData);
|
|
109
|
+
static void onSelectionChanged(GObject *object, GParamSpec *pspec, gpointer userData);
|
|
110
|
+
static void onFocusEnter(GtkEventControllerFocus *controller, gpointer userData);
|
|
111
|
+
static void onFocusLeave(GtkEventControllerFocus *controller, gpointer userData);
|
|
112
|
+
|
|
113
|
+
// Fills in the parts of Metrics every event carries.
|
|
114
|
+
facebook::react::TextInputEventEmitter::Metrics metricsFor(const Entry &entry) const;
|
|
115
|
+
|
|
116
|
+
std::shared_ptr<const facebook::react::TextInputEventEmitter> emitterFor(
|
|
117
|
+
facebook::react::Tag tag) const;
|
|
118
|
+
|
|
119
|
+
EmitterLookup lookup_;
|
|
120
|
+
std::unordered_map<facebook::react::Tag, Entry> entries_;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
} // namespace basalt
|