Skip to content

Status Bar Rendering Issues During Terminal Resize (WINCH Signal) #1479

@Abdelilah-AIT-HAMOU

Description

@Abdelilah-AIT-HAMOU

Summary

When using JLINE3's Status class for a persistent status bar at the terminal bottom, vertical terminal resizing causes multiple rendering issues including:

  • Status bar flickering/flinching
  • Text content scrolling upward unexpectedly
  • Status bar appearing duplicated
  • Scroll region corruption
  • Cursor positioning errors

These issues occur because LineReaderImpl.handleSignal() and Status.update() are not properly coordinated during WINCH (window change) signal handling.

Environment

  • JLINE Version: 3.30.4 (also affects earlier 3.x versions)
  • Operating System: macOS (reproducible on Linux)
  • Terminal Emulators: Terminal.app, iTerm2, and others supporting SIGWINCH
  • Java Version: Java 8+

Steps to Reproduce

  1. Create a JLINE3 LineReader with a Status bar enabled
  2. Display status bar content using Status.update(lines)
  3. Vertically resize the terminal window (drag to increase/decrease rows)
  4. Observe the status bar behavior during and after resize

Expected Behavior

When the terminal is resized:

  1. Status bar should remain at the bottom of the new terminal size
  2. No flickering or duplicate rendering
  3. Main content area (readline buffer) should remain visible and properly positioned
  4. Scroll region should be cleanly updated to account for new terminal dimensions
  5. Cursor should remain at the correct position in the readline buffer

Actual Behavior

When terminal is resized:

  1. ✗ Status bar flickers and may briefly disappear
  2. ✗ Status bar sometimes appears duplicated (shown at both old and new positions)
  3. ✗ Text from the readline buffer scrolls upward unexpectedly
  4. ✗ Scroll region becomes corrupted, causing content to render in wrong areas
  5. ✗ Cursor may jump to incorrect positions

Root Cause Analysis

The issue stems from lack of coordination between two components during WINCH signal handling:

1. LineReaderImpl.handleSignal() - Resize Handler

File: reader/src/main/java/org/jline/reader/impl/LineReaderImpl.java
Lines: 1259-1283

code used to roproduce the issue :

import java.io.IOException;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.*;
import org.jline.reader.*;
import org.jline.reader.impl.LineReaderImpl;
import org.jline.terminal.*;
import org.jline.utils.*;

/**
 * Simple JLine Terminal Demo with Status Bar
 *
 * This demonstrates:
 * - Basic terminal setup
 * - LineReader with custom prompt
 * - Status bar with multiple components (time, memory, line count)
 * - WINCH signal handling
 * - Cursor and display management
 *
 * To run: java -cp jline-3.x.x.jar SimpleTerminalDemo.java
 */
public class SimpleTerminalDemo {

    public static void main(String[] args) throws IOException {
        System.err.println("===== STDERR TEST - If you see this, stderr is working! =====");
        System.out.println("System.out.println working ? ");

        // Create terminal
        Terminal terminal = TerminalBuilder.builder()
                .system(true)
                .build();

        System.err.println("===== AFTER TERMINAL CREATED - Can you see this? =====");

        // Create line reader
        LineReader reader = LineReaderBuilder.builder()
                .terminal(terminal)
                .build();

        // Create and setup status bar
        SimpleStatusBar statusBar = new SimpleStatusBar(terminal, (LineReaderImpl) reader);
        statusBar.enable();

        // Print welcome message
        terminal.writer().println("=== JLine Terminal Demo ===");
        terminal.writer().println("Type 'help' for commands, 'exit' to quit");
        terminal.writer().println();
        terminal.writer().flush();

        // Main input loop
        int lineCount = 0;
        while (true) {
            try {
                // Read line with prompt
                String line = reader.readLine("demo> ");
                lineCount++;

                // Update status bar
                statusBar.setLineCount(lineCount);
                statusBar.refresh();

                // Process commands
                if (line == null || line.trim().equalsIgnoreCase("exit") || line.trim().equalsIgnoreCase("quit")) {
                    break;
                }

                if (line.trim().equalsIgnoreCase("help")) {
                    terminal.writer().println("Commands:");
                    terminal.writer().println("  help     - Show this help");
                    terminal.writer().println("  clear    - Clear screen");
                    terminal.writer().println("  status   - Toggle status bar");
                    terminal.writer().println("  echo     - Echo your input");
                    terminal.writer().println("  exit     - Exit demo");
                } else if (line.trim().equalsIgnoreCase("clear")) {
                    terminal.puts(InfoCmp.Capability.clear_screen);
                    terminal.flush();
                } else if (line.trim().equalsIgnoreCase("status")) {
                    statusBar.toggle();
                } else if (line.trim().startsWith("echo ")) {
                    terminal.writer().println("You said: " + line.substring(5));
                } else if (!line.trim().isEmpty()) {
                    terminal.writer().println("Unknown command: " + line);
                    terminal.writer().println("Type 'help' for available commands");
                }

                terminal.writer().flush();

            } catch (UserInterruptException e) {
                // Ctrl+C pressed
                terminal.writer().println("^C");
                terminal.writer().flush();
            } catch (EndOfFileException e) {
                // Ctrl+D pressed
                break;
            }
        }

        // Cleanup
        statusBar.shutdown();
        statusBar.disable();
        terminal.writer().println("\nGoodbye!");
        terminal.writer().flush();
        terminal.close();
    }

    /**
     * Simple Status Bar with Time, Memory, and Line Count components
     */
    static class SimpleStatusBar {
        private final Terminal terminal;
        private final LineReaderImpl reader;
        private final Status status;
        private boolean enabled = false;
        private int lineCount = 0;

        // Debounce mechanism for resize
        private final ScheduledExecutorService resizeExecutor;
        private volatile ScheduledFuture<?> pendingRestore = null;
        private static final int RESIZE_SETTLE_MS = 150; // Wait 150ms after last WINCH

        // Components
        private final TimeComponent timeComponent = new TimeComponent();
        private final MemoryComponent memoryComponent = new MemoryComponent();
        private final LineCountComponent lineCountComponent = new LineCountComponent();

        public SimpleStatusBar(Terminal terminal, LineReaderImpl reader) {
            this.terminal = terminal;
            this.reader = reader;
            this.status = Status.getStatus(terminal, true);

            // Create executor for debouncing
            this.resizeExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
                Thread t = new Thread(r, "StatusBar-Resize");
                t.setDaemon(true);
                return t;
            });

            // Install WINCH handler with debouncing
            installWinchHandler();
        }

        public void enable() {
            if (!enabled) {
                enabled = true;
                terminal.writer().write('\n');
                terminal.writer().flush();
                refresh();
            }
        }

        public void disable() {
            if (enabled) {
                enabled = false;
                status.update(Collections.emptyList());
            }
        }

        public void toggle() {
            if (enabled) {
                disable();
                terminal.writer().println("Status bar disabled");
            } else {
                enable();
                terminal.writer().println("Status bar enabled");
            }
            terminal.writer().flush();
        }

        public void setLineCount(int count) {
            this.lineCount = count;
        }

        public void refresh() {
            if (!enabled) {
                return;
            }

            // Build status line
            AttributedStringBuilder builder = new AttributedStringBuilder();
            builder.append(timeComponent.render());
            builder.append(" ¦ ");
            builder.append(memoryComponent.render());
            builder.append(" ¦ ");
            lineCountComponent.setLineCount(lineCount);
            builder.append(lineCountComponent.render());

            // Update status
            status.update(Collections.singletonList(builder.toAttributedString()));
        }

        private void installWinchHandler() {
            // Get JLine's WINCH handler
            Terminal.SignalHandler jlineWinch = terminal.handle(Terminal.Signal.WINCH, s -> {});

            // Install our debounced handler
            terminal.handle(Terminal.Signal.WINCH, signal -> {
                // Step 1: Let JLine do its resize first
                if (jlineWinch != null) {
                    jlineWinch.handle(signal);
                }

                if (!enabled) {
                    return; // Nothing to do if disabled
                }

                // Step 2: Hide status bar immediately (prevents scroll region issues)
                status.update(Collections.emptyList());

                // Step 3: Cancel any pending restore
                if (pendingRestore != null && !pendingRestore.isDone()) {
                    pendingRestore.cancel(false);
                }

                // Step 4: Schedule restore after resize settles (150ms of quiet)
                pendingRestore = resizeExecutor.schedule(() -> {
                    try {
                        // Resize status to new terminal size
                        status.resize();

                        // Wait a bit more for terminal to settle
                        Thread.sleep(50);

                        // Restore status bar
                        refresh();
                    } catch (Exception e) {
                        // Ignore errors during restore
                    }
                }, RESIZE_SETTLE_MS, TimeUnit.MILLISECONDS);
            });
        }

        public void shutdown() {
            // Cancel pending tasks
            if (pendingRestore != null && !pendingRestore.isDone()) {
                pendingRestore.cancel(false);
            }

            // Shutdown executor
            resizeExecutor.shutdown();
            try {
                if (!resizeExecutor.awaitTermination(1, TimeUnit.SECONDS)) {
                    resizeExecutor.shutdownNow();
                }
            } catch (InterruptedException e) {
                resizeExecutor.shutdownNow();
                Thread.currentThread().interrupt();
            }
        }

        // ========== Status Bar Components ==========

        /**
         * Shows current time
         */
        static class TimeComponent {
            private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

            public AttributedString render() {
                String time = LocalTime.now().format(formatter);
                return new AttributedStringBuilder()
                        .style(AttributedStyle.DEFAULT.foreground(AttributedStyle.CYAN))
                        .append("⏰ ")
                        .style(AttributedStyle.DEFAULT)
                        .append(time)
                        .toAttributedString();
            }
        }

        /**
         * Shows memory usage
         */
        static class MemoryComponent {
            public AttributedString render() {
                Runtime runtime = Runtime.getRuntime();
                long usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024);
                long totalMemory = runtime.maxMemory() / (1024 * 1024);
                String memoryText = String.format("%dMB / %dMB", usedMemory, totalMemory);

                return new AttributedStringBuilder()
                        .style(AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN))
                        .append("💾 ")
                        .style(AttributedStyle.DEFAULT)
                        .append(memoryText)
                        .toAttributedString();
            }
        }

        /**
         * Shows line count
         */
        static class LineCountComponent {
            private int lineCount = 0;

            public void setLineCount(int count) {
                this.lineCount = count;
            }

            public AttributedString render() {
                String text = String.format("Lines: %d", lineCount);
                return new AttributedStringBuilder()
                        .style(AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW))
                        .append("📝 ")
                        .style(AttributedStyle.DEFAULT)
                        .append(text)
                        .toAttributedString();
            }
        }
    }
}
```

run-bash.sh:
#!/bin/bash


# Static JLine jar path
JLINE_JAR="put your path to jline.jar here"

if [ ! -f "$JLINE_JAR" ]; then
    echo "Error: JLine jar not found at: $JLINE_JAR"
    exit 1
fi

echo "Using JLine jar: $JLINE_JAR"

# Compile
javac -cp "$JLINE_JAR" SimpleTerminalDemo.java

if [ $? -ne 0 ]; then
    echo "Compilation failed!"
    exit 1
fi

echo "Running demo..."
echo ""

# Run
java -cp ".:$JLINE_JAR" SimpleTerminalDemo

# Cleanup
rm -f SimpleTerminalDemo.class SimpleTerminalDemo\$SimpleStatusBar*.class

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions