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
@@ -0,0 +1,268 @@
1
+ require 'xot/util'
2
+ require 'reflex/view'
3
+ require 'reflex/font'
4
+ require 'reflex/color'
5
+ require 'reflex/bell_event'
6
+ require 'reflex/terminal'
7
+
8
+
9
+ module Reflex
10
+
11
+
12
+ class TerminalView < View
13
+
14
+ DEFAULT_FONT_SIZE = 14
15
+
16
+ DEFAULT_FONT_NAME =
17
+ if Xot.osx? then 'Menlo'
18
+ elsif Xot.win32? then 'Consolas'
19
+ else 'DejaVu Sans Mono'
20
+ end
21
+
22
+ CURSOR_BLINK_INTERVAL = 0.5
23
+
24
+ def initialize(
25
+ *args,
26
+ terminal: nil, # attach this instead of spawning one
27
+ command: nil, # passed to Terminal#spawn ($SHELL if nil)
28
+ envs: {}, # child's env, a nil value removes one
29
+ font: nil, # a Font, a font name, or [name, size]
30
+ font_size: DEFAULT_FONT_SIZE,
31
+ **kwargs, &block)
32
+
33
+ super(*args, **kwargs, &block)
34
+ @terminal, @command, @envs = terminal, command, envs
35
+ @renderer = ReflexTerminal::Renderer.new
36
+ @cursor_blink = true
37
+ @prev_bells = terminal&.bells || 0
38
+ @font_size = font_size
39
+ self.font = font || DEFAULT_FONT_NAME
40
+ end
41
+
42
+ attr_reader :terminal
43
+
44
+ def font=(font)
45
+ unless font.is_a? Font
46
+ name, size = [font].flatten
47
+ font = Font.new name, size || @font_size
48
+ end
49
+ @renderer.font = font
50
+ @font_size = font.size
51
+ resize_terminal
52
+ redraw
53
+ end
54
+
55
+ def font()
56
+ @renderer.font
57
+ end
58
+
59
+ def font_size=(size)
60
+ self.font = [font.name, size]
61
+ end
62
+
63
+ def font_size()
64
+ font.size
65
+ end
66
+
67
+ def on_attach(e)
68
+ unless @terminal
69
+ @terminal = Terminal.new
70
+ @terminal.spawn(@envs, *[@command].compact)
71
+ end
72
+ resize_terminal
73
+ focus
74
+ end
75
+
76
+ def on_detach(e)
77
+ stop_cursor_blink
78
+ end
79
+
80
+ def on_activate(e)
81
+ update_cursor_blink
82
+ end
83
+
84
+ def on_deactivate(e)
85
+ update_cursor_blink
86
+ end
87
+
88
+ def on_focus(e)
89
+ update_cursor_blink
90
+ end
91
+
92
+ def on_update(e)
93
+ t = @terminal || return
94
+ if t.update || @renderer.glyph_count == 0
95
+ @renderer.bake_glyphs t
96
+ redraw
97
+ end
98
+ if t.bells > @prev_bells
99
+ bells, @prev_bells = t.bells - @prev_bells, t.bells
100
+ on_bell BellEvent.new(bells)
101
+ end
102
+ end
103
+
104
+ def on_draw(e)
105
+ t = @terminal || return
106
+
107
+ @renderer.draw e.painter, t, e.bounds
108
+ draw_cursor e.painter, t
109
+ end
110
+
111
+ def on_key_down(e)
112
+ t = @terminal || return
113
+
114
+ # typing changes what a selection covers, so it is dropped. A modifier
115
+ # arrives here on its own, and one held with command is a shortcut for
116
+ # the application rather than something typed
117
+ typed = e.chars && (e.modifiers & %i[command win]).empty?
118
+ t.deselect if typed && t.selection?
119
+ t.scroll_to 0 if t.scroll != 0
120
+ t.write_key e
121
+ start_cursor_blink
122
+ end
123
+
124
+ def on_key_up(e)
125
+ @terminal&.write_key e
126
+ end
127
+
128
+ def on_pointer_down(e)
129
+ focus
130
+ t = @terminal || return
131
+
132
+ # the child process gets the mouse once it asks for it, unless shift
133
+ # says this drag belongs to the user: the way a terminal lets text be
134
+ # selected inside an application that tracks the mouse itself
135
+ @selecting = e.left? && (!t.mouse_tracking? || e.modifiers.include?(:shift))
136
+ return t.write_pointer(e) unless @selecting
137
+
138
+ x, y = to_cell e.x, e.y
139
+ case e.click_count
140
+ when 1 then t.deselect
141
+ when 2 then t.select_word x, y
142
+ else t.select_line y
143
+ end
144
+ end
145
+
146
+ def on_pointer_up(e)
147
+ t = @terminal || return
148
+ t.write_pointer e unless @selecting
149
+ @selecting = false
150
+ end
151
+
152
+ def on_pointer_move(e)
153
+ t = @terminal || return
154
+ return t.write_pointer(e) unless @selecting
155
+ return unless e.drag? && e.click_count == 1
156
+
157
+ down = e.down || return
158
+ from, to = to_cell(down.x, down.y), to_cell(e.x, e.y)
159
+ if from == to
160
+ t.deselect if t.selection?
161
+ else
162
+ t.select(*from, *to)
163
+ end
164
+ end
165
+
166
+ def on_wheel(e)
167
+ t = @terminal || return
168
+
169
+ if t.mouse_tracking?
170
+ t.write_wheel e
171
+ else
172
+ rows = (e.dy / @renderer.cell_height).round
173
+ return if rows == 0
174
+ t.scroll_by rows
175
+ redraw
176
+ end
177
+ end
178
+
179
+ def on_resize(e)
180
+ resize_terminal
181
+ end
182
+
183
+ def on_bell(e)
184
+ # Called with a BellEvent when the terminal received one or more BEL
185
+ # characters (0x07). What a bell means is left to the application: a
186
+ # sound, a flash of the window, a badge, or nothing at all.
187
+ end
188
+
189
+ private
190
+
191
+ def blinking?()
192
+ window&.active? && focus?
193
+ end
194
+
195
+ def update_cursor_blink()
196
+ if blinking?
197
+ start_cursor_blink
198
+ else
199
+ stop_cursor_blink
200
+ redraw
201
+ end
202
+ end
203
+
204
+ def start_cursor_blink()
205
+ @cursor_blink = true
206
+ stop_cursor_blink
207
+ @cursor_blinker = interval CURSOR_BLINK_INTERVAL do
208
+ @cursor_blink = !@cursor_blink
209
+ redraw
210
+ end
211
+ redraw
212
+ end
213
+
214
+ def stop_cursor_blink()
215
+ @cursor_blinker&.stop
216
+ @cursor_blinker = nil
217
+ end
218
+
219
+ def draw_cursor(painter, terminal)
220
+ x, y, style, visible = terminal.cursor
221
+ return unless visible
222
+
223
+ # ghostty never reports BLOCK_HOLLOW itself -- there is no DECSCUSR for
224
+ # it -- so showing one while the terminal is not taking input is the
225
+ # view's own decision, and it does not blink
226
+ hollow = !blinking?
227
+ return unless hollow || @cursor_blink
228
+
229
+ cw, ch = @renderer.cell_width, @renderer.cell_height
230
+ color = terminal.cursor_color || terminal.foreground_color || Color.new(1, 1, 1)
231
+
232
+ painter.push fill: color, stroke: nil do |p|
233
+ case hollow ? Terminal::CURSOR_BLOCK_HOLLOW : style
234
+ when Terminal::CURSOR_BAR then p.rect x * cw, y * ch, 2, ch
235
+ when Terminal::CURSOR_UNDERLINE then p.rect x * cw, (y + 1) * ch - 2, cw, 2
236
+ when Terminal::CURSOR_BLOCK_HOLLOW
237
+ p.fill nil
238
+ p.stroke color
239
+ p.rect x * cw + 0.5, y * ch + 0.5, cw - 1, ch - 1
240
+ else
241
+ p.fill color.dup.tap {|c| c.alpha = 0.5}
242
+ p.rect x * cw, y * ch, cw, ch
243
+ end
244
+ end
245
+ end
246
+
247
+ def to_cell(x, y)
248
+ # Clamped to the screen so that a drag leaving the view keeps selecting
249
+ # up to the edge, rather than asking for a cell the terminal will refuse.
250
+ cw, ch = @renderer.cell_width, @renderer.cell_height
251
+ [(x / cw).floor.clamp(0, @terminal.columns - 1),
252
+ (y / ch).floor.clamp(0, @terminal.rows - 1)]
253
+ end
254
+
255
+ def resize_terminal()
256
+ return unless @terminal && width > 0 && height > 0
257
+ cw, ch = @renderer.cell_width, @renderer.cell_height
258
+ @terminal.resize(
259
+ (width / cw).floor.clamp(1..),
260
+ (height / ch).floor.clamp(1..),
261
+ cell_width: cw.round, cell_height: ch,
262
+ screen_width: width.to_i, screen_height: height.to_i)
263
+ end
264
+
265
+ end# TerminalView
266
+
267
+
268
+ end# Reflex
@@ -0,0 +1,2 @@
1
+ require 'reflex/ext'
2
+ require 'reflex_terminal_ext'
@@ -0,0 +1,41 @@
1
+ module ReflexTerminal
2
+
3
+
4
+ module Extension
5
+
6
+ module_function
7
+
8
+ def name(downcase = false)
9
+ super().split('::')[..-2].join.then {|s|
10
+ downcase ? s.gsub(/([a-z])([A-Z])/) {"#{$1}-#{$2}"}.downcase : s
11
+ }
12
+ end
13
+
14
+ def version()
15
+ File.read(root_dir 'VERSION')[/[\d\.]+/]
16
+ end
17
+
18
+ def root_dir(path = '')
19
+ File.expand_path "../../#{path}", __dir__
20
+ end
21
+
22
+ def inc_dir()
23
+ root_dir 'include'
24
+ end
25
+
26
+ def lib_dir()
27
+ root_dir 'lib'
28
+ end
29
+
30
+ def ext_dir()
31
+ root_dir 'ext'
32
+ end
33
+
34
+ def lib_name()
35
+ "#{name true}.dll" if /mswin|ming|cygwin/.match? RUBY_PLATFORM
36
+ end
37
+
38
+ end# Extension
39
+
40
+
41
+ end# ReflexTerminal
@@ -0,0 +1,7 @@
1
+ require 'reflex'
2
+
3
+ require 'reflex-terminal/extension'
4
+
5
+ require 'reflex/bell_event'
6
+ require 'reflex/terminal'
7
+ require 'reflex/terminal_view'
@@ -0,0 +1,39 @@
1
+ # -*- mode: ruby -*-
2
+
3
+
4
+ require_relative 'lib/reflex-terminal/extension'
5
+
6
+
7
+ Gem::Specification.new do |s|
8
+ glob = -> *patterns do
9
+ patterns.map {|pat| Dir.glob(pat).to_a}.flatten
10
+ end
11
+
12
+ ext = ReflexTerminal::Extension
13
+ name = ext.name true
14
+ rdocs = glob.call *%w[README .doc/ext/**/*.cpp]
15
+
16
+ s.name = name
17
+ s.version = ext.version
18
+ s.license = 'MIT'
19
+ s.summary = 'A terminal emulator view for Reflex.'
20
+ s.description = 'Provides Reflex::Terminal, a headless terminal emulator model built on libghostty-vt, and Reflex::TerminalView, a View that renders it in the Reflex GUI toolkit.'
21
+ s.authors = %w[xordog]
22
+ s.email = 'xordog@gmail.com'
23
+ s.homepage = "https://github.com/xord/reflex-terminal"
24
+
25
+ s.platform = Gem::Platform::RUBY
26
+ s.required_ruby_version = '>= 3.0.0'
27
+
28
+ s.add_dependency 'xot', '~> 0.4.0'
29
+ s.add_dependency 'rucy', '~> 0.4.0'
30
+ s.add_dependency 'rays', '~> 0.4.0'
31
+ s.add_dependency 'reflexion', '~> 0.6.0'
32
+
33
+ s.files = `git ls-files`.split $/
34
+ s.executables = s.files.grep(%r{^bin/}) {|f| File.basename f}
35
+ s.test_files = s.files.grep %r{^(test|spec|features)/}
36
+ s.extra_rdoc_files = rdocs.to_a
37
+
38
+ s.extensions << 'Rakefile'
39
+ end
data/src/pty.cpp ADDED
@@ -0,0 +1,236 @@
1
+ #if defined(OSX) || defined(LINUX)
2
+
3
+
4
+ #include "terminal.h"
5
+
6
+
7
+ #include <errno.h>
8
+ #include <fcntl.h>
9
+ #include <signal.h>
10
+ #include <stdlib.h>
11
+ #include <unistd.h>
12
+ #ifdef OSX
13
+ #include <util.h>
14
+ #else
15
+ #include <pty.h>
16
+ #endif
17
+ #include <sys/ioctl.h>
18
+ #include <sys/wait.h>
19
+ #include <vector>
20
+ #include <reflex/exception.h>
21
+
22
+
23
+ namespace Reflex
24
+ {
25
+
26
+
27
+ struct PTY::Data
28
+ {
29
+
30
+ int fd = -1, pid = -1;
31
+
32
+ };// PTY::Data
33
+
34
+
35
+ static struct winsize
36
+ to_winsize (int columns, int rows, int cell_width, int cell_height)
37
+ {
38
+ struct winsize size = {};
39
+ size.ws_col = (unsigned short) columns;
40
+ size.ws_row = (unsigned short) rows;
41
+ size.ws_xpixel = (unsigned short) (columns * cell_width);
42
+ size.ws_ypixel = (unsigned short) (rows * cell_height);
43
+ return size;
44
+ }
45
+
46
+
47
+ PTY::PTY ()
48
+ {
49
+ }
50
+
51
+ PTY::~PTY ()
52
+ {
53
+ close();
54
+ }
55
+
56
+ void
57
+ PTY::spawn (
58
+ const StringList& args, const Terminal::EnvMap& envs,
59
+ int columns, int rows, int cell_width, int cell_height,
60
+ bool login)
61
+ {
62
+ if (args.empty() || args[0].empty())
63
+ argument_error(__FILE__, __LINE__, "args is empty");
64
+ if (is_open())
65
+ invalid_state_error(__FILE__, __LINE__, "already spawned");
66
+
67
+ String argv0 = args[0];
68
+ if (login)
69
+ {
70
+ // shells read their login profiles when argv[0] starts with
71
+ // a '-', so pass the base name prefixed with it (/bin/zsh
72
+ // becomes -zsh); the path itself still goes to execvp()
73
+ size_t pos = argv0.rfind('/');
74
+ argv0 = "-" + (pos == String::npos ? argv0 : argv0.substr(pos + 1));
75
+ }
76
+
77
+ std::vector<const char*> argv;
78
+ argv.emplace_back(argv0.c_str());
79
+ for (size_t i = 1; i < args.size(); ++i)
80
+ argv.emplace_back(args[i].c_str());
81
+ argv.push_back(NULL);
82
+
83
+ struct winsize size = to_winsize(columns, rows, cell_width, cell_height);
84
+
85
+ // built here rather than in the child: allocating between fork()
86
+ // and exec() is not async-signal-safe
87
+ Terminal::EnvMap child_envs = Terminal_make_child_envs(envs);
88
+
89
+ int master = -1;
90
+ pid_t child = forkpty(&master, NULL, NULL, &size);
91
+ if (child < 0)
92
+ system_error(__FILE__, __LINE__, "forkpty() failed");
93
+
94
+ if (child == 0)
95
+ {
96
+ // child process: exec the command
97
+ for (const auto& it : child_envs)
98
+ {
99
+ if (it.second)
100
+ setenv(it.first.c_str(), it.second->c_str(), 1);
101
+ else
102
+ unsetenv(it.first.c_str());
103
+ }
104
+
105
+ // exec the real path; argv[0] may be the login shell name
106
+ execvp(args[0].c_str(), (char* const*) &argv[0]);
107
+ _exit(127);// exec failed
108
+ }
109
+
110
+ int flags = fcntl(master, F_GETFL, 0);
111
+ if (flags < 0 || fcntl(master, F_SETFL, flags | O_NONBLOCK) < 0)
112
+ {
113
+ ::close(master);
114
+ kill(child, SIGKILL);
115
+ waitpid(child, NULL, 0);
116
+ system_error(__FILE__, __LINE__, "failed to set O_NONBLOCK");
117
+ }
118
+
119
+ self->fd = master;
120
+ self->pid = child;
121
+ }
122
+
123
+ size_t
124
+ PTY::read (char* buffer, size_t size)
125
+ {
126
+ // nonblocking: returns 0 when nothing is available, which a caller
127
+ // cannot tell apart from the child having exited -- is_child_alive()
128
+ // answers that
129
+
130
+ if (!is_open()) return 0;
131
+
132
+ ssize_t n = ::read(self->fd, buffer, size);
133
+ if (n > 0) return (size_t) n;
134
+
135
+ if (n == 0 || (errno != EAGAIN && errno != EINTR))
136
+ close();// EOF or EIO: the child has exited
137
+ return 0;
138
+ }
139
+
140
+ bool
141
+ PTY::wait_readable (int timeout_msec) const
142
+ {
143
+ if (!is_open()) return false;
144
+
145
+ fd_set fds;
146
+ FD_ZERO(&fds);
147
+ FD_SET(self->fd, &fds);
148
+ struct timeval timeout = {0, timeout_msec * 1000};
149
+ return select(self->fd + 1, &fds, NULL, NULL, &timeout) > 0;
150
+ }
151
+
152
+ void
153
+ PTY::write (const char* bytes, size_t size)
154
+ {
155
+ if (!is_open() || !bytes || size == 0) return;
156
+
157
+ size_t offset = 0;
158
+ while (offset < size)
159
+ {
160
+ ssize_t n = ::write(self->fd, bytes + offset, size - offset);
161
+ if (n > 0)
162
+ offset += (size_t) n;
163
+ else if (errno == EINTR)
164
+ continue;
165
+ else if (errno == EAGAIN)
166
+ {
167
+ // the pty buffer is full; wait briefly for the child to drain it
168
+ fd_set fds;
169
+ FD_ZERO(&fds);
170
+ FD_SET(self->fd, &fds);
171
+ struct timeval timeout = {0, 100 * 1000};// 100ms
172
+ if (select(self->fd + 1, NULL, &fds, NULL, &timeout) <= 0)
173
+ break;// give up to avoid blocking the UI thread forever
174
+ }
175
+ else
176
+ {
177
+ close();
178
+ break;
179
+ }
180
+ }
181
+ }
182
+
183
+ void
184
+ PTY::set_size (int columns, int rows, int cell_width, int cell_height)
185
+ {
186
+ if (!is_open()) return;
187
+
188
+ struct winsize size = to_winsize(columns, rows, cell_width, cell_height);
189
+ ioctl(self->fd, TIOCSWINSZ, &size);// sends SIGWINCH to the child
190
+ }
191
+
192
+ void
193
+ PTY::close ()
194
+ {
195
+ if (self->fd >= 0)
196
+ {
197
+ ::close(self->fd);
198
+ self->fd = -1;
199
+ }
200
+ if (self->pid >= 0)
201
+ {
202
+ waitpid(self->pid, NULL, WNOHANG);// reap if already exited
203
+ self->pid = -1;
204
+ }
205
+ }
206
+
207
+ bool
208
+ PTY::is_open () const
209
+ {
210
+ return self->fd >= 0;
211
+ }
212
+
213
+ bool
214
+ PTY::is_child_alive () const
215
+ {
216
+ if (self->pid < 0) return false;
217
+
218
+ return waitpid(self->pid, NULL, WNOHANG) == 0;
219
+ }
220
+
221
+ PTY::operator bool () const
222
+ {
223
+ return is_open();
224
+ }
225
+
226
+ bool
227
+ PTY::operator ! () const
228
+ {
229
+ return !operator bool();
230
+ }
231
+
232
+
233
+ }// Reflex
234
+
235
+
236
+ #endif// OSX || LINUX