From fb2ab091e5428a17bda68a81cd0091a9e8c44c7b Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Wed, 9 Sep 2026 18:57:54 +0300 Subject: [PATCH] feat(workspace): add terminal panel with PTY and VT100 emulation - 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 | 37 +- pom.xml | 26 +- .../sixth/bridge/pty/PtySession.java | 158 +++++ .../sixth/bridge/pty/ScreenBuffer.java | 574 ++++++++++++++++++ .../svjatoslav/sixth/bridge/pty/UnixPty.java | 235 +++++++ .../sixth/bridge/pty/Vt100Emulator.java | 255 ++++++++ .../java/eu/svjatoslav/sixth/core/Main.java | 117 +++- .../sixth/workspace/TerminalPanel.java | 317 ++++++++++ .../svjatoslav/sixth/workspace/Workspace.java | 114 ++++ 9 files changed, 1815 insertions(+), 18 deletions(-) create mode 100644 src/main/java/eu/svjatoslav/sixth/bridge/pty/PtySession.java create mode 100644 src/main/java/eu/svjatoslav/sixth/bridge/pty/ScreenBuffer.java create mode 100644 src/main/java/eu/svjatoslav/sixth/bridge/pty/UnixPty.java create mode 100644 src/main/java/eu/svjatoslav/sixth/bridge/pty/Vt100Emulator.java create mode 100644 src/main/java/eu/svjatoslav/sixth/workspace/TerminalPanel.java create mode 100644 src/main/java/eu/svjatoslav/sixth/workspace/Workspace.java diff --git a/doc/index.org b/doc/index.org index 3f8c060..1c97181 100644 --- a/doc/index.org +++ b/doc/index.org @@ -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 --- a/pom.xml +++ b/pom.xml @@ -5,11 +5,13 @@ sixth 1.0-SNAPSHOT Sixth - Distributed data storage, analyze, computation and visualization platform + Spatial computing environment UTF-8 UTF-8 + 21 + 21 @@ -23,20 +25,19 @@ eu.svjatoslav sixth-3d - 1.2-SNAPSHOT + 1.5-SNAPSHOT - eu.svjatoslav - svjatoslavcommons - 1.8-SNAPSHOT + net.java.dev.jna + jna + 5.14.0 eu.svjatoslav - javainspect - 1.5 - test + svjatoslavcommons + 1.8 @@ -45,11 +46,10 @@ org.apache.maven.plugins maven-compiler-plugin - 2.3.2 + 3.13.0 - 1.8 - 1.8 - true + 21 + 21 UTF-8 @@ -111,7 +111,7 @@ svjatoslav.eu Svjatoslav repository - https://www2.svjatoslav.eu/maven/ + https://www3.svjatoslav.eu/maven/ 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 index 0000000..30ec7a2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/bridge/pty/PtySession.java @@ -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}. + * + *

Engine-independent: this class knows nothing about the 3D world. + * Rendering and keyboard delivery are the caller's job (see + * {@code workspace.TerminalPanel}).

+ * + *

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.

+ */ +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 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. + * + *

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.

+ */ + 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 index 0000000..03a5e9b --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/bridge/pty/ScreenBuffer.java @@ -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. + * + *

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.

+ * + *

All public methods must be called while holding {@code synchronized} + * on this instance (the emulator and the renderer both access it).

+ */ +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 index 0000000..7ef4b70 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/bridge/pty/UnixPty.java @@ -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. + * + *

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.

+ * + *

Linux-only by design; the workspace targets Linux.

+ */ +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. + * + *

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.

+ * + * @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 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 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. + * + *

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.

+ */ + private static final List 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 index 0000000..b45331b --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/bridge/pty/Vt100Emulator.java @@ -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}. + * + *

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.

+ * + *

Not a full xterm: no scrollback, no mouse reporting, no 8-bit C1. + * Unknown sequences are consumed and ignored rather than mishandled.

+ * + *

Threading: all calls arrive on the PTY reader thread; the emulator + * synchronizes on the buffer for every operation.

+ */ +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); + } + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/core/Main.java b/src/main/java/eu/svjatoslav/sixth/core/Main.java index 04791ec..64d0504 100644 --- a/src/main/java/eu/svjatoslav/sixth/core/Main.java +++ b/src/main/java/eu/svjatoslav/sixth/core/Main.java @@ -1,11 +1,122 @@ /* - * 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. + * + *

Usage: {@code sixth [--selftest]}

+ * + *

{@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.

+ */ 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 index 0000000..71a9f2f --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/workspace/TerminalPanel.java @@ -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}. + * + *

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).

+ * + *

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.

+ */ +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 index 0000000..d2376b6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/workspace/Workspace.java @@ -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. + * + *

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.

+ */ +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(); + } +} -- 2.20.1