Skip to content

TerminalUI.display() resets JLine's Display every redraw, defeating frame diffing (full-screen rewrite per frame) #1361

Closed

Description

@Benjamin-Tomkins

Summary

In the JLine TUI layer, TerminalUI.display() calls display.clear() + display.reset() on every redraw in the fullScreen path, immediately before display.update(newLines, …). org.jline.utils.Display.reset() discards the cached previous frame, so update() has no diff base and rewrites the entire screen on every frame instead of only the changed cells. JLine's cell-level diffing is effectively disabled for every redraw.

This is invisible for static frames (the redundant bytes render identical pixels), but very visible during a fast window resize, where each redraw re-lays out content: every frame becomes a full, non-atomic screen rewrite, producing tearing/flicker on terminals without synchronized output (e.g. macOS Terminal.app).

Environment

  • spring-shell-jline 4.0.3-SNAPSHOT
  • JLine 3.30.x (FFM PTY provider, Java 25)
  • macOS Terminal.app (no DECSET 2026 synchronized output)

Evidence

org/springframework/shell/jline/tui/component/view/TerminalUI.java, display() (the body invoked for every redraw()):

private synchronized void display() {
    ...
    if (fullScreen) {
        display.clear();
        display.reset();   // wipes JLine's previous-frame cache (the diff base)
        display.resize(size.getRows(), size.getColumns());
        ...
        render(rect);
    }
    ...
    display.update(newLines, targetCursorPos);  // would cell-diff, but reset() just nulled its base
}

Because reset() runs before update() on every frame, update() always performs a full redraw.

Impact

  • Full-screen rewrite per frame regardless of how little changed.
  • Fast resize: tearing / blank-growth / flicker on terminals lacking synchronized output.
  • Higher TTY write volume than necessary at any repaint cadence.

Proposed fix

  1. Only clear()/reset()/resize() when the terminal size actually changed (track the last applied size); for same-size redraws, go straight to render(rect) + display.update(...) and let JLine diff. Keeps resize correctness while restoring incremental updates for the common case.
  2. Optionally emit synchronized output (DECSET 2026 / BSU-ESU) around the write when the terminal advertises it, so even a full rewrite is presented atomically.

(1) is the higher-value change — it makes both the steady-state and resize paths write only what changed.

Notes

Found while diagnosing resize tearing in a downstream Compose-style TUI built on spring-shell-jline. Consumer-side fixes (view-rect sync, resize-event coalescing) reduce the symptom but cannot remove the per-frame full rewrite, which lives in TerminalUI.display().

Activity

  1. czpilar commented on Jun 23, 2026

    @czpilar
    Contributor

    Reproduced, and confirmed it affects Windows too.

    TerminalUI.display() calls display.reset() before every display.update() in full screen mode. reset() empties JLine's cached previous frame (oldLines), so the per-line diff has nothing to compare against and re-emits the whole screen on every redraw.

    Verified with a test that measures bytes written for a second, identical frame: current code rewrites everything, whereas keeping JLine's cache writes ~0 bytes. It's purely about bytes written, so it's platform-independent - the test passes on Windows. The visible tearing/flicker additionally depends on the terminal's synchronized-output support.

    Fix: only clear()/reset()/resize() when the terminal size actually changes; otherwise let JLine diff. PR to follow. (Scoped to full screen, as reported; the non-full-screen path uses relative output where the per-frame reset is harder to remove safely.)

    Fixed in PR #1362

    Here is a reproducer, just use tui command to get to full screen mode:

    package org.springframework.shell.samples.helloworld.boot;
    
    import java.util.concurrent.Executors;
    import java.util.concurrent.ScheduledExecutorService;
    import java.util.concurrent.TimeUnit;
    import java.util.concurrent.atomic.AtomicInteger;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.shell.core.command.annotation.Command;
    import org.springframework.shell.core.command.annotation.Option;
    import org.springframework.shell.jline.tui.component.message.ShellMessageBuilder;
    import org.springframework.shell.jline.tui.component.view.TerminalUI;
    import org.springframework.shell.jline.tui.component.view.TerminalUIBuilder;
    import org.springframework.shell.jline.tui.component.view.control.BoxView;
    import org.springframework.shell.jline.tui.component.view.event.EventLoop;
    import org.springframework.shell.jline.tui.component.view.event.KeyEvent.Key;
    import org.springframework.shell.jline.tui.geom.HorizontalAlign;
    import org.springframework.shell.jline.tui.geom.VerticalAlign;
    
    @SpringBootApplication
    public class SpringShellApplication {
    
    	@Autowired
    	private TerminalUIBuilder terminalUiBuilder;
    
    	public static void main(String[] args) {
    		SpringApplication.run(SpringShellApplication.class, args);
    	}
    
    	@Command(name = "hello", description = "Say hello to a given name", group = "Greetings",
    			help = "A command that greets the user with 'Hello ${name}!'. Usage: hello [-n | --name]=<name>")
    	public void sayHello(@Option(shortName = 'n', longName = "name", description = "the name of the person to greet",
    			defaultValue = "World") String name) {
    		System.out.println("Hello " + name + "!");
    	}
    
    	// gh-1361 visual demo (throwaway, not for the fix PR): a full-screen view that
    	// redraws on a 100ms timer. Before the fix the whole screen is cleared+rewritten
    	// every tick (the border flickers); after the fix only the counter cell changes.
    	@Command(name = "tui", description = "Full-screen redraw demo (press 'q' to quit)")
    	public void tui() {
    		TerminalUI ui = terminalUiBuilder.build();
    		BoxView view = new BoxView();
    		ui.configure(view);
    		view.setShowBorder(true);
    		view.setTitle("gh-1361 redraw demo - resize the window, press 'q' to quit");
    		AtomicInteger frame = new AtomicInteger();
    		view.setDrawFunction((screen, rect) -> {
    			screen.writerBuilder()
    				.build()
    				.text("redraw #" + frame.get(), rect, HorizontalAlign.CENTER, VerticalAlign.CENTER);
    			return rect;
    		});
    		ui.setRoot(view, true);
    		EventLoop eventLoop = ui.getEventLoop();
    		eventLoop.keyEvents().subscribe(event -> {
    			if (event.getPlainKey() == Key.q) {
    				eventLoop.dispatch(ShellMessageBuilder.ofInterrupt());
    			}
    		});
    		ScheduledExecutorService exec = Executors.newSingleThreadScheduledExecutor();
    		exec.scheduleAtFixedRate(() -> {
    			frame.incrementAndGet();
    			ui.redraw();
    		}, 100, 100, TimeUnit.MILLISECONDS);
    		try {
    			ui.run();
    		}
    		finally {
    			exec.shutdownNow();
    		}
    	}
    
    }
  2. added and removed
    status/need-triageTeam needs to triage and take a first look
    on Sep 21, 2026
  3. added this to the 4.0.4 milestone on Sep 21, 2026
  4. added a commit that references this issue on Sep 21, 2026
    4cda1a8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions