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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d19432b15ff535a2ce8f4bfb5b759ddd3599b4fb0af949d7871cf1059497c483
4
- data.tar.gz: ce58f726a5a9dc186deb042c0d0e31fa8694676d0cdb4c496cfb57668d5fd4c1
3
+ metadata.gz: a22c29848cc6a0454f9be515cbc0f654537e37f9fbde37902f55dd34c103ce21
4
+ data.tar.gz: b96e180cc3750f1f0d3f72fc822669acaf68cc33c65b312029664b36981794cb
5
5
  SHA512:
6
- metadata.gz: 5b96c66aa1b96e853e7d268c012f8bc5e62fdf77c1e3d4e89360b764d4cfc1d20bdb271be81acad05f26417d09e17fe27f437680cb00a33fcf6f2c934600fef2
7
- data.tar.gz: '045758a0d3ec1e4f2d6bfe4bb887743cc2ab80310bd842190b362e9a9976fd023b8818c35d13f80478839bbbb12d91372b22ddabc91ee430afda8c3ce3b0853a'
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.2...HEAD
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
@@ -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
- render
547
- @needs_render = false
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
- data = @process.read_nonblock
41
- return nil unless data
42
- @terminal.feed(data)
43
- data
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
- out = String.new("\e[0m")
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 `h`/`l` as no-ops anyway, so dropping
531
- # everything is safe and avoids `\e[?Nr` colliding with DECSTBM.
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
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.3"
2
+ VERSION = "0.1.4"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: muxr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roel Bondoc