View Javadoc
1   package com.guinetik.hexafun.examples.tui;
2   
3   import java.io.BufferedReader;
4   import java.io.InputStreamReader;
5   
6   /**
7    * Cross-platform terminal utility functions for TUI applications.
8    *
9    * <p>Provides terminal dimension detection on Unix, macOS, and Windows.</p>
10   */
11  public final class HexaTerminal {
12  
13      private HexaTerminal() {}
14  
15      /** Default minimum width for TUI applications */
16      public static final int MIN_WIDTH = 60;
17  
18      /** Default width if detection fails */
19      public static final int DEFAULT_WIDTH = 120;
20  
21      /** Default minimum height */
22      public static final int MIN_HEIGHT = 20;
23  
24      /** Default height if detection fails */
25      public static final int DEFAULT_HEIGHT = 40;
26  
27      /** Cached OS type */
28      private static final boolean IS_WINDOWS = System.getProperty("os.name", "")
29          .toLowerCase().contains("win");
30  
31      /**
32       * Detect the current terminal width.
33       *
34       * @return Terminal width in characters
35       */
36      public static int detectWidth() {
37          return detectWidth(MIN_WIDTH, DEFAULT_WIDTH);
38      }
39  
40      /**
41       * Detect terminal width with custom bounds.
42       *
43       * @param minWidth Minimum width to return
44       * @param defaultWidth Default if detection fails
45       * @return Terminal width in characters
46       */
47      public static int detectWidth(int minWidth, int defaultWidth) {
48          int[] size = detectSize();
49          if (size != null && size[1] > 0) {
50              return Math.max(minWidth, size[1]);
51          }
52          return defaultWidth;
53      }
54  
55      /**
56       * Detect the current terminal height.
57       *
58       * @return Terminal height in rows
59       */
60      public static int detectHeight() {
61          return detectHeight(MIN_HEIGHT, DEFAULT_HEIGHT);
62      }
63  
64      /**
65       * Detect terminal height with custom bounds.
66       *
67       * @param minHeight Minimum height to return
68       * @param defaultHeight Default if detection fails
69       * @return Terminal height in rows
70       */
71      public static int detectHeight(int minHeight, int defaultHeight) {
72          int[] size = detectSize();
73          if (size != null && size[0] > 0) {
74              return Math.max(minHeight, size[0]);
75          }
76          return defaultHeight;
77      }
78  
79      /**
80       * Detect terminal size as [rows, columns].
81       *
82       * @return int[2] with {rows, columns} or null if detection fails
83       */
84      public static int[] detectSize() {
85          return IS_WINDOWS ? detectSizeWindows() : detectSizeUnix();
86      }
87  
88      /**
89       * Unix/macOS/WSL size detection using stty.
90       */
91      private static int[] detectSizeUnix() {
92          // Try stty size (works on Linux, macOS, WSL)
93          try {
94              ProcessBuilder pb = new ProcessBuilder("stty", "size");
95              pb.inheritIO();
96              pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
97              pb.redirectOutput(ProcessBuilder.Redirect.PIPE);
98              pb.redirectError(ProcessBuilder.Redirect.PIPE);
99  
100             Process p = pb.start();
101             String line = new BufferedReader(
102                 new InputStreamReader(p.getInputStream())
103             ).readLine();
104 
105             int exitCode = p.waitFor();
106             if (exitCode == 0 && line != null && !line.isBlank()) {
107                 String[] parts = line.trim().split("\\s+");
108                 if (parts.length >= 2) {
109                     int rows = Integer.parseInt(parts[0]);
110                     int cols = Integer.parseInt(parts[1]);
111                     if (rows > 0 && cols > 0) {
112                         return new int[]{rows, cols};
113                     }
114                 }
115             }
116         } catch (Exception ignored) {}
117 
118         // Fallback: tput
119         try {
120             int cols = runCommandInt("tput", "cols");
121             int rows = runCommandInt("tput", "lines");
122             if (cols > 0 && rows > 0) {
123                 return new int[]{rows, cols};
124             }
125         } catch (Exception ignored) {}
126 
127         // Fallback: environment variables
128         return detectSizeFromEnv();
129     }
130 
131     /**
132      * Windows size detection using PowerShell or MODE.
133      */
134     private static int[] detectSizeWindows() {
135         // Try PowerShell (most reliable on modern Windows)
136         try {
137             ProcessBuilder pb = new ProcessBuilder(
138                 "powershell", "-NoProfile", "-Command",
139                 "[Console]::WindowWidth; [Console]::WindowHeight"
140             );
141             pb.redirectErrorStream(true);
142             Process p = pb.start();
143 
144             BufferedReader reader = new BufferedReader(
145                 new InputStreamReader(p.getInputStream())
146             );
147             String widthLine = reader.readLine();
148             String heightLine = reader.readLine();
149             p.waitFor();
150 
151             if (widthLine != null && heightLine != null) {
152                 int cols = Integer.parseInt(widthLine.trim());
153                 int rows = Integer.parseInt(heightLine.trim());
154                 if (rows > 0 && cols > 0) {
155                     return new int[]{rows, cols};
156                 }
157             }
158         } catch (Exception ignored) {}
159 
160         // Fallback: MODE CON (cmd.exe)
161         try {
162             ProcessBuilder pb = new ProcessBuilder("cmd", "/c", "mode", "con");
163             pb.redirectErrorStream(true);
164             Process p = pb.start();
165 
166             BufferedReader reader = new BufferedReader(
167                 new InputStreamReader(p.getInputStream())
168             );
169 
170             int cols = -1, rows = -1;
171             String line;
172             while ((line = reader.readLine()) != null) {
173                 line = line.toLowerCase();
174                 if (line.contains("columns") || line.contains("cols")) {
175                     cols = extractNumber(line);
176                 } else if (line.contains("lines")) {
177                     rows = extractNumber(line);
178                 }
179             }
180             p.waitFor();
181 
182             if (cols > 0 && rows > 0) {
183                 return new int[]{rows, cols};
184             }
185         } catch (Exception ignored) {}
186 
187         // Fallback: environment variables
188         return detectSizeFromEnv();
189     }
190 
191     /**
192      * Try to get size from environment variables.
193      */
194     private static int[] detectSizeFromEnv() {
195         String colsEnv = System.getenv("COLUMNS");
196         String rowsEnv = System.getenv("LINES");
197         if (colsEnv != null && rowsEnv != null) {
198             try {
199                 int cols = Integer.parseInt(colsEnv.trim());
200                 int rows = Integer.parseInt(rowsEnv.trim());
201                 if (rows > 0 && cols > 0) {
202                     return new int[]{rows, cols};
203                 }
204             } catch (NumberFormatException ignored) {}
205         }
206         return null;
207     }
208 
209     /**
210      * Run a command and return the output as an integer.
211      */
212     private static int runCommandInt(String... cmd) throws Exception {
213         ProcessBuilder pb = new ProcessBuilder(cmd);
214         pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
215         pb.redirectErrorStream(true);
216         Process p = pb.start();
217         String line = new BufferedReader(
218             new InputStreamReader(p.getInputStream())
219         ).readLine();
220         p.waitFor();
221         return line != null ? Integer.parseInt(line.trim()) : -1;
222     }
223 
224     /**
225      * Extract a number from a string like "Columns: 120" or "Lines: 30".
226      */
227     private static int extractNumber(String line) {
228         StringBuilder sb = new StringBuilder();
229         for (char c : line.toCharArray()) {
230             if (Character.isDigit(c)) {
231                 sb.append(c);
232             } else if (sb.length() > 0) {
233                 break; // Stop at first non-digit after finding digits
234             }
235         }
236         try {
237             return sb.length() > 0 ? Integer.parseInt(sb.toString()) : -1;
238         } catch (NumberFormatException e) {
239             return -1;
240         }
241     }
242 
243     /**
244      * Get terminal size as "ROWSxCOLS" string.
245      *
246      * @return Size string like "24x80" or "unknown"
247      */
248     public static String getSizeString() {
249         int[] size = detectSize();
250         if (size != null) {
251             return size[0] + "x" + size[1];
252         }
253         return "unknown";
254     }
255 
256     /**
257      * Check if running on Windows.
258      */
259     public static boolean isWindows() {
260         return IS_WINDOWS;
261     }
262 }