feat(workspace): add terminal panel with PTY and VT100 emulation
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Wed, 9 Sep 2026 15:57:54 +0000 (18:57 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Wed, 9 Sep 2026 15:57:54 +0000 (18:57 +0300)
- New bridge.pty package: UnixPty (JNA-based), PtySession,
  Vt100Emulator, and ScreenBuffer supporting fullscreen curses apps
  (alternate screen, application cursor keys)
- New workspace package: Workspace and TerminalPanel render the
  terminal as a panel in the 3D workspace
- Main gains a --selftest mode that drives the PTY end-to-end
  (shell prompt, ls, arithmetic echo, mc/htop open+quit, ESC
  handling) for xvfb-run CI verification
- Build: Java 21, maven-compiler-plugin 3.13.0, sixth-3d
  1.5-SNAPSHOT, add JNA 5.14.0, drop javainspect, repo URL
  www2 -> www3
- Docs: reposition Sixth as a spatial computing environment, define
  roles/boundaries vs sixth-3d, outline VM/VNC and Xvfb roadmap

doc/index.org
pom.xml
src/main/java/eu/svjatoslav/sixth/bridge/pty/PtySession.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/bridge/pty/ScreenBuffer.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/bridge/pty/UnixPty.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/bridge/pty/Vt100Emulator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/core/Main.java
src/main/java/eu/svjatoslav/sixth/workspace/TerminalPanel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/sixth/workspace/Workspace.java [new file with mode: 0644]

index 3f8c060..1c97181 100644 (file)
@@ -59,8 +59,8 @@ that can be adapted and extended to fit virtually any problem domain.
    :ID:       52dbbf4c-2ef4-42a6-8331-ad006b6a52ae
    :END:
 
-+ [[https://www3.svjatoslav.eu/projects/sixth/][Sixth]] — Parent project (The one you are viewing right now. Only the
-  shell and the vision the moment)
++ [[https://www3.svjatoslav.eu/projects/sixth/][Sixth]] — Parent project and the product: a spatial computing
+  environment (3D virtual workspace).
   + [[https://www3.svjatoslav.eu/projects/sixth-data/][Sixth Data]] — Data storage and computation engine. (Very early
     stage, nothing to see yet)
   + [[https://www3.svjatoslav.eu/projects/sixth-3d/][Sixth 3D]] — Real-time 3D engine for user interface and data
@@ -71,6 +71,39 @@ that can be adapted and extended to fit virtually any problem domain.
 The system is far from complete — the scope is large and available
 time is limited.
 
+** Roles and boundaries
+
++ *Sixth 3D* is a reusable library. Anyone can take it to build games
+  or visualizations. It keeps only generic mechanisms: the renderer,
+  scene graph, camera, input primitives, and the widget layer
+  (~GuiComponent~, ~TextCanvas~, ~TextEditComponent~) that games can
+  also use (in-game consoles, interactive screens).
++ *Sixth* is the concrete product built on that library: the virtual
+  workspace you actually work in. All application-integration bridges
+  (PTY/terminal, future VNC client, window capture) live here — the
+  engine must never gain native dependencies or application-domain
+  concepts.
++ *Sixth 3D Demos* remains the capability gallery and regression
+  harness. Workspace features are prototyped as demos, but the real
+  thing lives in Sixth.
+
+** Virtual workspace
+
+Sixth is a spatial computing environment: a persistent 3D world where
+every program you use — text editor, terminal, browser, virtual
+machine, data view — is a virtual object you place, walk to, and work
+at. It replaces window-switching with spatial memory: related tools
+live near each other, and your body remembers where things are.
+
+Roadmap for external applications, from cheapest to most general:
+
++ *Virtual machines* — talk VNC/SPICE to the guest display instead of
+  scraping a window; the protocol provides both framebuffer updates
+  and input injection.
++ *Arbitrary Linux applications* (browser, etc.) — run the app on a
+  dedicated ~Xvfb~ display, capture pixels via ~XShmGetImage~, inject
+  input via XTEST; the app lives only inside the 3D world.
+
 * Ideas
 :PROPERTIES:
 :CUSTOM_ID: ideas
diff --git a/pom.xml b/pom.xml
index 63f0401..1f98019 100644 (file)
--- a/pom.xml
+++ b/pom.xml
@@ -5,11 +5,13 @@
     <artifactId>sixth</artifactId>
     <version>1.0-SNAPSHOT</version>
     <name>Sixth</name>
-    <description>Distributed data storage, analyze, computation and visualization platform</description>
+    <description>Spatial computing environment</description>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <maven.compiler.source>21</maven.compiler.source>
+        <maven.compiler.target>21</maven.compiler.target>
     </properties>
 
 
         <dependency>
             <groupId>eu.svjatoslav</groupId>
             <artifactId>sixth-3d</artifactId>
-            <version>1.2-SNAPSHOT</version>
+            <version>1.5-SNAPSHOT</version>
         </dependency>
 
         <dependency>
-            <groupId>eu.svjatoslav</groupId>
-            <artifactId>svjatoslavcommons</artifactId>
-            <version>1.8-SNAPSHOT</version>
+            <groupId>net.java.dev.jna</groupId>
+            <artifactId>jna</artifactId>
+            <version>5.14.0</version>
         </dependency>
 
         <dependency>
             <groupId>eu.svjatoslav</groupId>
-            <artifactId>javainspect</artifactId>
-            <version>1.5</version>
-            <scope>test</scope>
+            <artifactId>svjatoslavcommons</artifactId>
+            <version>1.8</version>
         </dependency>
     </dependencies>
 
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
-                <version>2.3.2</version>
+                <version>3.13.0</version>
                 <configuration>
-                    <source>1.8</source>
-                    <target>1.8</target>
-                    <optimize>true</optimize>
+                    <source>21</source>
+                    <target>21</target>
                     <encoding>UTF-8</encoding>
                 </configuration>
             </plugin>
         <repository>
             <id>svjatoslav.eu</id>
             <name>Svjatoslav repository</name>
-            <url>https://www2.svjatoslav.eu/maven/</url>
+            <url>https://www3.svjatoslav.eu/maven/</url>
         </repository>
     </repositories>
 
diff --git a/src/main/java/eu/svjatoslav/sixth/bridge/pty/PtySession.java b/src/main/java/eu/svjatoslav/sixth/bridge/pty/PtySession.java
new file mode 100644 (file)
index 0000000..30ec7a2
--- /dev/null
@@ -0,0 +1,158 @@
+/*
+ * Sixth spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.bridge.pty;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A shell process running on a real pseudo-terminal, feeding a
+ * {@link ScreenBuffer} through a {@link Vt100Emulator}.
+ *
+ * <p>Engine-independent: this class knows nothing about the 3D world.
+ * Rendering and keyboard delivery are the caller's job (see
+ * {@code workspace.TerminalPanel}).</p>
+ *
+ * <p>Output path: a daemon reader thread pumps decoded characters into the
+ * emulator and invokes the registered {@link ContentListener} after every
+ * batch, so the renderer can repaint. Input path: {@link #send(String)}
+ * writes bytes to the PTY master.</p>
+ */
+public class PtySession {
+
+    /**
+     * Called on the reader thread after terminal content changed.
+     */
+    public interface ContentListener {
+        void contentChanged();
+    }
+
+    private final UnixPty pty;
+    private final ScreenBuffer screenBuffer;
+    private final Vt100Emulator emulator;
+    private final Thread readerThread;
+
+    private volatile ContentListener contentListener;
+    private volatile boolean running = true;
+
+    /**
+     * Spawns an interactive bash on a new PTY.
+     *
+     * @param columns terminal width in characters
+     * @param rows    terminal height in characters
+     */
+    public PtySession(final int columns, final int rows) {
+        screenBuffer = new ScreenBuffer(columns, rows);
+        emulator = new Vt100Emulator(screenBuffer);
+
+        final Map<String, String> environment = new HashMap<>(
+                System.getenv());
+        environment.put("TERM", "xterm-256color");
+        environment.put("COLORTERM", "truecolor");
+
+        // Do not inherit an "active conda environment" from the parent
+        // process: a fresh terminal session must re-activate through
+        // bashrc like any desktop terminal. Inheriting CONDA_DEFAULT_ENV
+        // without CONDA_PREFIX crashes conda's activator, which then
+        // blocks shell startup with an interactive error-report question.
+        environment.remove("CONDA_DEFAULT_ENV");
+        environment.remove("CONDA_SHLVL");
+        environment.remove("CONDA_PROMPT_MODIFIER");
+        environment.remove("CONDA_PREFIX");
+        // never block shell startup on conda's interactive error upload
+        environment.put("CONDA_REPORT_ERRORS", "false");
+
+        pty = new UnixPty(new String[]{"/bin/bash"},
+                System.getProperty("user.dir"), environment, columns, rows);
+
+        readerThread = new Thread(this::readLoop, "pty-reader");
+        readerThread.setDaemon(true);
+        readerThread.start();
+    }
+
+    private InputStream ptyInputStream() {
+        return new InputStream() {
+            private final byte[] chunk = new byte[8192];
+
+            @Override
+            public int read() {
+                final byte[] one = new byte[1];
+                return read(one, 0, 1) < 0 ? -1 : (one[0] & 0xFF);
+            }
+
+            @Override
+            public int read(final byte[] buffer, final int offset,
+                            final int length) {
+                final int count = pty.read(chunk);
+                if (count < 0)
+                    return -1;
+                final int amount = Math.min(count, length);
+                System.arraycopy(chunk, 0, buffer, offset, amount);
+                return amount;
+            }
+        };
+    }
+
+    private void readLoop() {
+        final char[] chunk = new char[8192];
+        try (final InputStreamReader reader = new InputStreamReader(
+                ptyInputStream(), StandardCharsets.UTF_8)) {
+            while (running) {
+                final int count = reader.read(chunk);
+                if (count < 0)
+                    break;
+                emulator.accept(chunk, count);
+                final ContentListener listener = contentListener;
+                if (listener != null)
+                    listener.contentChanged();
+            }
+        } catch (final IOException e) {
+            // PTY closed: shell exited or session stopped
+        }
+        running = false;
+    }
+
+    /**
+     * Sends text to the shell as if typed (UTF-8 encoded).
+     */
+    public void send(final String text) {
+        final byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
+        pty.write(bytes, 0, bytes.length);
+    }
+
+    public ScreenBuffer getScreenBuffer() {
+        return screenBuffer;
+    }
+
+    public void setContentListener(final ContentListener listener) {
+        this.contentListener = listener;
+    }
+
+    public boolean isRunning() {
+        return running && pty.isChildAlive();
+    }
+
+    /**
+     * Kills the shell and stops the reader thread.
+     *
+     * <p>Order matters: kill the child first so the reader's blocking
+     * read() returns, join the reader, and only then let the JVM move on
+     * to exit — a daemon thread mid-JNA-call during JVM teardown can
+     * crash the process with native heap errors.</p>
+     */
+    public void stop() {
+        running = false;
+        pty.destroy();
+        try {
+            readerThread.join(2000);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/sixth/bridge/pty/ScreenBuffer.java b/src/main/java/eu/svjatoslav/sixth/bridge/pty/ScreenBuffer.java
new file mode 100644 (file)
index 0000000..03a5e9b
--- /dev/null
@@ -0,0 +1,574 @@
+/*
+ * Sixth spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.bridge.pty;
+
+/**
+ * Character cell grid backing a terminal screen.
+ *
+ * <p>Pure model: no rendering, no engine dependencies. Colors are stored as
+ * ANSI palette indices ({@code -1} = terminal default); mapping to actual
+ * RGB values is the renderer's job.</p>
+ *
+ * <p>All public methods must be called while holding {@code synchronized}
+ * on this instance (the emulator and the renderer both access it).</p>
+ */
+public class ScreenBuffer {
+
+    /**
+     * Default color marker: use the terminal's default foreground/background.
+     */
+    public static final int DEFAULT_COLOR = -1;
+
+    /**
+     * One character cell: glyph plus attributes.
+     */
+    public static final class Cell {
+        public char ch = ' ';
+        public int fg = DEFAULT_COLOR;
+        public int bg = DEFAULT_COLOR;
+        public boolean bold = false;
+        public boolean reverse = false;
+
+        void set(final char c, final int fg, final int bg,
+                 final boolean bold, final boolean reverse) {
+            this.ch = c;
+            this.fg = fg;
+            this.bg = bg;
+            this.bold = bold;
+            this.reverse = reverse;
+        }
+
+        void clear(final int bg) {
+            set(' ', DEFAULT_COLOR, bg, false, false);
+        }
+    }
+
+    private final int columns;
+    private final int rows;
+
+    private Cell[][] screen;
+    private Cell[][] alternateScreen;
+
+    public int cursorX = 0;
+    public int cursorY = 0;
+    public boolean cursorVisible = true;
+
+    private int savedCursorX = 0;
+    private int savedCursorY = 0;
+
+    private int scrollTop = 0;
+    private int scrollBottom;
+
+    private int currentFg = DEFAULT_COLOR;
+    private int currentBg = DEFAULT_COLOR;
+    private boolean currentBold = false;
+    private boolean currentReverse = false;
+
+    boolean autoWrap = true;
+    boolean insertMode = false;
+    boolean lineDrawing = false;
+
+    /**
+     * DECCKM (?1h): when set, cursor keys must be reported as SS3
+     * (ESC O A) instead of CSI (ESC [ A). Curses applications enable
+     * this; input translation reads it.
+     */
+    public boolean applicationCursorKeys = false;
+
+    private boolean pendingWrap = false;
+
+    public ScreenBuffer(final int columns, final int rows) {
+        this.columns = columns;
+        this.rows = rows;
+        this.scrollBottom = rows - 1;
+        screen = newGrid();
+    }
+
+    private Cell[][] newGrid() {
+        final Cell[][] grid = new Cell[rows][columns];
+        for (int row = 0; row < rows; row++)
+            for (int column = 0; column < columns; column++) {
+                grid[row][column] = new Cell();
+                grid[row][column].clear(DEFAULT_COLOR);
+            }
+        return grid;
+    }
+
+    public int getColumns() {
+        return columns;
+    }
+
+    public int getRows() {
+        return rows;
+    }
+
+    public Cell getCell(final int row, final int column) {
+        return screen[row][column];
+    }
+
+    // ------------------------------------------------------------------
+    // printing
+    // ------------------------------------------------------------------
+
+    public void putChar(final char c) {
+        if (pendingWrap) {
+            pendingWrap = false;
+            carriageReturn();
+            lineFeed();
+        }
+        if (insertMode)
+            insertChars(1);
+        screen[cursorY][cursorX].set(mapLineDrawing(c), currentFg, currentBg,
+                currentBold, currentReverse);
+        if (cursorX == columns - 1) {
+            if (autoWrap)
+                pendingWrap = true;
+        } else
+            cursorX++;
+    }
+
+    private char mapLineDrawing(final char c) {
+        if (!lineDrawing)
+            return c;
+        // DEC special graphics character set (approximate)
+        return switch (c) {
+            case 'j' -> '┘';
+            case 'k' -> '┐';
+            case 'l' -> '┌';
+            case 'm' -> '└';
+            case 'n' -> '┼';
+            case 'q' -> '─';
+            case 't' -> '├';
+            case 'u' -> '┤';
+            case 'v' -> '┴';
+            case 'w' -> '┬';
+            case 'x' -> '│';
+            case 'a' -> '▒';
+            default -> c;
+        };
+    }
+
+    // ------------------------------------------------------------------
+    // control characters
+    // ------------------------------------------------------------------
+
+    public void carriageReturn() {
+        cursorX = 0;
+        pendingWrap = false;
+    }
+
+    public void lineFeed() {
+        pendingWrap = false;
+        if (cursorY == scrollBottom)
+            scrollUp(1);
+        else if (cursorY < rows - 1)
+            cursorY++;
+    }
+
+    /**
+     * Reverse index: cursor up, scrolling the region down at the top edge.
+     */
+    public void reverseIndex() {
+        pendingWrap = false;
+        if (cursorY == scrollTop)
+            scrollDown(1);
+        else if (cursorY > 0)
+            cursorY--;
+    }
+
+    public void backspace() {
+        pendingWrap = false;
+        if (cursorX > 0)
+            cursorX--;
+    }
+
+    public void tab() {
+        pendingWrap = false;
+        cursorX = Math.min(columns - 1, (cursorX + 8) & ~7);
+    }
+
+    // ------------------------------------------------------------------
+    // cursor movement (CSI)
+    // ------------------------------------------------------------------
+
+    public void cursorUp(final int n) {
+        pendingWrap = false;
+        cursorY = Math.max(scrollTop, cursorY - Math.max(1, n));
+    }
+
+    public void cursorDown(final int n) {
+        pendingWrap = false;
+        cursorY = Math.min(scrollBottom, cursorY + Math.max(1, n));
+    }
+
+    public void cursorForward(final int n) {
+        pendingWrap = false;
+        cursorX = Math.min(columns - 1, cursorX + Math.max(1, n));
+    }
+
+    public void cursorBack(final int n) {
+        pendingWrap = false;
+        cursorX = Math.max(0, cursorX - Math.max(1, n));
+    }
+
+    /**
+     * 1-based absolute position, as sent by CSI H / CSI f.
+     */
+    public void setCursorPosition(final int row1based, final int column1based) {
+        pendingWrap = false;
+        cursorY = clamp(row1based - 1, 0, rows - 1);
+        cursorX = clamp(column1based - 1, 0, columns - 1);
+    }
+
+    public void setCursorColumn(final int column1based) {
+        pendingWrap = false;
+        cursorX = clamp(column1based - 1, 0, columns - 1);
+    }
+
+    public void setCursorRow(final int row1based) {
+        pendingWrap = false;
+        cursorY = clamp(row1based - 1, 0, rows - 1);
+    }
+
+    public void saveCursor() {
+        savedCursorX = cursorX;
+        savedCursorY = cursorY;
+    }
+
+    public void restoreCursor() {
+        pendingWrap = false;
+        cursorX = savedCursorX;
+        cursorY = savedCursorY;
+    }
+
+    private static int clamp(final int v, final int min, final int max) {
+        return Math.max(min, Math.min(max, v));
+    }
+
+    // ------------------------------------------------------------------
+    // erasing
+    // ------------------------------------------------------------------
+
+    private void clearRange(final int row, final int fromColumn,
+                            final int toColumn) {
+        for (int column = fromColumn; column <= toColumn; column++)
+            screen[row][column].clear(currentBg);
+    }
+
+    /**
+     * CSI J: 0 = cursor to end, 1 = start to cursor, 2 = whole screen.
+     */
+    public void eraseInDisplay(final int mode) {
+        pendingWrap = false;
+        switch (mode) {
+            case 0 -> {
+                clearRange(cursorY, cursorX, columns - 1);
+                for (int row = cursorY + 1; row < rows; row++)
+                    clearRange(row, 0, columns - 1);
+            }
+            case 1 -> {
+                for (int row = 0; row < cursorY; row++)
+                    clearRange(row, 0, columns - 1);
+                clearRange(cursorY, 0, cursorX);
+            }
+            case 2, 3 -> {
+                for (int row = 0; row < rows; row++)
+                    clearRange(row, 0, columns - 1);
+            }
+            default -> {
+            }
+        }
+    }
+
+    /**
+     * CSI K: 0 = cursor to end of line, 1 = start to cursor, 2 = whole line.
+     */
+    public void eraseInLine(final int mode) {
+        pendingWrap = false;
+        switch (mode) {
+            case 0 -> clearRange(cursorY, cursorX, columns - 1);
+            case 1 -> clearRange(cursorY, 0, cursorX);
+            case 2 -> clearRange(cursorY, 0, columns - 1);
+            default -> {
+            }
+        }
+    }
+
+    /**
+     * CSI X: erase n characters from cursor without moving it.
+     */
+    public void eraseChars(final int n) {
+        pendingWrap = false;
+        final int count = Math.max(1, n);
+        clearRange(cursorY, cursorX,
+                Math.min(columns - 1, cursorX + count - 1));
+    }
+
+    // ------------------------------------------------------------------
+    // inserting / deleting / scrolling
+    // ------------------------------------------------------------------
+
+    /**
+     * CSI P: delete n characters at cursor, shifting the rest of the line left.
+     */
+    public void deleteChars(final int n) {
+        pendingWrap = false;
+        final int count = Math.max(1, n);
+        for (int column = cursorX; column < columns; column++) {
+            if (column + count < columns)
+                screen[cursorY][column].set(
+                        screen[cursorY][column + count].ch,
+                        screen[cursorY][column + count].fg,
+                        screen[cursorY][column + count].bg,
+                        screen[cursorY][column + count].bold,
+                        screen[cursorY][column + count].reverse);
+            else
+                screen[cursorY][column].clear(currentBg);
+        }
+    }
+
+    /**
+     * CSI @: insert n blank characters at cursor, shifting line right.
+     */
+    public void insertChars(final int n) {
+        final int count = Math.max(1, n);
+        for (int column = columns - 1; column >= cursorX; column--) {
+            if (column - count >= cursorX)
+                screen[cursorY][column].set(
+                        screen[cursorY][column - count].ch,
+                        screen[cursorY][column - count].fg,
+                        screen[cursorY][column - count].bg,
+                        screen[cursorY][column - count].bold,
+                        screen[cursorY][column - count].reverse);
+            else
+                screen[cursorY][column].clear(currentBg);
+        }
+    }
+
+    /**
+     * CSI L: insert n blank lines at cursor row (within scroll region).
+     */
+    public void insertLines(final int n) {
+        pendingWrap = false;
+        if (cursorY < scrollTop || cursorY > scrollBottom)
+            return;
+        final int count = Math.min(Math.max(1, n), scrollBottom - cursorY + 1);
+        for (int row = scrollBottom; row >= cursorY; row--) {
+            if (row - count >= cursorY)
+                copyRow(row - count, row);
+            else
+                clearRange(row, 0, columns - 1);
+        }
+    }
+
+    /**
+     * CSI M: delete n lines at cursor row (within scroll region).
+     */
+    public void deleteLines(final int n) {
+        pendingWrap = false;
+        if (cursorY < scrollTop || cursorY > scrollBottom)
+            return;
+        final int count = Math.min(Math.max(1, n), scrollBottom - cursorY + 1);
+        for (int row = cursorY; row <= scrollBottom; row++) {
+            if (row + count <= scrollBottom)
+                copyRow(row + count, row);
+            else
+                clearRange(row, 0, columns - 1);
+        }
+    }
+
+    private void copyRow(final int fromRow, final int toRow) {
+        for (int column = 0; column < columns; column++) {
+            final Cell from = screen[fromRow][column];
+            screen[toRow][column].set(from.ch, from.fg, from.bg, from.bold,
+                    from.reverse);
+        }
+    }
+
+    /**
+     * Scroll the scroll region up by n lines (content moves up, blanks at
+     * bottom).
+     */
+    public void scrollUp(final int n) {
+        final int count = Math.min(Math.max(1, n),
+                scrollBottom - scrollTop + 1);
+        for (int row = scrollTop; row <= scrollBottom; row++) {
+            if (row + count <= scrollBottom)
+                copyRow(row + count, row);
+            else
+                clearRange(row, 0, columns - 1);
+        }
+    }
+
+    /**
+     * Scroll the scroll region down by n lines (content moves down, blanks at
+     * top).
+     */
+    public void scrollDown(final int n) {
+        final int count = Math.min(Math.max(1, n),
+                scrollBottom - scrollTop + 1);
+        for (int row = scrollBottom; row >= scrollTop; row--) {
+            if (row - count >= scrollTop)
+                copyRow(row - count, row);
+            else
+                clearRange(row, 0, columns - 1);
+        }
+    }
+
+    /**
+     * CSI r: set scroll region, 1-based inclusive rows. Also homes the cursor
+     * per xterm behavior.
+     */
+    public void setScrollRegion(final int top1based, final int bottom1based) {
+        final int top = clamp(top1based - 1, 0, rows - 1);
+        final int bottom = clamp(bottom1based - 1, 0, rows - 1);
+        if (top < bottom) {
+            scrollTop = top;
+            scrollBottom = bottom;
+        }
+        setCursorPosition(1, 1);
+    }
+
+    // ------------------------------------------------------------------
+    // attributes / modes
+    // ------------------------------------------------------------------
+
+    /**
+     * CSI m: select graphic rendition. {@code params[i] == -1} marks an
+     * omitted parameter (treated as 0).
+     */
+    public void sgr(final int[] params) {
+        if (params.length == 0) {
+            resetAttributes();
+            return;
+        }
+        for (int i = 0; i < params.length; i++) {
+            final int p = params[i] < 0 ? 0 : params[i];
+            if (p == 0)
+                resetAttributes();
+            else if (p == 1)
+                currentBold = true;
+            else if (p == 22)
+                currentBold = false;
+            else if (p == 7)
+                currentReverse = true;
+            else if (p == 27)
+                currentReverse = false;
+            else if (p >= 30 && p <= 37)
+                currentFg = p - 30;
+            else if (p == 39)
+                currentFg = DEFAULT_COLOR;
+            else if (p >= 40 && p <= 47)
+                currentBg = p - 40;
+            else if (p == 49)
+                currentBg = DEFAULT_COLOR;
+            else if (p >= 90 && p <= 97)
+                currentFg = p - 90 + 8;
+            else if (p >= 100 && p <= 107)
+                currentBg = p - 100 + 8;
+            else if ((p == 38 || p == 48) && i + 2 < params.length
+                    && params[i + 1] == 5) {
+                // 256-color palette index; renderer maps it
+                if (p == 38)
+                    currentFg = 16 + (params[i + 2] & 0xFF);
+                else
+                    currentBg = 16 + (params[i + 2] & 0xFF);
+                i += 2;
+            }
+            // other attributes (underline, blink, ...) accepted but not
+            // visualized
+        }
+    }
+
+    private void resetAttributes() {
+        currentFg = DEFAULT_COLOR;
+        currentBg = DEFAULT_COLOR;
+        currentBold = false;
+        currentReverse = false;
+    }
+
+    /**
+     * DEC private mode set/reset (CSI ? ... h / l).
+     */
+    public void setDecMode(final int mode, final boolean enabled) {
+        switch (mode) {
+            case 1 -> applicationCursorKeys = enabled;
+            case 7 -> autoWrap = enabled;
+            case 25 -> cursorVisible = enabled;
+            case 47, 1047 -> setAlternateScreen(enabled, false);
+            case 1048 -> {
+                if (enabled)
+                    saveCursor();
+                else
+                    restoreCursor();
+            }
+            case 1049 -> setAlternateScreen(enabled, true);
+            default -> {
+                // other private modes (bracketed paste, application
+                // keypad, mouse reporting, ...) accepted but ignored
+            }
+        }
+    }
+
+    /**
+     * ANSI mode set/reset (CSI h / l without '?').
+     */
+    public void setAnsiMode(final int mode, final boolean enabled) {
+        if (mode == 4)
+            insertMode = enabled;
+    }
+
+    private void setAlternateScreen(final boolean enabled,
+                                    final boolean saveCursor) {
+        if (enabled) {
+            if (saveCursor)
+                saveCursor();
+            alternateScreen = screen;
+            screen = newGrid();
+            setCursorPosition(1, 1);
+        } else {
+            if (alternateScreen != null)
+                screen = alternateScreen;
+            alternateScreen = null;
+            if (saveCursor)
+                restoreCursor();
+        }
+    }
+
+    public void fullReset() {
+        resetAttributes();
+        autoWrap = true;
+        insertMode = false;
+        lineDrawing = false;
+        applicationCursorKeys = false;
+        cursorVisible = true;
+        pendingWrap = false;
+        scrollTop = 0;
+        scrollBottom = rows - 1;
+        alternateScreen = null;
+        screen = newGrid();
+        cursorX = 0;
+        cursorY = 0;
+    }
+
+    // ------------------------------------------------------------------
+    // debugging / testing
+    // ------------------------------------------------------------------
+
+    /**
+     * Renders the visible screen as plain text (attributes stripped).
+     */
+    public String dumpText() {
+        final StringBuilder result = new StringBuilder();
+        for (int row = 0; row < rows; row++) {
+            final StringBuilder line = new StringBuilder();
+            for (int column = 0; column < columns; column++)
+                line.append(screen[row][column].ch);
+            result.append(line.toString().replaceAll("\\s+$", ""))
+                    .append('\n');
+        }
+        return result.toString();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/sixth/bridge/pty/UnixPty.java b/src/main/java/eu/svjatoslav/sixth/bridge/pty/UnixPty.java
new file mode 100644 (file)
index 0000000..7ef4b70
--- /dev/null
@@ -0,0 +1,235 @@
+/*
+ * Sixth spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.bridge.pty;
+
+import com.sun.jna.Library;
+import com.sun.jna.Memory;
+import com.sun.jna.Native;
+import com.sun.jna.Pointer;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Minimal Linux PTY implementation via JNA direct libc calls.
+ *
+ * <p>Replaces the pty4j dependency: pty4j 0.12.x requires purejavacomm
+ * (jtermios classes), which is no longer published on any reachable Maven
+ * repository. All we need is {@code posix_openpt} + {@code fork} +
+ * {@code execve}, which is a handful of libc calls.</p>
+ *
+ * <p>Linux-only by design; the workspace targets Linux.</p>
+ */
+final class UnixPty {
+
+    private static final int O_RDWR = 0x02;
+    private static final int O_NOCTTY = 0x100;
+    private static final int TIOCSWINSZ = 0x5414;
+    private static final int POSIX_SPAWN_SETSID = 0x80; // glibc >= 2.26
+    private static final int SIGKILL = 9;
+
+    private interface CLib extends Library {
+        CLib INSTANCE = Native.load("c", CLib.class);
+
+        int posix_openpt(int flags);
+
+        int grantpt(int fd);
+
+        int unlockpt(int fd);
+
+        String ptsname(int fd);
+
+        int ioctl(int fd, int request, Pointer arg);
+
+        int posix_spawn(Memory pid, String path, Pointer fileActions,
+                        Pointer attributes, Pointer argv, Pointer envp);
+
+        int posix_spawn_file_actions_init(Pointer fileActions);
+
+        int posix_spawn_file_actions_addopen(Pointer fileActions, int fd,
+                                             String path, int oflag,
+                                             int mode);
+
+        int posix_spawn_file_actions_adddup2(Pointer fileActions, int fd,
+                                             int newFd);
+
+        int posix_spawn_file_actions_addclose(Pointer fileActions, int fd);
+
+        int posix_spawnattr_init(Pointer attributes);
+
+        int posix_spawnattr_setflags(Pointer attributes, short flags);
+
+        int close(int fd);
+
+        int read(int fd, Pointer buffer, int count);
+
+        int write(int fd, Pointer buffer, int count);
+
+        int waitpid(int pid, int[] status, int options);
+
+        int kill(int pid, int signal);
+    }
+
+    final int masterFd;
+    final int childPid;
+
+    /**
+     * Single reused native buffer for reads: per-call Memory allocation
+     * risks the JNA cleaner freeing a buffer while a thread is blocked in
+     * libc read() on it during JVM shutdown.
+     */
+    private final Memory readMemory = new Memory(65536);
+
+    /**
+     * Creates a PTY and spawns the command on the slave side via
+     * {@code posix_spawn}: a new session whose first opened terminal
+     * becomes its controlling terminal.
+     *
+     * <p>posix_spawn is used instead of fork+exec because fork inside a
+     * live JVM child deadlocks on JVM internal locks (futex) before it can
+     * exec — observed empirically: the forked child hung as a second JVM
+     * and bash never started.</p>
+     *
+     * @param command     argv (absolute path first)
+     * @param workingDir  child working directory (applied by the caller's
+     *                    command, e.g. bash is started with the dir as cwd
+     *                    via a chdir wrapper below)
+     * @param environment full environment for the child
+     * @param columns     terminal width in characters
+     * @param rows        terminal height in characters
+     */
+    UnixPty(final String[] command, final String workingDir,
+            final Map<String, String> environment,
+            final int columns, final int rows) {
+
+        masterFd = CLib.INSTANCE.posix_openpt(O_RDWR | O_NOCTTY);
+        if (masterFd < 0)
+            throw new IllegalStateException("posix_openpt failed: "
+                    + Native.getLastError());
+        if (CLib.INSTANCE.grantpt(masterFd) != 0
+                || CLib.INSTANCE.unlockpt(masterFd) != 0)
+            throw new IllegalStateException("grantpt/unlockpt failed: "
+                    + Native.getLastError());
+
+        final String slaveName = CLib.INSTANCE.ptsname(masterFd);
+
+        // window size must be set before spawn so the child inherits it
+        final Memory winsize = new Memory(8);
+        winsize.setShort(0, (short) rows);
+        winsize.setShort(2, (short) columns);
+        winsize.setShort(4, (short) 0);
+        winsize.setShort(6, (short) 0);
+        CLib.INSTANCE.ioctl(masterFd, TIOCSWINSZ, winsize);
+
+        // child file actions: slave PTY onto stdin/stdout/stderr, drop
+        // the master. POSIX_SPAWN_SETSID makes the child a session leader;
+        // glibc applies it BEFORE file actions, so the addopen below (no
+        // O_NOCTTY) also acquires the slave as the controlling terminal.
+        final Memory fileActions = new Memory(256);
+        final Memory attributes = new Memory(256);
+        CLib.INSTANCE.posix_spawn_file_actions_init(fileActions);
+        CLib.INSTANCE.posix_spawnattr_init(attributes);
+        CLib.INSTANCE.posix_spawnattr_setflags(attributes,
+                (short) POSIX_SPAWN_SETSID);
+        CLib.INSTANCE.posix_spawn_file_actions_addopen(fileActions, 0,
+                slaveName, O_RDWR, 0);
+        CLib.INSTANCE.posix_spawn_file_actions_adddup2(fileActions, 0, 1);
+        CLib.INSTANCE.posix_spawn_file_actions_adddup2(fileActions, 0, 2);
+        CLib.INSTANCE.posix_spawn_file_actions_addclose(fileActions,
+                masterFd);
+
+        final List<String> envStrings = new ArrayList<>();
+        environment.forEach((key, value) -> envStrings.add(key + "=" + value));
+        final Pointer envp = pointerArray(
+                envStrings.toArray(new String[0]));
+
+        // working directory: posix_spawn has no chdir file action (glibc
+        // added addchdir only in 2.29 as a GNU extension), so spawn a
+        // helper shell that chdirs and execs the real command with its
+        // arguments.
+        final StringBuilder execLine = new StringBuilder(
+                "cd \"$1\" && shift && exec");
+        for (final String arg : command)
+            execLine.append(" \"").append(arg.replace("\"", "\\\""))
+                    .append('"');
+        final String[] shellCommand = {"/bin/sh", "-c",
+                execLine.toString(), "sh", workingDir};
+        final Pointer shellArgv = pointerArray(shellCommand);
+
+        final Memory pidResult = new Memory(4);
+        final int rc = CLib.INSTANCE.posix_spawn(pidResult, "/bin/sh",
+                fileActions, attributes, shellArgv, envp);
+        if (rc != 0)
+            throw new IllegalStateException("posix_spawn failed: " + rc);
+        childPid = pidResult.getInt(0);
+    }
+
+    /**
+     * Builds a NULL-terminated char** from Java strings.
+     *
+     * <p>The backing native memory is deliberately kept reachable for the
+     * JVM lifetime (a few dozen small strings per PTY): the child execs
+     * asynchronously after {@code fork}, so the memory must outlive this
+     * method call.</p>
+     */
+    private static final List<Memory> NATIVE_STRING_STORAGE =
+            new ArrayList<>();
+
+    private static synchronized Pointer pointerArray(
+            final String[] strings) {
+        final Memory array = new Memory(
+                (long) (strings.length + 1) * Native.POINTER_SIZE);
+        NATIVE_STRING_STORAGE.add(array);
+        for (int i = 0; i < strings.length; i++) {
+            final byte[] bytes = strings[i].getBytes(StandardCharsets.UTF_8);
+            final Memory entry = new Memory(bytes.length + 1);
+            entry.write(0, bytes, 0, bytes.length);
+            entry.setByte(bytes.length, (byte) 0);
+            NATIVE_STRING_STORAGE.add(entry);
+            array.setPointer((long) i * Native.POINTER_SIZE, entry);
+        }
+        array.setPointer((long) strings.length * Native.POINTER_SIZE, null);
+        return array;
+    }
+
+    /**
+     * Blocking read from the PTY master.
+     *
+     * @return number of bytes read, or -1 when the child side closed
+     */
+    int read(final byte[] buffer) {
+        final int amount = Math.min(buffer.length, (int) readMemory.size());
+        final int count = CLib.INSTANCE.read(masterFd, readMemory, amount);
+        if (count <= 0)
+            return -1;
+        readMemory.read(0, buffer, 0, count);
+        return count;
+    }
+
+    void write(final byte[] buffer, final int offset, final int length) {
+        final Memory memory = new Memory(length);
+        memory.write(0, buffer, offset, length);
+        int written = 0;
+        while (written < length) {
+            final int count = CLib.INSTANCE.write(masterFd,
+                    memory.share(written), length - written);
+            if (count <= 0)
+                return;
+            written += count;
+        }
+    }
+
+    boolean isChildAlive() {
+        return CLib.INSTANCE.kill(childPid, 0) == 0;
+    }
+
+    void destroy() {
+        CLib.INSTANCE.kill(childPid, SIGKILL);
+        CLib.INSTANCE.waitpid(childPid, new int[1], 0);
+        CLib.INSTANCE.close(masterFd);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/sixth/bridge/pty/Vt100Emulator.java b/src/main/java/eu/svjatoslav/sixth/bridge/pty/Vt100Emulator.java
new file mode 100644 (file)
index 0000000..b45331b
--- /dev/null
@@ -0,0 +1,255 @@
+/*
+ * Sixth spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.bridge.pty;
+
+/**
+ * Minimal VT100/xterm escape sequence interpreter feeding a
+ * {@link ScreenBuffer}.
+ *
+ * <p>Covers what an interactive shell and everyday tools actually emit:
+ * printable text (UTF-8 decoded by the caller), C0 controls, CSI cursor
+ * movement / erasing / insert-delete / scroll region / SGR colors, DEC
+ * private modes (autowrap, cursor visibility, alternate screen), OSC
+ * (ignored), and the DEC line-drawing charset.</p>
+ *
+ * <p>Not a full xterm: no scrollback, no mouse reporting, no 8-bit C1.
+ * Unknown sequences are consumed and ignored rather than mishandled.</p>
+ *
+ * <p>Threading: all calls arrive on the PTY reader thread; the emulator
+ * synchronizes on the buffer for every operation.</p>
+ */
+public class Vt100Emulator {
+
+    private enum State {
+        GROUND, ESCAPE, CSI, OSC, OSC_ESC, CHARSET, IGNORE_ONE
+    }
+
+    private final ScreenBuffer buffer;
+
+    private State state = State.GROUND;
+
+    private final StringBuilder params = new StringBuilder();
+    private boolean csiPrivate = false;
+
+    public Vt100Emulator(final ScreenBuffer buffer) {
+        this.buffer = buffer;
+    }
+
+    public void accept(final char[] chars, final int length) {
+        for (int i = 0; i < length; i++)
+            accept(chars[i]);
+    }
+
+    public void accept(final char c) {
+        switch (state) {
+            case GROUND -> ground(c);
+            case ESCAPE -> escape(c);
+            case CSI -> csi(c);
+            case OSC -> osc(c);
+            case OSC_ESC -> {
+                state = State.GROUND; // consume ST's backslash
+            }
+            case CHARSET -> charset(c);
+            case IGNORE_ONE -> state = State.GROUND;
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // GROUND
+    // ------------------------------------------------------------------
+
+    private void ground(final char c) {
+        if (c == 0x1B) {
+            state = State.ESCAPE;
+            return;
+        }
+        if (c < 0x20) {
+            control(c);
+            return;
+        }
+        if (c == 0x7F)
+            return; // DEL: ignored on input path
+        synchronized (buffer) {
+            buffer.putChar(c);
+        }
+    }
+
+    private void control(final char c) {
+        synchronized (buffer) {
+            switch (c) {
+                case '\b' -> buffer.backspace();
+                case '\t' -> buffer.tab();
+                case '\n', 0x0B, 0x0C -> buffer.lineFeed();
+                case '\r' -> buffer.carriageReturn();
+                default -> {
+                    // BEL and the rest: no visual effect
+                }
+            }
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // ESC
+    // ------------------------------------------------------------------
+
+    private void escape(final char c) {
+        switch (c) {
+            case '[' -> {
+                params.setLength(0);
+                csiPrivate = false;
+                state = State.CSI;
+            }
+            case ']' -> state = State.OSC;
+            case '(' -> state = State.CHARSET;
+            case ')' -> state = State.IGNORE_ONE; // G1 charset: unused
+            case 'O' -> state = State.IGNORE_ONE; // SS3: single-char control
+            case '7' -> {
+                synchronized (buffer) {
+                    buffer.saveCursor();
+                }
+                state = State.GROUND;
+            }
+            case '8' -> {
+                synchronized (buffer) {
+                    buffer.restoreCursor();
+                }
+                state = State.GROUND;
+            }
+            case 'D' -> {
+                synchronized (buffer) {
+                    buffer.lineFeed();
+                }
+                state = State.GROUND;
+            }
+            case 'M' -> {
+                synchronized (buffer) {
+                    buffer.reverseIndex();
+                }
+                state = State.GROUND;
+            }
+            case 'E' -> {
+                synchronized (buffer) {
+                    buffer.carriageReturn();
+                    buffer.lineFeed();
+                }
+                state = State.GROUND;
+            }
+            case 'c' -> {
+                synchronized (buffer) {
+                    buffer.fullReset();
+                }
+                state = State.GROUND;
+            }
+            default -> state = State.GROUND; // =, > and unknowns: ignore
+        }
+    }
+
+    private void charset(final char c) {
+        synchronized (buffer) {
+            buffer.lineDrawing = (c == '0');
+        }
+        state = State.GROUND;
+    }
+
+    private void osc(final char c) {
+        if (c == 0x07)
+            state = State.GROUND; // BEL terminates
+        else if (c == 0x1B)
+            state = State.OSC_ESC; // expect ST ("\")
+    }
+
+    // ------------------------------------------------------------------
+    // CSI
+    // ------------------------------------------------------------------
+
+    private void csi(final char c) {
+        if (c == '?') {
+            csiPrivate = true;
+            return;
+        }
+        if ((c >= '0' && c <= '9') || c == ';') {
+            params.append(c);
+            return;
+        }
+        if (c >= 0x20 && c <= 0x2F)
+            return; // intermediate bytes: ignored (e.g. space in "CSI 4 SP q")
+        if (c < 0x40 || c > 0x7E) {
+            state = State.GROUND;
+            return;
+        }
+        dispatchCsi(c);
+        state = State.GROUND;
+    }
+
+    private int[] parsedParams() {
+        if (params.length() == 0)
+            return new int[0];
+        final String[] parts = params.toString().split(";", -1);
+        final int[] result = new int[parts.length];
+        for (int i = 0; i < parts.length; i++)
+            result[i] = parts[i].isEmpty() ? -1 : Integer.parseInt(parts[i]);
+        return result;
+    }
+
+    private int param(final int[] p, final int index, final int defaultValue) {
+        if (index >= p.length || p[index] <= 0)
+            return defaultValue;
+        return p[index];
+    }
+
+    private void dispatchCsi(final char command) {
+        final int[] p = parsedParams();
+        synchronized (buffer) {
+            switch (command) {
+                case 'A' -> buffer.cursorUp(param(p, 0, 1));
+                case 'B' -> buffer.cursorDown(param(p, 0, 1));
+                case 'C' -> buffer.cursorForward(param(p, 0, 1));
+                case 'D' -> buffer.cursorBack(param(p, 0, 1));
+                case 'E' -> {
+                    buffer.cursorDown(param(p, 0, 1));
+                    buffer.carriageReturn();
+                }
+                case 'F' -> {
+                    buffer.cursorUp(param(p, 0, 1));
+                    buffer.carriageReturn();
+                }
+                case 'G', '`' -> buffer.setCursorColumn(param(p, 0, 1));
+                case 'd' -> buffer.setCursorRow(param(p, 0, 1));
+                case 'H', 'f' -> buffer.setCursorPosition(param(p, 0, 1),
+                        param(p, 1, 1));
+                case 'J' -> buffer.eraseInDisplay(param(p, 0, 0));
+                case 'K' -> buffer.eraseInLine(param(p, 0, 0));
+                case 'L' -> buffer.insertLines(param(p, 0, 1));
+                case 'M' -> buffer.deleteLines(param(p, 0, 1));
+                case 'P' -> buffer.deleteChars(param(p, 0, 1));
+                case '@' -> buffer.insertChars(param(p, 0, 1));
+                case 'S' -> buffer.scrollUp(param(p, 0, 1));
+                case 'T' -> buffer.scrollDown(param(p, 0, 1));
+                case 'X' -> buffer.eraseChars(param(p, 0, 1));
+                case 'm' -> buffer.sgr(p);
+                case 'r' -> buffer.setScrollRegion(param(p, 0, 1),
+                        param(p, 1, buffer.getRows()));
+                case 's' -> buffer.saveCursor();
+                case 'u' -> buffer.restoreCursor();
+                case 'h' -> setModes(p, true);
+                case 'l' -> setModes(p, false);
+                default -> {
+                    // unknown CSI: ignored
+                }
+            }
+        }
+    }
+
+    private void setModes(final int[] p, final boolean enabled) {
+        for (final int mode : p) {
+            if (mode < 0)
+                continue;
+            if (csiPrivate)
+                buffer.setDecMode(mode, enabled);
+            else
+                buffer.setAnsiMode(mode, enabled);
+        }
+    }
+}
index 04791ec..64d0504 100644 (file)
 /*
- * Sixth core user interface. Author: Svjatoslav Agejenko.
+ * Sixth spatial computing environment. Author: Svjatoslav Agejenko.
  * This project is released under Creative Commons Zero (CC0) license.
  */
-
 package eu.svjatoslav.sixth.core;
 
+import eu.svjatoslav.sixth.workspace.TerminalPanel;
+import eu.svjatoslav.sixth.workspace.Workspace;
+
+/**
+ * Entry point for the Sixth spatial computing environment.
+ *
+ * <p>Usage: {@code sixth [--selftest]}</p>
+ *
+ * <p>{@code --selftest} opens the workspace, types {@code ls} and an
+ * arithmetic echo into the terminal programmatically, verifies the expected
+ * output appears on the terminal screen, prints the screen contents, and
+ * exits with status 0 (pass) or 1 (fail). Intended to run under
+ * {@code xvfb-run} in CI-style verification.</p>
+ */
 public class Main {
 
- // TODO
+    private static final long SELFTEST_TIMEOUT_MS = 15_000;
+
+    public static void main(final String[] args) throws Exception {
+        final Workspace workspace = new Workspace();
+        workspace.open();
+
+        if (args.length > 0 && "--selftest".equals(args[0]))
+            System.exit(runSelfTest(workspace));
+
+        Runtime.getRuntime().addShutdownHook(
+                new Thread(workspace::close));
+    }
+
+    /**
+     * Drives the terminal without AWT events: injects bytes straight into
+     * the PTY, exactly what a focused keystroke would send.
+     */
+    private static int runSelfTest(final Workspace workspace)
+            throws InterruptedException {
+        final TerminalPanel terminal = workspace.getTerminalPanel();
+
+        // wait for the real shell prompt (bashrc noise may contain a bare
+        // "$", so match the user@host prompt text instead)
+        if (!waitFor(terminal, "n0@tiny"))
+            return fail(terminal, "no shell prompt appeared");
+
+        terminal.typeText("ls\r");
+        if (!waitFor(terminal, "pom.xml"))
+            return fail(terminal, "'ls' output missing pom.xml");
+        if (!waitFor(terminal, "src"))
+            return fail(terminal, "'ls' output missing src");
+
+        terminal.typeText("echo $((6*7))\r");
+        if (!waitFor(terminal, "42"))
+            return fail(terminal, "arithmetic echo produced no 42");
+
+        // unique sentinel that exists ONLY on the main screen; mc/htop
+        // draw on the alternate screen, and mc shows both its own
+        // "n0@tiny" command-line prompt AND file timestamps that can
+        // contain substrings like "42" — only a made-up string is a
+        // non-vacuous proof of being back at the shell
+        terminal.typeText("echo SENTINEL42XYZ\r");
+        if (!waitFor(terminal, "SENTINEL42XYZ"))
+            return fail(terminal, "sentinel echo failed");
+
+        // fullscreen curses programs: mc must open (and enable
+        // application cursor keys), quit on F10; htop must quit on 'q';
+        // mc must also quit on ESC 0 (proves ESC reaches programs)
+        terminal.typeText("mc\r");
+        if (!waitFor(terminal, "Name"))
+            return fail(terminal, "mc did not open");
+        if (!terminal.isApplicationCursorKeys())
+            return fail(terminal,
+                    "mc did not enable application cursor keys");
+        terminal.typeText("\u001B[21~"); // F10
+        if (!waitFor(terminal, "SENTINEL42XYZ"))
+            return fail(terminal, "mc did not quit on F10");
+
+        terminal.typeText("htop\r");
+        if (!waitFor(terminal, "Tasks"))
+            return fail(terminal, "htop did not open");
+        terminal.typeText("q");
+        if (!waitFor(terminal, "SENTINEL42XYZ"))
+            return fail(terminal, "htop did not quit on 'q'");
+
+        terminal.typeText("mc\r");
+        if (!waitFor(terminal, "Name"))
+            return fail(terminal, "mc did not reopen");
+        terminal.typeText("\u001B");
+        terminal.typeText("0"); // ESC 0 = F10 in mc
+        if (!waitFor(terminal, "SENTINEL42XYZ"))
+            return fail(terminal, "mc did not quit on ESC 0");
+
+        System.out.println("SELFTEST PASS — terminal screen:");
+        System.out.println(terminal.getScreenText());
+        workspace.close();
+        return 0;
+    }
+
+    private static boolean waitFor(final TerminalPanel terminal,
+                                   final String needle)
+            throws InterruptedException {
+        final long deadline = System.currentTimeMillis()
+                + SELFTEST_TIMEOUT_MS;
+        while (System.currentTimeMillis() < deadline) {
+            if (terminal.getScreenText().contains(needle))
+                return true;
+            Thread.sleep(100);
+        }
+        return false;
+    }
+
+    private static int fail(final TerminalPanel terminal,
+                            final String reason) {
+        System.out.println("SELFTEST FAIL: " + reason);
+        System.out.println("terminal screen:");
+        System.out.println(terminal.getScreenText());
+        return 1;
+    }
 }
diff --git a/src/main/java/eu/svjatoslav/sixth/workspace/TerminalPanel.java b/src/main/java/eu/svjatoslav/sixth/workspace/TerminalPanel.java
new file mode 100644 (file)
index 0000000..71a9f2f
--- /dev/null
@@ -0,0 +1,317 @@
+/*
+ * Sixth spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.workspace;
+
+import eu.svjatoslav.sixth.bridge.pty.PtySession;
+import eu.svjatoslav.sixth.bridge.pty.ScreenBuffer;
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.gui.GuiComponent;
+import eu.svjatoslav.sixth.e3d.gui.TextPointer;
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+
+import java.awt.event.KeyEvent;
+
+/**
+ * A live terminal panel in 3D space: a real shell on a PTY (via
+ * {@link PtySession}) rendered onto a {@link TextCanvas}.
+ *
+ * <p>Click the panel to focus it; keystrokes are translated to terminal
+ * input bytes and written to the PTY. ESC releases focus (engine focus
+ * model, same as the text editor).</p>
+ *
+ * <p>Terminal output arrives on the PTY reader thread, which copies the
+ * screen buffer into the canvas and requests a repaint. This is the same
+ * benign writer/renderer race the text editor already accepts.</p>
+ */
+public class TerminalPanel extends GuiComponent {
+
+    /**
+     * Standard xterm palette, indices 0-15 (normal + bright).
+     */
+    private static final Color[] PALETTE = {
+            new Color(0, 0, 0),           // 0 black
+            new Color(205, 49, 49),       // 1 red
+            new Color(13, 188, 121),      // 2 green
+            new Color(229, 229, 16),      // 3 yellow
+            new Color(36, 114, 200),      // 4 blue
+            new Color(188, 63, 188),      // 5 magenta
+            new Color(17, 168, 205),      // 6 cyan
+            new Color(229, 229, 229),     // 7 white
+            new Color(102, 102, 102),     // 8 bright black
+            new Color(241, 76, 76),       // 9 bright red
+            new Color(35, 209, 139),      // 10 bright green
+            new Color(245, 245, 67),      // 11 bright yellow
+            new Color(59, 142, 234),      // 12 bright blue
+            new Color(214, 112, 214),     // 13 bright magenta
+            new Color(41, 184, 219),      // 14 bright cyan
+            new Color(255, 255, 255),     // 15 bright white
+    };
+
+    private static final Color DEFAULT_FOREGROUND = new Color(220, 220, 220);
+    private static final Color DEFAULT_BACKGROUND = new Color(16, 16, 24);
+    private static final Color CURSOR_COLOR = new Color(200, 255, 200);
+
+    private final PtySession session;
+    private final TextCanvas textCanvas;
+
+    /**
+     * Creates a terminal panel running the given PTY session.
+     *
+     * @param transform              position in the world
+     * @param viewPanel              the view panel this component belongs to
+     * @param sizeInWorldCoordinates panel size; determines terminal columns
+     *                               and rows through the engine font metrics
+     * @param session                a running PTY session whose buffer
+     *                               dimensions match the panel grid
+     */
+    public TerminalPanel(final Transform transform, final ViewPanel viewPanel,
+                         final Point2D sizeInWorldCoordinates,
+                         final PtySession session) {
+        super(transform, viewPanel, sizeInWorldCoordinates.to3D());
+        this.session = session;
+
+        final int columns = (int) (sizeInWorldCoordinates.x
+                / TextCanvas.FONT_CHAR_WIDTH);
+        final int rows = (int) (sizeInWorldCoordinates.y
+                / TextCanvas.FONT_CHAR_HEIGHT);
+
+        textCanvas = new TextCanvas(new Transform(),
+                new TextPointer(rows, columns),
+                DEFAULT_FOREGROUND, DEFAULT_BACKGROUND);
+        textCanvas.setMouseInteractionController(this);
+        addShape(textCanvas);
+
+        session.setContentListener(this::onTerminalContent);
+    }
+
+    private void onTerminalContent() {
+        syncBufferToCanvas();
+        viewPanel.repaintDuringNextViewUpdate();
+    }
+
+    /**
+     * Copies the terminal screen buffer into the text canvas, resolving
+     * palette colors and painting the cursor as an inverted cell.
+     */
+    private void syncBufferToCanvas() {
+        final ScreenBuffer buffer = session.getScreenBuffer();
+        synchronized (buffer) {
+            for (int row = 0; row < buffer.getRows(); row++)
+                for (int column = 0; column < buffer.getColumns(); column++) {
+                    final ScreenBuffer.Cell cell = buffer.getCell(row, column);
+                    textCanvas.setForegroundColor(resolveForeground(cell));
+                    textCanvas.setBackgroundColor(resolveBackground(cell));
+                    textCanvas.putChar(row, column, cell.ch);
+                }
+
+            if (buffer.cursorVisible) {
+                final ScreenBuffer.Cell cell = buffer.getCell(buffer.cursorY,
+                        buffer.cursorX);
+                textCanvas.setForegroundColor(DEFAULT_BACKGROUND);
+                textCanvas.setBackgroundColor(CURSOR_COLOR);
+                textCanvas.putChar(buffer.cursorY, buffer.cursorX,
+                        cell.ch == ' ' ? ' ' : cell.ch);
+            }
+        }
+    }
+
+    private Color resolveForeground(final ScreenBuffer.Cell cell) {
+        if (cell.reverse)
+            return resolveColor(cell.bg, DEFAULT_BACKGROUND, false);
+        return resolveColor(cell.fg, DEFAULT_FOREGROUND, cell.bold);
+    }
+
+    private Color resolveBackground(final ScreenBuffer.Cell cell) {
+        if (cell.reverse)
+            return resolveColor(cell.fg, DEFAULT_FOREGROUND, cell.bold);
+        return resolveColor(cell.bg, DEFAULT_BACKGROUND, false);
+    }
+
+    private Color resolveColor(final int index, final Color defaultColor,
+                               final boolean bold) {
+        if (index == ScreenBuffer.DEFAULT_COLOR)
+            return defaultColor;
+        if (index < 16) {
+            final int adjusted = (bold && index < 8) ? index + 8 : index;
+            return PALETTE[adjusted];
+        }
+        // 256-color palette: 16-231 color cube, 232-255 grayscale
+        final int i = index - 16;
+        if (i < 216) {
+            final int r = i / 36;
+            final int g = (i / 6) % 6;
+            final int b = i % 6;
+            return new Color(cubeChannel(r), cubeChannel(g), cubeChannel(b));
+        }
+        final int gray = 8 + (i - 216) * 10;
+        return new Color(gray, gray, gray);
+    }
+
+    private static int cubeChannel(final int level) {
+        return level == 0 ? 0 : 55 + level * 40;
+    }
+
+    @Override
+    public boolean keyPressed(final KeyEvent event,
+                              final ViewPanel viewPanel) {
+        // Focus model: Ctrl+ESC releases focus (GuiComponent convention
+        // uses plain ESC, but terminal programs need ESC themselves —
+        // mc uses ESC for dialogs and ESC 0 for F10).
+        if (event.getKeyChar() == '\u001B') {
+            if (event.isControlDown())
+                return super.keyPressed(event, viewPanel);
+            session.send("\u001B");
+            return true;
+        }
+
+        final String sequence = translate(event);
+        if (sequence != null)
+            session.send(sequence);
+        return true;
+    }
+
+    /**
+     * Whether the terminal is in application cursor keys mode (DECCKM).
+     * Curses programs (mc, htop, vim) enable it; cursor keys must then be
+     * reported as SS3 instead of CSI.
+     */
+    public boolean isApplicationCursorKeys() {
+        synchronized (session.getScreenBuffer()) {
+            return session.getScreenBuffer().applicationCursorKeys;
+        }
+    }
+
+    /**
+     * Cursor key sequence honoring application cursor mode.
+     */
+    private String cursorKey(final char csiFinal, final char ss3Final) {
+        return isApplicationCursorKeys()
+                ? "\u001BO" + ss3Final
+                : "\u001B[" + csiFinal;
+    }
+
+    /**
+     * Translates an AWT key event into the byte sequence a terminal would
+     * send. Returns null for keys with no terminal representation.
+     */
+    private String translate(final KeyEvent event) {
+        switch (event.getKeyCode()) {
+            case KeyEvent.VK_ENTER -> {
+                return "\r";
+            }
+            case KeyEvent.VK_BACK_SPACE -> {
+                return "\u007F";
+            }
+            case KeyEvent.VK_TAB -> {
+                // xterm: Shift+Tab is backtab (kcbt)
+                return event.isShiftDown() ? "\u001B[Z" : "\t";
+            }
+            case KeyEvent.VK_UP -> {
+                return cursorKey('A', 'A');
+            }
+            case KeyEvent.VK_DOWN -> {
+                return cursorKey('B', 'B');
+            }
+            case KeyEvent.VK_RIGHT -> {
+                return cursorKey('C', 'C');
+            }
+            case KeyEvent.VK_LEFT -> {
+                return cursorKey('D', 'D');
+            }
+            case KeyEvent.VK_HOME -> {
+                return cursorKey('H', 'H');
+            }
+            case KeyEvent.VK_END -> {
+                return cursorKey('F', 'F');
+            }
+            case KeyEvent.VK_DELETE -> {
+                return "\u001B[3~";
+            }
+            case KeyEvent.VK_PAGE_UP -> {
+                return "\u001B[5~";
+            }
+            case KeyEvent.VK_PAGE_DOWN -> {
+                return "\u001B[6~";
+            }
+            // xterm function keys (mc is unusable without F10 = quit)
+            case KeyEvent.VK_F1 -> {
+                return "\u001BOP";
+            }
+            case KeyEvent.VK_F2 -> {
+                return "\u001BOQ";
+            }
+            case KeyEvent.VK_F3 -> {
+                return "\u001BOR";
+            }
+            case KeyEvent.VK_F4 -> {
+                return "\u001BOS";
+            }
+            case KeyEvent.VK_F5 -> {
+                return "\u001B[15~";
+            }
+            case KeyEvent.VK_F6 -> {
+                return "\u001B[17~";
+            }
+            case KeyEvent.VK_F7 -> {
+                return "\u001B[18~";
+            }
+            case KeyEvent.VK_F8 -> {
+                return "\u001B[19~";
+            }
+            case KeyEvent.VK_F9 -> {
+                return "\u001B[20~";
+            }
+            case KeyEvent.VK_F10 -> {
+                return "\u001B[21~";
+            }
+            case KeyEvent.VK_F11 -> {
+                return "\u001B[23~";
+            }
+            case KeyEvent.VK_F12 -> {
+                return "\u001B[24~";
+            }
+            default -> {
+            }
+        }
+
+        final char keyChar = event.getKeyChar();
+        if (keyChar == KeyEvent.CHAR_UNDEFINED)
+            return null;
+        // control combinations arrive as C0 control characters already
+        if (event.isControlDown() && keyChar < 0x20)
+            return String.valueOf(keyChar);
+        if (keyChar >= 0x20 && keyChar != 0x7F) {
+            // Alt+key is reported as ESC prefix (Meta), like xterm
+            if (event.isAltDown())
+                return "\u001B" + keyChar;
+            return String.valueOf(keyChar);
+        }
+        return null;
+    }
+
+    /**
+     * Programmatic text injection, used by the self-test and future
+     * automation.
+     */
+    public void typeText(final String text) {
+        session.send(text);
+    }
+
+    public PtySession getSession() {
+        return session;
+    }
+
+    /**
+     * Current terminal screen as plain text (attributes stripped).
+     */
+    public String getScreenText() {
+        synchronized (session.getScreenBuffer()) {
+            return session.getScreenBuffer().dumpText();
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/sixth/workspace/Workspace.java b/src/main/java/eu/svjatoslav/sixth/workspace/Workspace.java
new file mode 100644 (file)
index 0000000..d2376b6
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * Sixth spatial computing environment. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.workspace;
+
+import eu.svjatoslav.sixth.bridge.pty.PtySession;
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Rectangle;
+import eu.svjatoslav.sixth.e3d.gui.ViewFrame;
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.e3d.gui.textEditorComponent.LookAndFeel;
+import eu.svjatoslav.sixth.e3d.gui.textEditorComponent.TextEditComponent;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.Grid2D;
+
+import java.io.IOException;
+
+import static eu.svjatoslav.sixth.e3d.geometry.Point3D.point;
+import static eu.svjatoslav.sixth.e3d.renderer.raster.Color.hex;
+
+/**
+ * The Sixth workspace: a persistent 3D world where programs are placeable
+ * objects.
+ *
+ * <p>Current population: one text editor (engine component) and one live
+ * terminal running bash on a real PTY. Click a panel to focus it and type;
+ * ESC releases focus; fly with the engine's standard camera controls.</p>
+ */
+public class Workspace {
+
+    /**
+     * Terminal geometry: 100 x 30 characters.
+     */
+    public static final int TERMINAL_COLUMNS = 100;
+    public static final int TERMINAL_ROWS = 30;
+
+    private final ViewFrame viewFrame;
+    private TerminalPanel terminalPanel;
+
+    public Workspace() {
+        viewFrame = new ViewFrame("Sixth");
+    }
+
+    /**
+     * Builds the scene and shows the window.
+     *
+     * @throws IOException if the terminal PTY cannot be created
+     */
+    public void open() throws IOException {
+        final ViewPanel viewPanel = viewFrame.getViewPanel();
+        final ShapeCollection scene = viewPanel.getRootShapeCollection();
+
+        viewPanel.getCamera().getTransform().set(150, -120, -350, 0,
+                -0.12, 0);
+
+        addGrid(scene);
+        addTextEditor(viewPanel, scene);
+        addTerminal(viewPanel, scene);
+
+        viewPanel.repaintDuringNextViewUpdate();
+    }
+
+    private void addGrid(final ShapeCollection scene) {
+        final Transform transform = Transform.fromAngles(0, 100, 0, 0,
+                Math.PI / 2, 0);
+        final Rectangle rectangle = new Rectangle(2000);
+        final LineAppearance appearance = new LineAppearance(10,
+                hex("00b3ad"));
+        scene.addShape(new Grid2D(transform, rectangle, 10, 10, appearance));
+    }
+
+    private void addTextEditor(final ViewPanel viewPanel,
+                               final ShapeCollection scene) {
+        final TextEditComponent editor = new TextEditComponent(
+                new Transform(point(-700, 0, 300)), viewPanel,
+                new Point2D(400, 240), new LookAndFeel());
+        editor.setText("Sixth workspace\n\n"
+                + "Click a panel to focus it.\n"
+                + "Type into it. ESC releases focus\n"
+                + "(in the terminal: Ctrl+ESC).\n\n"
+                + "The panel on the right is a real\n"
+                + "bash shell running on a PTY.\n"
+                + "Try: ls, mc, htop");
+        scene.addShape(editor);
+    }
+
+    private void addTerminal(final ViewPanel viewPanel,
+                             final ShapeCollection scene) throws IOException {
+        final PtySession session = new PtySession(TERMINAL_COLUMNS,
+                TERMINAL_ROWS);
+        terminalPanel = new TerminalPanel(
+                new Transform(point(100, 0, 300)), viewPanel,
+                new Point2D(TERMINAL_COLUMNS * TextCanvas.FONT_CHAR_WIDTH,
+                        TERMINAL_ROWS * TextCanvas.FONT_CHAR_HEIGHT),
+                session);
+        scene.addShape(terminalPanel);
+    }
+
+    public TerminalPanel getTerminalPanel() {
+        return terminalPanel;
+    }
+
+    /**
+     * Shuts down background processes (the shell).
+     */
+    public void close() {
+        if (terminalPanel != null)
+            terminalPanel.getSession().stop();
+    }
+}