reflex-terminal 0.1.0

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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.doc/ext/reflex-terminal/native.cpp +19 -0
  3. data/.doc/ext/reflex-terminal/renderer.cpp +110 -0
  4. data/.doc/ext/reflex-terminal/terminal.cpp +530 -0
  5. data/.github/PULL_REQUEST_TEMPLATE.md +12 -0
  6. data/.github/workflows/release-gem.yml +60 -0
  7. data/.github/workflows/tag.yml +35 -0
  8. data/.github/workflows/test.yml +46 -0
  9. data/.github/workflows/utils.rb +127 -0
  10. data/CONTRIBUTING.md +7 -0
  11. data/ChangeLog.md +6 -0
  12. data/Gemfile +5 -0
  13. data/LICENSE +21 -0
  14. data/README.md +100 -0
  15. data/Rakefile +44 -0
  16. data/VERSION +1 -0
  17. data/examples/terminal.rb +101 -0
  18. data/ext/reflex-terminal/defs.h +17 -0
  19. data/ext/reflex-terminal/extconf.rb +29 -0
  20. data/ext/reflex-terminal/native.cpp +19 -0
  21. data/ext/reflex-terminal/renderer.cpp +118 -0
  22. data/ext/reflex-terminal/terminal.cpp +572 -0
  23. data/include/reflex/terminal.h +233 -0
  24. data/include/reflex-terminal/defs.h +36 -0
  25. data/include/reflex-terminal/renderer.h +57 -0
  26. data/include/reflex-terminal/ruby/renderer.h +41 -0
  27. data/include/reflex-terminal/ruby/terminal.h +43 -0
  28. data/include/reflex-terminal/ruby.h +11 -0
  29. data/include/reflex-terminal.h +13 -0
  30. data/lib/reflex/bell_event.rb +15 -0
  31. data/lib/reflex/terminal.rb +168 -0
  32. data/lib/reflex/terminal_view.rb +268 -0
  33. data/lib/reflex-terminal/ext.rb +2 -0
  34. data/lib/reflex-terminal/extension.rb +41 -0
  35. data/lib/reflex-terminal.rb +7 -0
  36. data/reflex-terminal.gemspec +39 -0
  37. data/src/pty.cpp +236 -0
  38. data/src/renderer.cpp +365 -0
  39. data/src/terminal.cpp +1547 -0
  40. data/src/terminal.h +66 -0
  41. data/src/win32/pty.cpp +387 -0
  42. data/test/helper.rb +11 -0
  43. data/test/test_renderer.rb +113 -0
  44. data/test/test_terminal.rb +683 -0
  45. data/test/test_terminal_view.rb +182 -0
  46. metadata +152 -0
data/src/terminal.h ADDED
@@ -0,0 +1,66 @@
1
+ // -*- c++ -*-
2
+ #pragma once
3
+ #ifndef __REFLEX_TERMINAL_SRC_TERMINAL_H__
4
+ #define __REFLEX_TERMINAL_SRC_TERMINAL_H__
5
+
6
+
7
+ #include <stddef.h>
8
+ #include <xot/pimpl.h>
9
+ #include <xot/noncopyable.h>
10
+ #include <reflex/defs.h>
11
+ #include <reflex/terminal.h>
12
+
13
+
14
+ namespace Reflex
15
+ {
16
+
17
+
18
+ class PTY : public Xot::NonCopyable
19
+ {
20
+
21
+ public:
22
+
23
+ PTY ();
24
+
25
+ ~PTY ();
26
+
27
+ void spawn (
28
+ const StringList& args, const Terminal::EnvMap& envs,
29
+ int columns, int rows, int cell_width, int cell_height,
30
+ bool login = false);
31
+
32
+ size_t read (char* buffer, size_t size);
33
+
34
+ bool wait_readable (int timeout_msec) const;
35
+
36
+ void write (const char* bytes, size_t size);
37
+
38
+ void set_size (
39
+ int columns, int rows, int cell_width, int cell_height);
40
+
41
+ void close ();
42
+
43
+ bool is_open () const;
44
+
45
+ bool is_child_alive () const;
46
+
47
+ operator bool () const;
48
+
49
+ bool operator ! () const;
50
+
51
+ struct Data;
52
+
53
+ Xot::PImpl<Data> self;
54
+
55
+ };// PTY
56
+
57
+
58
+ Terminal::EnvMap Terminal_make_child_envs (const Terminal::EnvMap& envs);
59
+
60
+ const std::vector<uint>& Terminal_get_cell_offsets (const Terminal& terminal);
61
+
62
+
63
+ }// Reflex
64
+
65
+
66
+ #endif//EOH
data/src/win32/pty.cpp ADDED
@@ -0,0 +1,387 @@
1
+ // ConPTY needs windows 10 1809
2
+ #define XOT_WIN32_WINNT 0x0A00// for <xot/windows.h>
3
+ #define NTDDI_VERSION 0x0A000006// NTDDI_WIN10_RS5
4
+
5
+ #include "../terminal.h"
6
+
7
+
8
+ #include <stdlib.h>
9
+ #include <string.h>
10
+ #include <wchar.h>
11
+ #include <string>
12
+ #include <vector>
13
+ #include <map>
14
+ #include <xot/windows.h>
15
+ #include <reflex/exception.h>
16
+
17
+
18
+ namespace Reflex
19
+ {
20
+
21
+
22
+ struct PTY::Data
23
+ {
24
+
25
+ HANDLE input = NULL;
26
+
27
+ HANDLE output = NULL;
28
+
29
+ HPCON console = NULL;
30
+
31
+ HANDLE process = NULL;
32
+
33
+ };// PTY::Data
34
+
35
+
36
+ static COORD
37
+ to_coord (int columns, int rows)
38
+ {
39
+ COORD size = {};
40
+ size.X = (SHORT) columns;
41
+ size.Y = (SHORT) rows;
42
+ return size;
43
+ }
44
+
45
+ static String
46
+ to_command_line (const StringList& args)
47
+ {
48
+ // CreateProcess takes one command line rather than an argument array,
49
+ // so put back the quoting a shell would have removed
50
+
51
+ String line;
52
+ for (const String& arg : args)
53
+ {
54
+ if (!line.empty()) line += ' ';
55
+
56
+ if (!arg.empty() && arg.find_first_of(" \t\"") == String::npos)
57
+ {
58
+ line += arg;
59
+ continue;
60
+ }
61
+
62
+ line += '"';
63
+ size_t backslashes = 0;
64
+ for (size_t i = 0; i < arg.size(); ++i)
65
+ {
66
+ if (arg[i] == '\\') {++backslashes; continue;}
67
+
68
+ // backslashes are literal unless they precede a quote, where
69
+ // each of them needs one of its own to stay literal
70
+ line.append(arg[i] == '"' ? backslashes * 2 + 1 : backslashes, '\\');
71
+ backslashes = 0;
72
+ line += arg[i];
73
+ }
74
+ line.append(backslashes * 2, '\\');// the closing quote counts as one
75
+ line += '"';
76
+ }
77
+ return line;
78
+ }
79
+
80
+ static std::wstring
81
+ to_environment_block (const Terminal::EnvMap& envs)
82
+ {
83
+ // a block of NAME=VALUE strings, each terminated by a NUL and the
84
+ // whole ended by an empty one
85
+
86
+ std::map<String, String, decltype([](const String& lhs, const String& rhs)
87
+ {
88
+ // windows matches variable names case insensitively, so an inherited
89
+ // Path has to be the same entry as a PATH from the application
90
+ return _stricmp(lhs.c_str(), rhs.c_str()) < 0;
91
+ })> map;
92
+
93
+ LPWCH current = GetEnvironmentStringsW();
94
+ if (current)
95
+ {
96
+ for (LPWCH p = current; *p; p += wcslen(p) + 1)
97
+ {
98
+ // a leading '=' marks the per-drive current directories
99
+ // that cmd.exe keeps, which are not variables to inherit
100
+ const wchar_t* eq = wcschr(p + 1, L'=');
101
+ if (!eq) continue;
102
+
103
+ map[String(p, eq - p)] = String(eq + 1, wcslen(eq + 1));
104
+ }
105
+ FreeEnvironmentStringsW(current);
106
+ }
107
+
108
+ for (const auto& it : Terminal_make_child_envs(envs))
109
+ {
110
+ if (it.second) map[it.first] = *it.second;
111
+ else map.erase(it.first);
112
+ }
113
+
114
+ String block;
115
+ for (const auto& it : map)
116
+ {
117
+ block += it.first;
118
+ block += '=';
119
+ block += it.second;
120
+ block += '\0';
121
+ }
122
+ block += '\0';
123
+ return block.to_wstr();
124
+ }
125
+
126
+
127
+ PTY::PTY ()
128
+ {
129
+ }
130
+
131
+ PTY::~PTY ()
132
+ {
133
+ close();
134
+ }
135
+
136
+ void
137
+ PTY::spawn (
138
+ const StringList& args, const Terminal::EnvMap& envs,
139
+ int columns, int rows, int cell_width, int cell_height,
140
+ bool login)
141
+ {
142
+ // login is ignored: windows has no convention for telling a shell
143
+ // to read its login profile
144
+
145
+ if (args.empty() || args[0].empty())
146
+ argument_error(__FILE__, __LINE__, "args is empty");
147
+ if (is_open())
148
+ invalid_state_error(__FILE__, __LINE__, "already spawned");
149
+
150
+ // the pseudo console owns the far end of each pipe
151
+ HANDLE child_input = NULL, child_output = NULL;
152
+ if (
153
+ !CreatePipe(&child_input, &self->input, NULL, 0) ||
154
+ !CreatePipe(&self->output, &child_output, NULL, 0))
155
+ {
156
+ // the ends the console has not taken over yet are ours to close
157
+ if (child_input) CloseHandle(child_input);
158
+ if (child_output) CloseHandle(child_output);
159
+ close();
160
+ system_error(__FILE__, __LINE__, "CreatePipe() failed");
161
+ }
162
+
163
+ // PIPE_NOWAIT is what keeps write() from stalling the UI thread once
164
+ // the console stops draining. It is documented as legacy, but nothing
165
+ // else makes a CreatePipe() handle non-blocking, and a terminal that
166
+ // can freeze the whole app is worse than one that refuses to start
167
+ DWORD mode = PIPE_NOWAIT;
168
+ if (!SetNamedPipeHandleState(self->input, &mode, NULL, NULL))
169
+ {
170
+ CloseHandle(child_input);
171
+ CloseHandle(child_output);
172
+ close();
173
+ system_error(__FILE__, __LINE__, "failed to make the input pipe non-blocking");
174
+ }
175
+
176
+ HRESULT result = CreatePseudoConsole(
177
+ to_coord(columns, rows), child_input, child_output, 0, &self->console);
178
+
179
+ // the console duplicated what it needs, so let go of our copies
180
+ CloseHandle(child_input);
181
+ CloseHandle(child_output);
182
+
183
+ if (FAILED(result))
184
+ {
185
+ close();
186
+ system_error(__FILE__, __LINE__, "CreatePseudoConsole() failed");
187
+ }
188
+
189
+ // asking for the size is expected to fail; a zero means it failed
190
+ // for some other reason and there is nothing to allocate
191
+ size_t attrs_size = 0;
192
+ InitializeProcThreadAttributeList(NULL, 1, 0, &attrs_size);
193
+ if (attrs_size == 0)
194
+ {
195
+ close();
196
+ system_error(__FILE__, __LINE__, "failed to size the process attributes");
197
+ }
198
+
199
+ std::vector<char> attrs(attrs_size);
200
+
201
+ STARTUPINFOEXW startup = {};
202
+ startup.StartupInfo.cb = sizeof(startup);
203
+ startup.lpAttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST) &attrs[0];
204
+
205
+ if (
206
+ !InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, &attrs_size) ||
207
+ !UpdateProcThreadAttribute(
208
+ startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
209
+ self->console, sizeof(self->console), NULL, NULL))
210
+ {
211
+ close();
212
+ system_error(__FILE__, __LINE__, "failed to set up the process attributes");
213
+ }
214
+
215
+ std::wstring command = to_command_line(args).to_wstr();
216
+ std::wstring block = to_environment_block(envs);
217
+
218
+ PROCESS_INFORMATION process = {};
219
+ BOOL ok = CreateProcessW(
220
+ NULL, &command[0], NULL, NULL, FALSE,
221
+ EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, &block[0], NULL,
222
+ &startup.StartupInfo, &process);
223
+
224
+ DeleteProcThreadAttributeList(startup.lpAttributeList);
225
+
226
+ if (!ok)
227
+ {
228
+ close();
229
+ system_error(__FILE__, __LINE__, "CreateProcess() failed");
230
+ }
231
+
232
+ CloseHandle(process.hThread);
233
+ self->process = process.hProcess;
234
+ }
235
+
236
+ size_t
237
+ PTY::read (char* buffer, size_t size)
238
+ {
239
+ if (!is_open()) return 0;
240
+
241
+ // pipes block on read, so take only what has already arrived
242
+ DWORD available = 0;
243
+ if (!PeekNamedPipe(self->output, NULL, 0, NULL, &available, NULL))
244
+ {
245
+ close();// the pipe is gone with the pseudo console that wrote to it
246
+ return 0;
247
+ }
248
+
249
+ // an exited child is deliberately not taken as the end of the output:
250
+ // the pseudo console pumps it on a thread of its own, so bytes
251
+ // written just before the exit may still be on their way. Reading
252
+ // stops when the pipe itself ends, above
253
+ if (available == 0) return 0;
254
+
255
+ if (available > size) available = (DWORD) size;
256
+
257
+ DWORD read = 0;
258
+ if (!ReadFile(self->output, buffer, available, &read, NULL))
259
+ {
260
+ close();
261
+ return 0;
262
+ }
263
+ return read;
264
+ }
265
+
266
+ bool
267
+ PTY::wait_readable (int timeout_msec) const
268
+ {
269
+ if (!is_open()) return false;
270
+
271
+ // there is nothing to wait on for a pipe, so poll it. Sleep(1) would
272
+ // really sleep a whole frame at the default timer resolution, so
273
+ // yield the rest of the time slice and keep the time ourselves
274
+ LARGE_INTEGER frequency = {}, start = {}, now = {};
275
+ QueryPerformanceFrequency(&frequency);
276
+ QueryPerformanceCounter(&start);
277
+
278
+ while (true)
279
+ {
280
+ DWORD available = 0;
281
+ if (!PeekNamedPipe(self->output, NULL, 0, NULL, &available, NULL))
282
+ return false;
283
+
284
+ if (available > 0) return true;
285
+
286
+ QueryPerformanceCounter(&now);
287
+ LONGLONG elapsed = (now.QuadPart - start.QuadPart) * 1000;
288
+ if (elapsed >= (LONGLONG) timeout_msec * frequency.QuadPart)
289
+ return false;
290
+
291
+ SwitchToThread();
292
+ }
293
+ }
294
+
295
+ void
296
+ PTY::write (const char* bytes, size_t size)
297
+ {
298
+ if (!is_open() || !bytes || size == 0) return;
299
+
300
+ enum {DRAIN_TIMEOUT = 100};// msec
301
+
302
+ size_t offset = 0;
303
+ ULONGLONG deadline = GetTickCount64() + DRAIN_TIMEOUT;
304
+ while (offset < size)
305
+ {
306
+ DWORD written = 0;
307
+ if (!WriteFile(self->input, bytes + offset, (DWORD) (size - offset), &written, NULL))
308
+ {
309
+ // a full PIPE_NOWAIT pipe reports failure here, with a code that
310
+ // also means the pipe is closing, so there is nothing to tell the
311
+ // two apart by. Fall through to the wait below and leave noticing
312
+ // a dead pipe to read(), which PeekNamedPipe() answers plainly.
313
+ // This is where errno lets the posix side close() right away
314
+ written = 0;
315
+ }
316
+
317
+ if (written > 0)
318
+ {
319
+ offset += written;
320
+ deadline = GetTickCount64() + DRAIN_TIMEOUT;// the timeout is per stall
321
+ continue;
322
+ }
323
+
324
+ if (GetTickCount64() >= deadline)
325
+ break;// give up to avoid blocking the UI thread forever
326
+
327
+ // the pipe is full; wait for the console to drain it. Sleeping
328
+ // rather than yielding as wait_readable() does: the default timer
329
+ // resolution is nothing next to this timeout, and spinning would
330
+ // burn the UI thread for as long as the wait lasts
331
+ Sleep(1);
332
+ }
333
+ }
334
+
335
+ void
336
+ PTY::set_size (int columns, int rows, int cell_width, int cell_height)
337
+ {
338
+ if (!is_open()) return;
339
+
340
+ // the cell size has nowhere to go: a pseudo console is sized in cells only
341
+ ResizePseudoConsole(self->console, to_coord(columns, rows));
342
+ }
343
+
344
+ void
345
+ PTY::close ()
346
+ {
347
+ if (self->console)
348
+ {
349
+ // out of the order Data declares them: the pseudo console flushes what
350
+ // it still holds on the way out, so closing the pipe it writes to
351
+ // first would leave it stuck
352
+ ClosePseudoConsole(self->console);
353
+ self->console = NULL;
354
+ }
355
+
356
+ if (self->input) {CloseHandle(self->input); self->input = NULL;}
357
+ if (self->output) {CloseHandle(self->output); self->output = NULL;}
358
+ if (self->process) {CloseHandle(self->process); self->process = NULL;}
359
+ }
360
+
361
+ bool
362
+ PTY::is_open () const
363
+ {
364
+ return self->output != NULL;
365
+ }
366
+
367
+ bool
368
+ PTY::is_child_alive () const
369
+ {
370
+ if (!self->process) return false;
371
+
372
+ return WaitForSingleObject(self->process, 0) == WAIT_TIMEOUT;
373
+ }
374
+
375
+ PTY::operator bool () const
376
+ {
377
+ return is_open();
378
+ }
379
+
380
+ bool
381
+ PTY::operator ! () const
382
+ {
383
+ return !operator bool();
384
+ }
385
+
386
+
387
+ }// Reflex
data/test/helper.rb ADDED
@@ -0,0 +1,11 @@
1
+ %w[../xot ../rucy ../rays ../reflex .]
2
+ .map {|s| File.expand_path "../#{s}/lib", __dir__}
3
+ .each {|s| $:.unshift s if !$:.include?(s) && File.directory?(s)}
4
+
5
+ require 'xot/test'
6
+ require 'reflex'
7
+ require 'reflex-terminal'
8
+
9
+ require 'test/unit'
10
+
11
+ include Xot::Test
@@ -0,0 +1,113 @@
1
+ require_relative 'helper'
2
+
3
+
4
+ class TestRenderer < Test::Unit::TestCase
5
+
6
+ def font(size = 14)
7
+ Reflex::Font.new Reflex::TerminalView::DEFAULT_FONT_NAME, size
8
+ end
9
+
10
+ def renderer(f = font)
11
+ ReflexTerminal::Renderer.new.tap {|r| r.font = f}
12
+ end
13
+
14
+ def terminal(text, columns = 20, rows = 4)
15
+ t = Reflex::Terminal.new columns, rows
16
+ t.feed text
17
+ t.update
18
+ t
19
+ end
20
+
21
+ def pixels(renderer, terminal)
22
+ image = Reflex::Image.new 400, 100
23
+ image.paint {|p| renderer.draw p, terminal, image.bounds}
24
+ bitmap = image.bitmap
25
+ (0...image.height.to_i).flat_map {|y|
26
+ (0...image.width.to_i).map {|x| bitmap[x, y].to_a}}
27
+ end
28
+
29
+ def test_font()
30
+ f = font 20
31
+ r = renderer f
32
+ # the font crosses back as a copy, so it is the same one by its own
33
+ # measure rather than by object
34
+ assert_equal f.name, r.font.name
35
+ assert_equal f.size, r.font.size
36
+ assert_equal f.width('M'), r.cell_width
37
+ assert_equal f.height.ceil, r.cell_height
38
+
39
+ # the atlas holds glyphs of one size, so it goes with the old font
40
+ r.bake_glyphs terminal('hello')
41
+ assert_operator r.glyph_count, :>, 0
42
+ r.font = font 30
43
+ assert_equal 0, r.glyph_count
44
+ end
45
+
46
+ def test_bake_glyphs()
47
+ r = renderer
48
+ ascii = (0x20..0x7e).size
49
+ assert_equal 0, r.glyph_count
50
+
51
+ # printable ascii comes along on the first bake, so that a screen of
52
+ # it never sends the renderer back to rasterize one character at a
53
+ # time. it also leaves something in the atlas no matter what was on
54
+ # show, which is how a caller tells that it has been baked
55
+ r.bake_glyphs terminal('aab')
56
+ assert_equal ascii, r.glyph_count
57
+
58
+ # the seed does not swallow what is not in it, and a cluster on the
59
+ # screen more than once still stands for one glyph
60
+ r.bake_glyphs terminal('あああい')
61
+ assert_equal ascii + 2, r.glyph_count
62
+
63
+ # a cell holds a whole cluster, so a flag is one glyph the font
64
+ # composes rather than the two regional indicators it is written with
65
+ r.bake_glyphs terminal("\u{1F1EF}\u{1F1F5}")
66
+ assert_equal ascii + 3, r.glyph_count
67
+ end
68
+
69
+ def test_draw()
70
+ r = renderer
71
+ t = terminal "hello \e[31mworld\e[0m"
72
+ r.bake_glyphs t
73
+
74
+ # a blank screen and a written one cannot look the same
75
+ assert_not_equal pixels(r, terminal('')), pixels(r, t)
76
+ end
77
+
78
+ def test_draw_colors()
79
+ # the terminal keeps a default background of its own and the child
80
+ # can change it, so the renderer has to ask every time rather than
81
+ # settle on a theme
82
+ r = renderer
83
+ t = terminal ''
84
+ assert_equal [0, 0, 0, 1], pixels(r, t).first
85
+
86
+ t.feed "\e]10;#00ff00\a\e]11;#800000\a"
87
+ t.update
88
+ assert_equal [0x80, 0, 0], pixels(r, t).first.first(3).map {|c| (c * 255).round}
89
+ end
90
+
91
+ def test_draw_inverted()
92
+ # a cell shows a selection by swapping its colors, so selecting text
93
+ # that is already inverse has to swap it back
94
+ r = renderer
95
+ t = terminal "\e[7mhello"
96
+ r.bake_glyphs t
97
+ assert_equal [1, 1, 1, 1], pixels(r, t).first
98
+
99
+ t.select 0, 0, 4, 0
100
+ t.update
101
+ assert_equal [0, 0, 0, 1], pixels(r, t).first
102
+ end
103
+
104
+ def test_draw_without_a_font()
105
+ r = ReflexTerminal::Renderer.new
106
+ t = terminal 'hello'
107
+ assert_raise(Rucy::NativeError) {r.bake_glyphs t}
108
+ assert_raise(Rucy::NativeError) do
109
+ Reflex::Image.new(10, 10).paint {|p| r.draw p, t, [0, 0, 10, 10]}
110
+ end
111
+ end
112
+
113
+ end unless linux?# rays has no font lookup by name there