: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
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
<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>
--- /dev/null
+/*
+ * 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();
+ }
+ }
+}
--- /dev/null
+/*
+ * 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();
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
/*
- * 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;
+ }
}
--- /dev/null
+/*
+ * 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();
+ }
+ }
+}
--- /dev/null
+/*
+ * 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();
+ }
+}