muxr 0.1.3 → 0.1.4
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 +4 -4
- data/CHANGELOG.md +20 -1
- data/lib/muxr/application.rb +48 -3
- data/lib/muxr/pane.rb +15 -4
- data/lib/muxr/renderer.rb +8 -1
- data/lib/muxr/terminal.rb +43 -2
- data/lib/muxr/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a22c29848cc6a0454f9be515cbc0f654537e37f9fbde37902f55dd34c103ce21
|
|
4
|
+
data.tar.gz: b96e180cc3750f1f0d3f72fc822669acaf68cc33c65b312029664b36981794cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9139072cdc6e55e0ab94c7144d492858cdb3fa75bf59f5009b925d7f4e6659d8228f64987aba7c08fdff119dbcbb480511969cc64c0904e96a3ae95cb8456ddc
|
|
7
|
+
data.tar.gz: 77028f21adf1d5a7710f952af3dfc0621223b4aa0cbb70ff15436c54e797d8d3809885b50d31a71c5f417afaae2bb153bbf149379110813beeb23ba716cefe25
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.4] - 2026-05-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Render flicker on large screens and during fzf-style redraws. Each
|
|
13
|
+
Renderer frame is now wrapped in DEC 2026 synchronized output (with
|
|
14
|
+
the cursor hidden for the duration of the diff), so terminals that
|
|
15
|
+
support it (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13,
|
|
16
|
+
foot) present the frame atomically instead of repainting cell by
|
|
17
|
+
cell. `Pane#read_from_pty` now drains the PTY to `EAGAIN` per tick,
|
|
18
|
+
collapsing multi-chunk bursts (vim cursor+status redraw, fzf
|
|
19
|
+
candidate list) into a single render, and the event loop caps
|
|
20
|
+
repaints at ~60 Hz while trimming `IO.select`'s timeout so deferred
|
|
21
|
+
frames still land on time. The Terminal emulator also honors
|
|
22
|
+
`\e[?2026h` / `\e[?2026l` from inner programs (fzf ≥0.41, neovim,
|
|
23
|
+
helix) as a render-timing hint — the outer paint is held until the
|
|
24
|
+
close sequence arrives or a 200 ms safety timeout expires.
|
|
25
|
+
|
|
9
26
|
## [0.1.3] - 2026-05-11
|
|
10
27
|
|
|
11
28
|
### Fixed
|
|
@@ -91,7 +108,9 @@ Initial release.
|
|
|
91
108
|
boundaries.
|
|
92
109
|
- Renderer that composes one frame and diff-emits ANSI to STDOUT.
|
|
93
110
|
|
|
94
|
-
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.
|
|
111
|
+
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.4...HEAD
|
|
112
|
+
[0.1.4]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.4
|
|
113
|
+
[0.1.3]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.3
|
|
95
114
|
[0.1.2]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.2
|
|
96
115
|
[0.1.1]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.1
|
|
97
116
|
[0.1.0]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.0
|
data/lib/muxr/application.rb
CHANGED
|
@@ -16,6 +16,11 @@ module Muxr
|
|
|
16
16
|
# attach via Renderer#reset_frame!.
|
|
17
17
|
class Application
|
|
18
18
|
SELECT_TIMEOUT = 0.05
|
|
19
|
+
# ~60 Hz cap on full repaints. Keystrokes in fzf or vim navigation can
|
|
20
|
+
# trigger PTY bursts faster than the terminal can usefully display them;
|
|
21
|
+
# the cap collapses those bursts and stops intermediate frames from
|
|
22
|
+
# showing through.
|
|
23
|
+
MIN_FRAME_INTERVAL = 1.0 / 60
|
|
19
24
|
SOCKETS_DIR = File.join(Dir.home, ".muxr", "sockets").freeze
|
|
20
25
|
DEFAULT_WIDTH = 80
|
|
21
26
|
DEFAULT_HEIGHT = 24
|
|
@@ -61,6 +66,7 @@ module Muxr
|
|
|
61
66
|
@listening_socket = nil
|
|
62
67
|
@socket_path = self.class.socket_path_for(@session_name)
|
|
63
68
|
@paste_buffer = +""
|
|
69
|
+
@last_render_at = nil
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
attr_reader :paste_buffer
|
|
@@ -513,6 +519,20 @@ module Muxr
|
|
|
513
519
|
write_ios << @current_client if @current_client && !@client_write_buffer.empty?
|
|
514
520
|
|
|
515
521
|
timeout = @message ? 0.25 : SELECT_TIMEOUT
|
|
522
|
+
# If a render is queued but we're inside the frame-rate budget, wake
|
|
523
|
+
# up as soon as the budget expires so the deferred paint lands on time.
|
|
524
|
+
if @current_client && @needs_render && @last_render_at
|
|
525
|
+
budget = MIN_FRAME_INTERVAL - (monotonic_now - @last_render_at)
|
|
526
|
+
timeout = budget.clamp(0, timeout) if budget < timeout
|
|
527
|
+
end
|
|
528
|
+
# If a pane is mid-synchronized-output (DEC 2026), wake up no later
|
|
529
|
+
# than its safety deadline so a crashed inner program can't wedge
|
|
530
|
+
# rendering past Terminal::SYNC_TIMEOUT.
|
|
531
|
+
deadline = nearest_sync_deadline
|
|
532
|
+
if deadline
|
|
533
|
+
remaining = deadline - monotonic_now
|
|
534
|
+
timeout = remaining.clamp(0, timeout) if remaining < timeout
|
|
535
|
+
end
|
|
516
536
|
ready_r, ready_w, = IO.select(read_ios, write_ios, nil, timeout)
|
|
517
537
|
|
|
518
538
|
ready_r&.each do |io|
|
|
@@ -542,13 +562,38 @@ module Muxr
|
|
|
542
562
|
break
|
|
543
563
|
end
|
|
544
564
|
|
|
545
|
-
if @current_client && @needs_render
|
|
546
|
-
|
|
547
|
-
@
|
|
565
|
+
if @current_client && @needs_render && !any_pane_syncing?
|
|
566
|
+
now = monotonic_now
|
|
567
|
+
if @last_render_at.nil? || (now - @last_render_at) >= MIN_FRAME_INTERVAL
|
|
568
|
+
render
|
|
569
|
+
@last_render_at = now
|
|
570
|
+
@needs_render = false
|
|
571
|
+
end
|
|
548
572
|
end
|
|
549
573
|
end
|
|
550
574
|
end
|
|
551
575
|
|
|
576
|
+
def monotonic_now
|
|
577
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# True iff any pane (or the drawer) has opened a DEC 2026 synchronized
|
|
581
|
+
# output block that hasn't yet closed or timed out. Used to defer the
|
|
582
|
+
# outer paint so it lands on a fully-formed inner frame.
|
|
583
|
+
def any_pane_syncing?
|
|
584
|
+
return true if @session.window.panes.any? { |p| p.terminal.sync_pending? }
|
|
585
|
+
drawer = @session.drawer&.pane
|
|
586
|
+
return true if drawer && drawer.terminal.sync_pending?
|
|
587
|
+
false
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def nearest_sync_deadline
|
|
591
|
+
deadlines = @session.window.panes.filter_map { |p| p.terminal.sync_deadline }
|
|
592
|
+
d = @session.drawer&.pane&.terminal&.sync_deadline
|
|
593
|
+
deadlines << d if d
|
|
594
|
+
deadlines.min
|
|
595
|
+
end
|
|
596
|
+
|
|
552
597
|
def accept_client
|
|
553
598
|
sock = @listening_socket.accept
|
|
554
599
|
if @current_client
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -36,11 +36,22 @@ module Muxr
|
|
|
36
36
|
@process.write(data)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Drain everything currently in the PTY's kernel read buffer, feeding
|
|
40
|
+
# each chunk to the Terminal. Coalescing reads here means we render once
|
|
41
|
+
# per fully-formed output burst (fzf re-render, vim cursor+status redraw,
|
|
42
|
+
# etc.) instead of once per ~8 KiB chunk — the latter shows intermediate
|
|
43
|
+
# frames and is the main source of in-pane flicker. Bounded by a byte cap
|
|
44
|
+
# so a runaway producer can't starve other panes on a single tick.
|
|
45
|
+
READ_BUDGET = 1 << 20 # 1 MiB
|
|
39
46
|
def read_from_pty
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
total = 0
|
|
48
|
+
while total < READ_BUDGET
|
|
49
|
+
chunk = @process.read_nonblock
|
|
50
|
+
break unless chunk
|
|
51
|
+
@terminal.feed(chunk)
|
|
52
|
+
total += chunk.bytesize
|
|
53
|
+
end
|
|
54
|
+
total.positive? ? total : nil
|
|
44
55
|
end
|
|
45
56
|
|
|
46
57
|
def resize(rows, cols)
|
data/lib/muxr/renderer.rb
CHANGED
|
@@ -371,7 +371,13 @@ module Muxr
|
|
|
371
371
|
end
|
|
372
372
|
|
|
373
373
|
def emit_frame(frame, session, input_state:, command_buffer:)
|
|
374
|
-
|
|
374
|
+
# \e[?2026h enters synchronized-output mode so terminals that support it
|
|
375
|
+
# (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13, foot) present
|
|
376
|
+
# the whole frame atomically instead of repainting incrementally as bytes
|
|
377
|
+
# arrive. \e[?25l hides the cursor for the duration of the diff so it
|
|
378
|
+
# doesn't smear across every \e[y;xH position; cursor_position turns it
|
|
379
|
+
# back on at the final spot.
|
|
380
|
+
out = String.new("\e[?2026h\e[?25l\e[0m")
|
|
375
381
|
same_size = @prev && @prev_w == frame[0].length && @prev_h == frame.length
|
|
376
382
|
cur_fg = :unset
|
|
377
383
|
cur_bg = :unset
|
|
@@ -400,6 +406,7 @@ module Muxr
|
|
|
400
406
|
end
|
|
401
407
|
out << "\e[0m"
|
|
402
408
|
out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
|
|
409
|
+
out << "\e[?2026l"
|
|
403
410
|
@out.write(out)
|
|
404
411
|
@out.flush
|
|
405
412
|
@prev = frame.map { |row| row.map(&:dup) }
|
data/lib/muxr/terminal.rb
CHANGED
|
@@ -12,6 +12,14 @@ module Muxr
|
|
|
12
12
|
|
|
13
13
|
SCROLLBACK_MAX = 5000
|
|
14
14
|
|
|
15
|
+
# Inner programs (fzf ≥ 0.41, neovim, helix, …) bracket coherent screen
|
|
16
|
+
# updates with `\e[?2026h … \e[?2026l` (DECSET 2026 — "Synchronized
|
|
17
|
+
# Output"). When we see the open, we know more bytes are coming that
|
|
18
|
+
# belong to the same logical frame; rendering before the close shows a
|
|
19
|
+
# half-painted state. SYNC_TIMEOUT is the safety cap so a crashed inner
|
|
20
|
+
# program (which left ?2026h open) cannot wedge the pane indefinitely.
|
|
21
|
+
SYNC_TIMEOUT = 0.2
|
|
22
|
+
|
|
15
23
|
Cell = Struct.new(:char, :fg, :bg, :attrs) do
|
|
16
24
|
def reset!
|
|
17
25
|
self.char = " "
|
|
@@ -52,6 +60,27 @@ module Muxr
|
|
|
52
60
|
@selection_anchor = nil
|
|
53
61
|
@selection_cursor = nil
|
|
54
62
|
@selection_mode = :linear
|
|
63
|
+
@sync_pending = false
|
|
64
|
+
@sync_started_at = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True iff the inner program has opened a synchronized-output block
|
|
68
|
+
# (\e[?2026h) and not yet closed it, and the safety timeout has not
|
|
69
|
+
# elapsed. The Application uses this to defer rendering so the diff lands
|
|
70
|
+
# on a fully-formed frame instead of a half-painted one.
|
|
71
|
+
def sync_pending?
|
|
72
|
+
return false unless @sync_pending
|
|
73
|
+
if @sync_started_at && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @sync_started_at) > SYNC_TIMEOUT
|
|
74
|
+
@sync_pending = false
|
|
75
|
+
@sync_started_at = nil
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sync_deadline
|
|
82
|
+
return nil unless @sync_pending && @sync_started_at
|
|
83
|
+
@sync_started_at + SYNC_TIMEOUT
|
|
55
84
|
end
|
|
56
85
|
|
|
57
86
|
attr_reader :selection_mode
|
|
@@ -527,8 +556,20 @@ module Muxr
|
|
|
527
556
|
when ">", "<", "=", "!"
|
|
528
557
|
return
|
|
529
558
|
when "?"
|
|
530
|
-
# DEC private modes — we treat
|
|
531
|
-
#
|
|
559
|
+
# DEC private modes — most we treat as no-ops, but mode 2026
|
|
560
|
+
# (Synchronized Output) is a render-timing hint we honor so the
|
|
561
|
+
# outer paint lands on fully-formed frames from fzf/nvim/helix.
|
|
562
|
+
if final == "h" || final == "l"
|
|
563
|
+
if csi_params.include?(2026)
|
|
564
|
+
if final == "h"
|
|
565
|
+
@sync_pending = true
|
|
566
|
+
@sync_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
567
|
+
else
|
|
568
|
+
@sync_pending = false
|
|
569
|
+
@sync_started_at = nil
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
|
532
573
|
return
|
|
533
574
|
end
|
|
534
575
|
|
data/lib/muxr/version.rb
CHANGED