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.
- checksums.yaml +7 -0
- data/.doc/ext/reflex-terminal/native.cpp +19 -0
- data/.doc/ext/reflex-terminal/renderer.cpp +110 -0
- data/.doc/ext/reflex-terminal/terminal.cpp +530 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +12 -0
- data/.github/workflows/release-gem.yml +60 -0
- data/.github/workflows/tag.yml +35 -0
- data/.github/workflows/test.yml +46 -0
- data/.github/workflows/utils.rb +127 -0
- data/CONTRIBUTING.md +7 -0
- data/ChangeLog.md +6 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +100 -0
- data/Rakefile +44 -0
- data/VERSION +1 -0
- data/examples/terminal.rb +101 -0
- data/ext/reflex-terminal/defs.h +17 -0
- data/ext/reflex-terminal/extconf.rb +29 -0
- data/ext/reflex-terminal/native.cpp +19 -0
- data/ext/reflex-terminal/renderer.cpp +118 -0
- data/ext/reflex-terminal/terminal.cpp +572 -0
- data/include/reflex/terminal.h +233 -0
- data/include/reflex-terminal/defs.h +36 -0
- data/include/reflex-terminal/renderer.h +57 -0
- data/include/reflex-terminal/ruby/renderer.h +41 -0
- data/include/reflex-terminal/ruby/terminal.h +43 -0
- data/include/reflex-terminal/ruby.h +11 -0
- data/include/reflex-terminal.h +13 -0
- data/lib/reflex/bell_event.rb +15 -0
- data/lib/reflex/terminal.rb +168 -0
- data/lib/reflex/terminal_view.rb +268 -0
- data/lib/reflex-terminal/ext.rb +2 -0
- data/lib/reflex-terminal/extension.rb +41 -0
- data/lib/reflex-terminal.rb +7 -0
- data/reflex-terminal.gemspec +39 -0
- data/src/pty.cpp +236 -0
- data/src/renderer.cpp +365 -0
- data/src/terminal.cpp +1547 -0
- data/src/terminal.h +66 -0
- data/src/win32/pty.cpp +387 -0
- data/test/helper.rb +11 -0
- data/test/test_renderer.rb +113 -0
- data/test/test_terminal.rb +683 -0
- data/test/test_terminal_view.rb +182 -0
- 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
|