/* * $Id$ * * Copyright (C) 2003-2015 JNode.org * * This library is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as published * by the Free Software Foundation; either version 2.1 of the License, or * (at your option) any later version. * * This library is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public * License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this library; If not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ package org.jnode.command.system; import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; import java.io.PipedReader; import java.io.PipedWriter; import java.io.PrintWriter; import java.io.Reader; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import javax.naming.NameNotFoundException; import org.jnode.driver.console.ConsoleManager; import org.jnode.driver.console.TextConsole; import org.jnode.driver.console.textscreen.TextScreenConsoleManager; import org.jnode.driver.input.KeyboardEvent; import org.jnode.driver.input.KeyboardListener; import org.jnode.naming.InitialNaming; import org.jnode.shell.AbstractCommand; import org.jnode.shell.ShellManager; import org.jnode.shell.syntax.Argument; import org.jnode.shell.syntax.FileArgument; /** * This command is a simple analog of the UNIX/Linux 'more' and 'less' commands. * * @author crawley@jnode.org */ public class PageCommand extends AbstractCommand implements KeyboardListener { private static final String help_file = "the file to be paged"; private static final String help_super = "output a file to the console one 'page' at a time"; private static final String str_no_pipe = "Paging piped from the console is not supported"; private static final String str_no_prev = "No previous search"; private static final String str_inv_regex = "Invalid regex"; private static final String str_not_found = "Not found"; private static final String str_any_key = "Hit any key to continue"; private static final String help_fw_page = "Move forwards 1 page: SPACE, f"; private static final String help_bw_page = "Move backwards 1 page: b"; private static final String help_fw_dline = "Move forwards 1 data line: ENTER"; private static final String help_bw_dline = "Move backwards 1 data line: k, y"; private static final String help_fw_sline = "Move forwards 1 screen line: d"; private static final String help_bw_sline = "Move backwards 1 screen line: u"; private static final String help_go_start = "Go to start of data: <"; private static final String help_go_end = "Go to end of data: >"; private static final String help_fw_search1 = "Search forwards: /regex"; private static final String help_fw_search2 = "Repeat search forwards: /"; private static final String help_bw_search1 = "Search backwards: ?regex"; private static final String help_bw_search2 = "Repeat search backwards: ?"; private static final String help_help = "Display this help screen: h"; private static final String help_quit = "Quit: q, CTRL-D"; private static boolean DEBUG = false; private static int LAST_SUBLINE = Integer.MAX_VALUE; private static int DEFAULT_COLOR = 0x07; private static int MATCH_COLOR = 0x04; private final FileArgument argFile; private PrintWriter err; private TextConsole console; private int pageHeight; private int pageWidth; private int pageSize; private int tabSize; // This is the line number of the top (data source) line displayed on the // screen page. private int topLineNo; private int topSublineNo; // This is the line number of the bottom (data source) line displayed on the // screen page. private int bottomLineNo; private int bottomSublineNo; // This pipe passes characters from the system thread that calls // our 'keyPressed' event method to the thread that runs the Page command. private PipedReader pr; private PipedWriter pw; private TextScreenConsoleManager manager; private LineStore lineStore; private Pattern regex; private Matcher matcher; private String prompt; private ScreenBuffer currentBuffer; public PageCommand() { super(help_super); argFile = new FileArgument("file", Argument.OPTIONAL, help_file); registerArguments(argFile); } /** * Classic java entry point */ public static void main(String[] args) throws Exception { new PageCommand().execute(args); } /** * JNode command entry point. */ @Override public void execute() throws Exception { err = getError().getPrintWriter(); Reader r = null; boolean opened = false; try { if (argFile.isSet()) { r = new FileReader(argFile.getValue()); opened = true; } else if (getInput().isTTY()) { // We cannot do this. We need to use the console as the // source of command characters for the Page command. debugln(str_no_pipe); exit(1); } else { r = getInput().getReader(); } setup(); lineStore = new LineStore(r); pager(); } catch (IOException ex) { debugln(ex.getMessage()); exit(1); } finally { if (r != null && opened) { try { r.close(); } catch (IOException ex) { // ignore } } tearDown(); } } /** * Set up the pager's console and command pipe. */ private void setup() throws NameNotFoundException, IOException { ShellManager sm = InitialNaming.lookup(ShellManager.NAME); manager = (TextScreenConsoleManager) sm.getCurrentShell().getConsole().getManager(); console = manager.createConsole( "page", (ConsoleManager.CreateOptions.TEXT | ConsoleManager.CreateOptions.STACKED | ConsoleManager.CreateOptions.NO_LINE_EDITTING | ConsoleManager.CreateOptions.NO_SYSTEM_OUT_ERR)); manager.focus(console); pageHeight = console.getDeviceHeight() - 1; pageWidth = console.getDeviceWidth(); pageSize = pageHeight * pageWidth; tabSize = console.getTabSize(); pw = new PipedWriter(); pr = new PipedReader(); pr.connect(pw); console.addKeyboardListener(this); } /** * Tear down the console and pipe. * @throws IOException */ private void tearDown() throws IOException { if (manager != null && console != null) { manager.unregisterConsole(console); } if (pw != null) { pw.close(); } if (pr != null) { pr.close(); } } /** * Do the paging, reading commands from our private console input * pipe to figure out what to do next. * * @param r the source of data to be paged. * @throws IOException */ private void pager() throws IOException { // Output first page. console.clear(); bottomLineNo = -1; boolean exit = false; nextPage(); // Process commands until we reach the EOF on the data source or // the command pipe. while (!exit) { prompt(); int ch = pr.read(); erasePrompt(); switch (ch) { case -1: exit = true; break; case ' ': case 'f': if (lineStore.isLastLineNo(bottomLineNo)) { exit = true; } else { nextPage(); } break; case 'b': prevPage(); break; case 'k': case 'y': prevLine(); break; case '\n': if (lineStore.isLastLineNo(bottomLineNo)) { exit = true; } else { nextLine(); } break; case 'u': prevScreenLine(); break; case 'd': nextScreenLine(); break; case '<': gotoPage(0); break; case '>': gotoLastPage(); break; case '/': searchForwards(); break; case '?': searchBackwards(); break; case '\004': // ^D case 'q': exit = true; break; case 'h': help(); default: // ignore } } } private void searchBackwards() throws IOException { String input = readLine('?'); int lineNo = bottomLineNo; if (input.length() <= 1) { if (regex == null) { setPrompt(str_no_prev); return; } lineNo--; } else { try { regex = Pattern.compile(input.substring(1)); } catch (PatternSyntaxException ex) { setPrompt(str_inv_regex); return; } matcher = regex.matcher(""); } while (true) { String line = lineStore.getLine(lineNo); if (line == null) { gotoPage(0); setPrompt(str_not_found); return; } matcher.reset(line); if (matcher.find()) { prepareReverse(lineNo, LAST_SUBLINE).output(); return; } lineNo--; } } private void searchForwards() throws IOException { String input = readLine('/'); int lineNo = topLineNo; if (input.length() <= 1) { if (regex == null) { setPrompt(str_no_prev); return; } lineNo++; } else { try { regex = Pattern.compile(input.substring(1)); } catch (PatternSyntaxException ex) { setPrompt(str_inv_regex); return; } matcher = regex.matcher(""); } while (true) { String line = lineStore.getLine(lineNo); if (line == null) { gotoLastPage(); setPrompt(str_not_found); return; } matcher.reset(line); if (matcher.find()) { prepare(lineNo, 0).output(); return; } lineNo++; } } /** * Read a line up to the next newline and return it as a String. The * line is echoed at the prompt location. * * @param ch a preread character. * @return * @throws IOException */ private String readLine(int ch) throws IOException { StringBuffer sb = new StringBuffer(); String line; do { sb.append((char) ch); line = sb.toString(); prompt(line); ch = pr.read(); } while (ch != -1 && ch != '\n'); return line; } private void help() throws IOException { String[] help = new String[] { help_fw_page, help_bw_page, help_fw_dline, help_bw_dline, help_fw_sline, help_bw_sline, help_go_start, help_go_end, help_fw_search1, help_fw_search2, help_bw_search1, help_bw_search2, help_help, help_quit }; // Remember the 'current' buffer so that we can repaint it // when we are done. ScreenBuffer prevBuffer = this.currentBuffer; // Prepare and paint the help screen ScreenBuffer buffer = new ScreenBuffer(true); for (int i = 0; i < help.length; i++) { prepareLine(help[i], i, buffer); } buffer.adjust(0, 0); buffer.output(); prompt(str_any_key); // Wait till the user is done, then repaint the previous screen. pr.read(); prompt(); prevBuffer.output(); } private void prompt() { if (prompt == null) { prompt(lineStore.isLastLineNo(bottomLineNo) ? "(END)" : (topLineNo + ", " + bottomLineNo + ": ")); } else { prompt(prompt); prompt = null; } } private void prompt(String text) { console.clearRow(this.pageHeight); console.setChar(0, this.pageHeight, text.toCharArray(), DEFAULT_COLOR); console.setCursor(0, this.pageHeight); } private void setPrompt(String prompt) { this.prompt = prompt; } private void erasePrompt() { console.clearRow(this.pageHeight); } /** * Page forward by one page. The implementation strategy is to 'prepare' * lines one at a time until we have got (at least) a whole screen full. * The preparation process involves copying characters to 'buffer', padding * lines with spaces, and doing BS and TAB interpretation. Then we output * the prepared characters to the screen. The 'output' process deals with * scrolling if we prepared less than a full screen, or truncation if we * prepared more than a full screen. * * @throws IOException */ private void nextPage() throws IOException { prepare(bottomLineNo + 1, 0).output(); } /** * Page backwards by one page. The implementation strategy is similar to * page forward except that we use prepareReverse which paints lines starting * with the nominated line and working backwards. * * @throws IOException */ private void prevPage() throws IOException { if (topLineNo > 0) { prepareReverse(topLineNo - 1, LAST_SUBLINE).output(); } } private void gotoPage(int firstLineNo) throws IOException { prepare(firstLineNo, 0).output(); } private void gotoLastPage() throws IOException { prepareReverse(lineStore.getLastLineNo(), LAST_SUBLINE).output(); } /** * Page forward by one line. We use the 'prepare' and 'output' * strategy as described in {@link nextPage}. * * @throws IOException */ private void nextLine() throws IOException { prepare(topLineNo + 1, 0).output(); } /** * Page backwards by one line. We use the 'prepare' and 'output' * strategy as described in {@link nextPage}. * * @throws IOException */ private void prevLine() throws IOException { prepareReverse(bottomLineNo - 1, LAST_SUBLINE).output(); } private void nextScreenLine() throws IOException { prepare(topLineNo, topSublineNo + 1).output(); } private void prevScreenLine() throws IOException { if (bottomSublineNo == 0) { prepareReverse(bottomLineNo - 1, LAST_SUBLINE).output(); } else { prepareReverse(bottomLineNo, bottomSublineNo + 1).output(); } } /** * Prepare lines for output by painting them to our private buffer in the forward * direction starting at a given line number and (rendered) subline number. * * @param startLineNo */ private ScreenBuffer prepare(int startLineNo, int startSublineNo) { while (true) { ScreenBuffer buffer = new ScreenBuffer(true); int lineNo = startLineNo; boolean more; do { String line = lineStore.getLine(lineNo); if (line == null) { break; } more = prepareLine(line, lineNo, buffer); lineNo++; } while (more); if (buffer.adjust(startLineNo, startSublineNo) || startLineNo == 0) { return buffer; } else { startLineNo -= 1; startSublineNo = LAST_SUBLINE; } } } /** * * Prepare lines for output by painting them to our private buffer in the reverse * direction starting at a given end line number and (rendered) subline number. */ private ScreenBuffer prepareReverse(int endLineNo, int endSublineNo) { ScreenBuffer buffer = new ScreenBuffer(false); int lineNo = endLineNo; String line = null; boolean more = true; while (more && lineNo >= 0) { line = lineStore.getLine(lineNo); more = prepareLine(line, lineNo, buffer); lineNo--; } if (buffer.adjust(endLineNo, endSublineNo)) { return buffer; } else { return prepare(0, 0); } } /** * Prepare lines for output by painting them to our private buffer * starting at the supplied bufferLineOffset * * @param line the line to be prepared * @param lineNo the line's line number * @param buffer the ScreenBuffer we are preparing */ private boolean prepareLine(String line, int lineNo, ScreenBuffer buffer) { buffer.startLine(lineNo); int pos = 0; int len = line.length(); int startMatchPos = len; int endMatchPos = len; if (matcher != null) { matcher.reset(line); if (matcher.find(0)) { startMatchPos = matcher.start(); endMatchPos = matcher.end(); } } for (int i = 0; i < len; i++) { if (i == startMatchPos) { buffer.setColor(MATCH_COLOR); } else if (i == endMatchPos) { if (matcher.find(i)) { startMatchPos = matcher.start(); endMatchPos = matcher.end(); if (startMatchPos > i) { buffer.setColor(DEFAULT_COLOR); } } else { buffer.setColor(DEFAULT_COLOR); } } // FIXME - support different renderings, including ones where // control characters are rendered as visible characters? char ch = line.charAt(i); switch (ch) { case '\n': throw new AssertionError("no newlines expected"); case '\r': // ignore bare CRs. break; case '\t': int fill = tabSize - pos % pageWidth % tabSize; for (int j = 0; j < fill; j++) { buffer.putChar(' '); } pos += fill; break; default: if (ch >= ' ' && ch <= '\377' && ch != '\177') { buffer.putChar(ch); } else { buffer.putChar('?'); } pos++; } } buffer.setColor(DEFAULT_COLOR); buffer.endLine(); return !buffer.isComplete(); } /** * Capture keyboard input events and write all character data to the * private pipe. The command thread will read them from * the other end as required. */ @Override public void keyPressed(KeyboardEvent event) { if (!event.isConsumed()) { char ch = event.getKeyChar(); if (ch != KeyboardEvent.NO_CHAR) { try { pw.write(ch); pw.flush(); } catch (IOException ex) { // ignore it } event.consume(); } } } @Override public void keyReleased(KeyboardEvent event) { // ignore } private void debugln(String msg) { if (DEBUG) { err.println(msg); } } /** * This class provides an in-memory buffer for lines read from the data source * being paged. In the future, this could be enhanced to cut down on memory * usage. When paging a seekable Reader, it could use seek/tell to record file * offsets rather than actual file lines. When paging a non-seekable Reader * (e.g. a pipe) it could store the lines read from the reader in a temporary file. * * @author crawley@jnode.org */ private final class LineStore { private final BufferedReader reader; private boolean reachedEOF; private List<String> lines = new ArrayList<String>(100); private LineStore(Reader reader) { this.reader = new BufferedReader(reader); } /** * Get a line identified by line number. * @param lineNo the line number * @return the requested line, or <code>null</code> if the EOF * was reached before the requested line could be reached. */ private String getLine(int lineNo) { if (lineNo < 0) { return null; } else if (lineNo < lines.size()) { return lines.get(lineNo); } else if (lineNo > lines.size()) { throw new AssertionError( "Reading at wrong place (" + lineNo + " > " + lines.size() + ")"); } else if (reachedEOF) { return null; } else { try { String line = reader.readLine(); if (line == null) { reachedEOF = true; } else { lines.add(line); } return line; } catch (IOException ex) { ex.printStackTrace(err); reachedEOF = true; return null; } } } /** * Get the last line number for the data source. This requires that * all lines of the data source are read up to the EOF position. * @return the last line number * @throws IOException */ private int getLastLineNo() throws IOException { while (!reachedEOF) { String line = reader.readLine(); if (line == null) { reachedEOF = true; } else { lines.add(line); } } return lines.size() - 1; } /** * Check if a given line number is known to be the last line * of the data source. This method does not do any reading * ahead of the data source to find the last line. * @param lineNo the line number to test * @return Returns <code>true</code> if the given line number * is known to be the last line, and <code>false</code> if it is * not, or if we don't know. */ private boolean isLastLineNo(int lineNo) { return reachedEOF && lineNo == lines.size() - 1; } } /** * The ScreenBuffer class holds the screen image that we are building. * It takes care of wrapping long lines over multiple screen lines. * <p> * In the javadoc and embedded comments, a 'line' refers to an arbitrarily * long data source line, and a 'subline' refers to a screen line. Sublines * are 'pageWidth' characters wide and are filled with SP characters when * completed. * * @author crawley@jnode.org */ private final class ScreenBuffer { private final class ScreenLine { private final char[] chars = new char[pageWidth]; private final int[] colors = new int[pageWidth]; private final int lineNo; private final int sublineNo; private ScreenLine(int lineNo, int sublineNo) { this.lineNo = lineNo; this.sublineNo = sublineNo; } } ArrayList<ScreenLine> lines = new ArrayList<ScreenLine>(); // The direction of filling ... private final boolean forwards; // The current color. private int color; // The character pos in the current subline private int charPos; // The current subline no private int linePos; // The current data source line number private int lineNo; private int sublineNo; private int firstLinePos; private int lastLinePos; ScreenBuffer(boolean forwards) { this.forwards = forwards; this.linePos = 0; this.color = DEFAULT_COLOR; } void setColor(int color) { this.color = color; } /** * Start a new line. If we are filling forwards, this will be * after the last subline of the current line. If we are filling * backwards it will be before first subline of the current line. * It is an error to start a new line when the buffer is full, so * this method won't grow the buffer. * * @param lineNo the line number of the datastream line we are starting. */ void startLine(int lineNo) throws IllegalArgumentException, IllegalStateException { if (lineNo < 0) { throw new IllegalArgumentException("lineNo < 0"); } // Record current line number and allocate the first screen line. this.lineNo = lineNo; this.sublineNo = 0; lines.add(linePos, new ScreenLine(lineNo, 0)); charPos = 0; } /** * End the current line. This fills the remainder of the subline, then * moves 'linePos' to the position for the next line. */ void endLine() { // Fill to the end of the current subline with a spaces ScreenLine line = lines.get(linePos); while (charPos < pageWidth) { line.chars[charPos] = ' '; line.colors[charPos] = color; charPos++; } // Move 'linepos' to the position for the next line. if (forwards) { linePos++; } else { while (linePos > 0 && lines.get(linePos - 1).lineNo == lineNo) { linePos--; } } } /** * Put a character to the current line, allocating a new subline * if we wrap past 'pageWidth'. * * @param ch */ void putChar(char ch) { if (charPos >= pageWidth) { newSubline(); charPos = 0; } ScreenLine line = lines.get(linePos); line.chars[charPos] = ch; line.colors[charPos] = color; charPos++; } /** * Add a new subline for the current line. The subline * goes into the buffer after the current subline. If * we are filling forward, the current subline stays where it is. * If we are filling backwards, other sublines in the current line * are moves 'upwards' to make space. */ private void newSubline() { sublineNo++; linePos++; lines.add(linePos, new ScreenLine(lineNo, sublineNo)); } /** * This method calculates the adjusted start/end linePos values corresponding * to the supplied lineNo/sublineNo and the opposite end of the screen buffer. * Then it resets the top/bottom lineNo and sublineNo fields. * * @param lineNo * @param sublineNo */ boolean adjust(final int lineNo, final int sublineNo) { debugln(lineNo + ", " + sublineNo); debugln(topLineNo + ", " + topSublineNo + ", " + bottomLineNo + ", " + bottomSublineNo); int linePos; int len = lines.size(); if (len == 0) { firstLinePos = 0; lastLinePos = -1; topLineNo = 0; topSublineNo = 0; bottomLineNo = -1; bottomSublineNo = 0; } else { for (linePos = 0; linePos < len - 1; linePos++) { ScreenLine line = lines.get(linePos); if (line.lineNo == lineNo && line.sublineNo == sublineNo) { break; } if (line.lineNo > lineNo) { linePos--; break; } } debugln(linePos + " : " + len); firstLinePos = forwards ? linePos : Math.max(0, linePos - pageHeight + 1); lastLinePos = forwards ? Math.min(len, linePos + pageHeight) - 1 : linePos; if (lastLinePos >= len) { firstLinePos = Math.max(0, firstLinePos - (len - lastLinePos)); lastLinePos = len - 1; } debugln(firstLinePos + ", " + lastLinePos); ScreenLine topLine = lines.get(firstLinePos); topLineNo = topLine.lineNo; topSublineNo = topLine.sublineNo; ScreenLine bottomLine = lines.get(lastLinePos); bottomLineNo = bottomLine.lineNo; bottomSublineNo = bottomLine.sublineNo; } debugln(topLineNo + ", " + topSublineNo + ", " + bottomLineNo + ", " + bottomSublineNo); return lastLinePos - firstLinePos == (pageHeight - 1); } /** * Test if the buffer is 'full'; i.e. if 'pageHeight' lines * have been populated. This should only be called after * we have called {@line #endLine()}. * * @return */ boolean isComplete() { if (charPos != pageWidth) { throw new IllegalStateException( "line is still active (" + charPos + ", " + pageWidth + ")"); } // Since we've called endLine(), linePos should be at the point where // the next line will be added to the buffer. if (forwards) { return linePos >= pageHeight; } else { return lines.size() - linePos - 1 >= pageHeight; } } /** * Output the buffer to the screen. When we're done, we make this buffer * the 'current' buffer. */ void output() { // This is probably the best I can do given the current console APIs. The // problem are: // 1) there is no 'console.setChar(x, y, chars, colors, x, 1)' method, and // 2) a call to setChar(...) will sync the screen, which currently repaints // every character to the screen device. // First we build a single char array for all characters on the screen, populate // from the lines, and pad out with spaces to the screen height. char[] tmp = new char[pageSize]; debugln("output: " + firstLinePos + ", " + lastLinePos); for (int y = firstLinePos; y <= lastLinePos; y++) { ScreenLine line = lines.get(y); System.arraycopy(line.chars, 0, tmp, (y - firstLinePos) * pageWidth, pageWidth); } Arrays.fill(tmp, (lastLinePos - firstLinePos + 1) * pageWidth, pageSize, ' '); // Next, output the characters in the default color console.setChar(0, 0, tmp, 0, pageSize, 0x7); // Finally, go back and repaint any characters that have a different color // to the default. We do this in runs, to avoid doing too many screen syncs. int color = DEFAULT_COLOR; int colorStartX = -1; int colorStartY = -1; int colorStartPos = -1; for (int y = firstLinePos; y <= lastLinePos; y++) { ScreenLine line = lines.get(y); int[] colors = line.colors; for (int x = 0; x < pageWidth; x++) { if (colors[x] != color) { if (color != DEFAULT_COLOR) { int pos = x + y * pageWidth; console.setChar(colorStartX, colorStartY, tmp, colorStartPos, pos - colorStartPos, color); } else { colorStartX = x; colorStartY = y; colorStartPos = colorStartX + colorStartY * pageWidth; } color = colors[x]; } } if (color != DEFAULT_COLOR) { console.setChar(colorStartX, colorStartY, tmp, colorStartPos, pageSize - colorStartPos, color); } } currentBuffer = this; } } }