// // getline.cs: A command line editor // // Authors: // Miguel de Icaza (miguel@novell.com) // // Copyright 2008 Novell, Inc. // // Dual-licensed under the terms of the MIT X11 license or the // Apache License 2.0 // // USE -define:DEMO to build this as a standalone file and test it // // TODO: // Enter an error (a = 1); Notice how the prompt is in the wrong line // This is caused by Stderr not being tracked by System.Console. // Completion support // Why is Thread.Interrupt not working? Currently I resort to Abort which is too much. // // Limitations in System.Console: // Console needs SIGWINCH support of some sort // Console needs a way of updating its position after things have been written // behind its back (P/Invoke puts for example). // System.Console needs to get the DELETE character, and report accordingly. // using System; using System.Text; using System.IO; using System.Threading; using System.Reflection; namespace Mono.Terminal { public class LineEditor { public class Completion { public string [] Result; public string Prefix; public Completion (string prefix, string [] result) { Prefix = prefix; Result = result; } } public delegate Completion AutoCompleteHandler (string text, int pos); //static StreamWriter log; // The text being edited. StringBuilder text; // The text as it is rendered (replaces (char)1 with ^A on display for example). StringBuilder rendered_text; // The prompt specified, and the prompt shown to the user. string prompt; string shown_prompt; // The current cursor position, indexes into "text", for an index // into rendered_text, use TextToRenderPos int cursor; // The row where we started displaying data. int home_row; // The maximum length that has been displayed on the screen int max_rendered; // If we are done editing, this breaks the interactive loop bool done = false; // The thread where the Editing started taking place Thread edit_thread; // Our object that tracks history History history; // The contents of the kill buffer (cut/paste in Emacs parlance) string kill_buffer = ""; // The string being searched for string search; string last_search; // whether we are searching (-1= reverse; 0 = no; 1 = forward) int searching; // The position where we found the match. int match_at; // Used to implement the Kill semantics (multiple Alt-Ds accumulate) KeyHandler last_handler; delegate void KeyHandler (); struct Handler { public ConsoleKeyInfo CKI; public KeyHandler KeyHandler; public Handler (ConsoleKey key, KeyHandler h) { CKI = new ConsoleKeyInfo ((char) 0, key, false, false, false); KeyHandler = h; } public Handler (char c, KeyHandler h) { KeyHandler = h; // Use the "Zoom" as a flag that we only have a character. CKI = new ConsoleKeyInfo (c, ConsoleKey.Zoom, false, false, false); } public Handler (ConsoleKeyInfo cki, KeyHandler h) { CKI = cki; KeyHandler = h; } public static Handler Control (char c, KeyHandler h) { return new Handler ((char) (c - 'A' + 1), h); } public static Handler Alt (char c, ConsoleKey k, KeyHandler h) { ConsoleKeyInfo cki = new ConsoleKeyInfo ((char) c, k, false, true, false); return new Handler (cki, h); } } /// /// Invoked when the user requests auto-completion using the tab character /// /// /// The result is null for no values found, an array with a single /// string, in that case the string should be the text to be inserted /// for example if the word at pos is "T", the result for a completion /// of "ToString" should be "oString", not "ToString". /// /// When there are multiple results, the result should be the full /// text /// public AutoCompleteHandler AutoCompleteEvent; static Handler [] handlers; public LineEditor (string name) : this (name, 10) { } public LineEditor (string name, int histsize) { handlers = new Handler [] { new Handler (ConsoleKey.Home, CmdHome), new Handler (ConsoleKey.End, CmdEnd), new Handler (ConsoleKey.LeftArrow, CmdLeft), new Handler (ConsoleKey.RightArrow, CmdRight), new Handler (ConsoleKey.UpArrow, CmdHistoryPrev), new Handler (ConsoleKey.DownArrow, CmdHistoryNext), new Handler (ConsoleKey.Enter, CmdDone), new Handler (ConsoleKey.Backspace, CmdBackspace), new Handler (ConsoleKey.Delete, CmdDeleteChar), new Handler (ConsoleKey.Tab, CmdTabOrComplete), // Emacs keys Handler.Control ('A', CmdHome), Handler.Control ('E', CmdEnd), Handler.Control ('B', CmdLeft), Handler.Control ('F', CmdRight), Handler.Control ('P', CmdHistoryPrev), Handler.Control ('N', CmdHistoryNext), Handler.Control ('K', CmdKillToEOF), Handler.Control ('Y', CmdYank), Handler.Control ('D', CmdDeleteChar), Handler.Control ('L', CmdRefresh), Handler.Control ('R', CmdReverseSearch), Handler.Control ('G', delegate {} ), Handler.Alt ('B', ConsoleKey.B, CmdBackwardWord), Handler.Alt ('F', ConsoleKey.F, CmdForwardWord), Handler.Alt ('D', ConsoleKey.D, CmdDeleteWord), Handler.Alt ((char) 8, ConsoleKey.Backspace, CmdDeleteBackword), // DEBUG //Handler.Control ('T', CmdDebug), // quote Handler.Control ('Q', delegate { HandleChar (Console.ReadKey (true).KeyChar); }) }; rendered_text = new StringBuilder (); text = new StringBuilder (); history = new History (name, histsize); //if (File.Exists ("log"))File.Delete ("log"); //log = File.CreateText ("log"); } void CmdDebug () { history.Dump (); Console.WriteLine (); Render (); } void Render () { Console.Write (shown_prompt); Console.Write (rendered_text); int max = System.Math.Max (rendered_text.Length + shown_prompt.Length, max_rendered); for (int i = rendered_text.Length + shown_prompt.Length; i < max_rendered; i++) Console.Write (' '); max_rendered = shown_prompt.Length + rendered_text.Length; // Write one more to ensure that we always wrap around properly if we are at the // end of a line. Console.Write (' '); UpdateHomeRow (max); } void UpdateHomeRow (int screenpos) { int lines = 1 + (screenpos / Console.WindowWidth); home_row = Console.CursorTop - (lines - 1); if (home_row < 0) home_row = 0; } void RenderFrom (int pos) { int rpos = TextToRenderPos (pos); int i; for (i = rpos; i < rendered_text.Length; i++) Console.Write (rendered_text [i]); if ((shown_prompt.Length + rendered_text.Length) > max_rendered) max_rendered = shown_prompt.Length + rendered_text.Length; else { int max_extra = max_rendered - shown_prompt.Length; for (; i < max_extra; i++) Console.Write (' '); } } void ComputeRendered () { rendered_text.Length = 0; for (int i = 0; i < text.Length; i++){ int c = (int) text [i]; if (c < 26){ if (c == '\t') rendered_text.Append (" "); else { rendered_text.Append ('^'); rendered_text.Append ((char) (c + (int) 'A' - 1)); } } else rendered_text.Append ((char)c); } } int TextToRenderPos (int pos) { int p = 0; for (int i = 0; i < pos; i++){ int c; c = (int) text [i]; if (c < 26){ if (c == 9) p += 4; else p += 2; } else p++; } return p; } int TextToScreenPos (int pos) { return shown_prompt.Length + TextToRenderPos (pos); } string Prompt { get { return prompt; } set { prompt = value; } } int LineCount { get { return (shown_prompt.Length + rendered_text.Length)/Console.WindowWidth; } } void ForceCursor (int newpos) { cursor = newpos; int actual_pos = shown_prompt.Length + TextToRenderPos (cursor); int row = home_row + (actual_pos/Console.WindowWidth); int col = actual_pos % Console.WindowWidth; if (row >= Console.BufferHeight) row = Console.BufferHeight-1; Console.SetCursorPosition (col, row); //log.WriteLine ("Going to cursor={0} row={1} col={2} actual={3} prompt={4} ttr={5} old={6}", newpos, row, col, actual_pos, prompt.Length, TextToRenderPos (cursor), cursor); //log.Flush (); } void UpdateCursor (int newpos) { if (cursor == newpos) return; ForceCursor (newpos); } void InsertChar (char c) { int prev_lines = LineCount; text = text.Insert (cursor, c); ComputeRendered (); if (prev_lines != LineCount){ Console.SetCursorPosition (0, home_row); Render (); ForceCursor (++cursor); } else { RenderFrom (cursor); ForceCursor (++cursor); UpdateHomeRow (TextToScreenPos (cursor)); } } // // Commands // void CmdDone () { done = true; } void CmdTabOrComplete () { bool complete = false; if (AutoCompleteEvent != null){ if (TabAtStartCompletes) complete = true; else { for (int i = 0; i < cursor; i++){ if (!Char.IsWhiteSpace (text [i])){ complete = true; break; } } } if (complete){ Completion completion = AutoCompleteEvent (text.ToString (), cursor); string [] completions = completion.Result; if (completions == null) return; int ncompletions = completions.Length; if (ncompletions == 0) return; if (completions.Length == 1){ InsertTextAtCursor (completions [0]); } else { int last = -1; for (int p = 0; p < completions [0].Length; p++){ char c = completions [0][p]; for (int i = 1; i < ncompletions; i++){ if (completions [i].Length < p) goto mismatch; if (completions [i][p] != c){ goto mismatch; } } last = p; } mismatch: if (last != -1){ InsertTextAtCursor (completions [0].Substring (0, last+1)); } Console.WriteLine (); foreach (string s in completions){ Console.Write (completion.Prefix); Console.Write (s); Console.Write (' '); } Console.WriteLine (); Render (); ForceCursor (cursor); } } else HandleChar ('\t'); } else HandleChar ('t'); } void CmdHome () { UpdateCursor (0); } void CmdEnd () { UpdateCursor (text.Length); } void CmdLeft () { if (cursor == 0) return; UpdateCursor (cursor-1); } void CmdBackwardWord () { int p = WordBackward (cursor); if (p == -1) return; UpdateCursor (p); } void CmdForwardWord () { int p = WordForward (cursor); if (p == -1) return; UpdateCursor (p); } void CmdRight () { if (cursor == text.Length) return; UpdateCursor (cursor+1); } void RenderAfter (int p) { ForceCursor (p); RenderFrom (p); ForceCursor (cursor); } void CmdBackspace () { if (cursor == 0) return; text.Remove (--cursor, 1); ComputeRendered (); RenderAfter (cursor); } void CmdDeleteChar () { // If there is no input, this behaves like EOF if (text.Length == 0){ done = true; text = null; Console.WriteLine (); return; } if (cursor == text.Length) return; text.Remove (cursor, 1); ComputeRendered (); RenderAfter (cursor); } int WordForward (int p) { if (p >= text.Length) return -1; int i = p; if (Char.IsPunctuation (text [p]) || Char.IsSymbol (text [p]) || Char.IsWhiteSpace (text[p])){ for (; i < text.Length; i++){ if (Char.IsLetterOrDigit (text [i])) break; } for (; i < text.Length; i++){ if (!Char.IsLetterOrDigit (text [i])) break; } } else { for (; i < text.Length; i++){ if (!Char.IsLetterOrDigit (text [i])) break; } } if (i != p) return i; return -1; } int WordBackward (int p) { if (p == 0) return -1; int i = p-1; if (i == 0) return 0; if (Char.IsPunctuation (text [i]) || Char.IsSymbol (text [i]) || Char.IsWhiteSpace (text[i])){ for (; i >= 0; i--){ if (Char.IsLetterOrDigit (text [i])) break; } for (; i >= 0; i--){ if (!Char.IsLetterOrDigit (text[i])) break; } } else { for (; i >= 0; i--){ if (!Char.IsLetterOrDigit (text [i])) break; } } i++; if (i != p) return i; return -1; } void CmdDeleteWord () { int pos = WordForward (cursor); if (pos == -1) return; string k = text.ToString (cursor, pos-cursor); if (last_handler == CmdDeleteWord) kill_buffer = kill_buffer + k; else kill_buffer = k; text.Remove (cursor, pos-cursor); ComputeRendered (); RenderAfter (cursor); } void CmdDeleteBackword () { int pos = WordBackward (cursor); if (pos == -1) return; string k = text.ToString (pos, cursor-pos); if (last_handler == CmdDeleteBackword) kill_buffer = k + kill_buffer; else kill_buffer = k; text.Remove (pos, cursor-pos); ComputeRendered (); RenderAfter (pos); } // // Adds the current line to the history if needed // void HistoryUpdateLine () { history.Update (text.ToString ()); } void CmdHistoryPrev () { if (!history.PreviousAvailable ()) return; HistoryUpdateLine (); SetText (history.Previous ()); } void CmdHistoryNext () { if (!history.NextAvailable()) return; history.Update (text.ToString ()); SetText (history.Next ()); } void CmdKillToEOF () { kill_buffer = text.ToString (cursor, text.Length-cursor); text.Length = cursor; ComputeRendered (); RenderAfter (cursor); } void CmdYank () { InsertTextAtCursor (kill_buffer); } void InsertTextAtCursor (string str) { int prev_lines = LineCount; text.Insert (cursor, str); ComputeRendered (); if (prev_lines != LineCount){ Console.SetCursorPosition (0, home_row); Render (); cursor += str.Length; ForceCursor (cursor); } else { RenderFrom (cursor); cursor += str.Length; ForceCursor (cursor); UpdateHomeRow (TextToScreenPos (cursor)); } } void SetSearchPrompt (string s) { SetPrompt ("(reverse-i-search)`" + s + "': "); } void ReverseSearch () { int p; if (cursor == text.Length){ // The cursor is at the end of the string p = text.ToString ().LastIndexOf (search); if (p != -1){ match_at = p; cursor = p; ForceCursor (cursor); return; } } else { // The cursor is somewhere in the middle of the string int start = (cursor == match_at) ? cursor - 1 : cursor; if (start != -1){ p = text.ToString ().LastIndexOf (search, start); if (p != -1){ match_at = p; cursor = p; ForceCursor (cursor); return; } } } // Need to search backwards in history HistoryUpdateLine (); string s = history.SearchBackward (search); if (s != null){ match_at = -1; SetText (s); ReverseSearch (); } } void CmdReverseSearch () { if (searching == 0){ match_at = -1; last_search = search; searching = -1; search = ""; SetSearchPrompt (""); } else { if (search == ""){ if (last_search != "" && last_search != null){ search = last_search; SetSearchPrompt (search); ReverseSearch (); } return; } ReverseSearch (); } } void SearchAppend (char c) { search = search + c; SetSearchPrompt (search); // // If the new typed data still matches the current text, stay here // if (cursor < text.Length){ string r = text.ToString (cursor, text.Length - cursor); if (r.StartsWith (search)) return; } ReverseSearch (); } void CmdRefresh () { Console.Clear (); max_rendered = 0; Render (); ForceCursor (cursor); } void InterruptEdit (object sender, ConsoleCancelEventArgs a) { // Do not abort our program: a.Cancel = true; // Interrupt the editor edit_thread.Abort(); } void HandleChar (char c) { if (searching != 0) SearchAppend (c); else InsertChar (c); } void EditLoop () { ConsoleKeyInfo cki; while (!done){ ConsoleModifiers mod; cki = Console.ReadKey (true); if (cki.Key == ConsoleKey.Escape){ cki = Console.ReadKey (true); mod = ConsoleModifiers.Alt; } else mod = cki.Modifiers; bool handled = false; foreach (Handler handler in handlers){ ConsoleKeyInfo t = handler.CKI; if (t.Key == cki.Key && t.Modifiers == mod){ handled = true; handler.KeyHandler (); last_handler = handler.KeyHandler; break; } else if (t.KeyChar == cki.KeyChar && t.Key == ConsoleKey.Zoom){ handled = true; handler.KeyHandler (); last_handler = handler.KeyHandler; break; } } if (handled){ if (searching != 0){ if (last_handler != CmdReverseSearch){ searching = 0; SetPrompt (prompt); } } continue; } if (cki.KeyChar != (char) 0) HandleChar (cki.KeyChar); } } void InitText (string initial) { text = new StringBuilder (initial); ComputeRendered (); cursor = text.Length; Render (); ForceCursor (cursor); } void SetText (string newtext) { Console.SetCursorPosition (0, home_row); InitText (newtext); } void SetPrompt (string newprompt) { shown_prompt = newprompt; Console.SetCursorPosition (0, home_row); Render (); ForceCursor (cursor); } public string Edit (string prompt, string initial) { edit_thread = Thread.CurrentThread; searching = 0; Console.CancelKeyPress += InterruptEdit; done = false; history.CursorToEnd (); max_rendered = 0; Prompt = prompt; shown_prompt = prompt; InitText (initial); history.Append (initial); do { try { EditLoop (); } catch (ThreadAbortException){ searching = 0; Thread.ResetAbort (); Console.WriteLine (); SetPrompt (prompt); SetText (""); } } while (!done); Console.WriteLine (); Console.CancelKeyPress -= InterruptEdit; if (text == null){ history.Close (); return null; } string result = text.ToString (); if (result != "") history.Accept (result); else history.RemoveLast (); return result; } public void SaveHistory () { if (history != null) { history.Close (); } } public bool TabAtStartCompletes { get; set; } // // Emulates the bash-like behavior, where edits done to the // history are recorded // class History { string [] history; int head, tail; int cursor, count; string histfile; public History (string app, int size) { if (size < 1) throw new ArgumentException ("size"); if (app != null){ string dir = Environment.GetFolderPath (Environment.SpecialFolder.ApplicationData); //Console.WriteLine (dir); if (!Directory.Exists (dir)){ try { Directory.CreateDirectory (dir); } catch { app = null; } } if (app != null) histfile = Path.Combine (dir, app) + ".history"; } history = new string [size]; head = tail = cursor = 0; if (File.Exists (histfile)){ using (StreamReader sr = File.OpenText (histfile)){ string line; while ((line = sr.ReadLine ()) != null){ if (line != "") Append (line); } } } } public void Close () { if (histfile == null) return; try { using (StreamWriter sw = File.CreateText (histfile)){ int start = (count == history.Length) ? head : tail; for (int i = start; i < start+count; i++){ int p = i % history.Length; sw.WriteLine (history [p]); } } } catch { // ignore } } // // Appends a value to the history // public void Append (string s) { //Console.WriteLine ("APPENDING {0} head={1} tail={2}", s, head, tail); history [head] = s; head = (head+1) % history.Length; if (head == tail) tail = (tail+1 % history.Length); if (count != history.Length) count++; //Console.WriteLine ("DONE: head={1} tail={2}", s, head, tail); } // // Updates the current cursor location with the string, // to support editing of history items. For the current // line to participate, an Append must be done before. // public void Update (string s) { history [cursor] = s; } public void RemoveLast () { head = head-1; if (head < 0) head = history.Length-1; } public void Accept (string s) { int t = head-1; if (t < 0) t = history.Length-1; history [t] = s; } public bool PreviousAvailable () { //Console.WriteLine ("h={0} t={1} cursor={2}", head, tail, cursor); if (count == 0) return false; int next = cursor-1; if (next < 0) next = count-1; if (next == head) return false; return true; } public bool NextAvailable () { if (count == 0) return false; int next = (cursor + 1) % history.Length; if (next == head) return false; return true; } // // Returns: a string with the previous line contents, or // nul if there is no data in the history to move to. // public string Previous () { if (!PreviousAvailable ()) return null; cursor--; if (cursor < 0) cursor = history.Length - 1; return history [cursor]; } public string Next () { if (!NextAvailable ()) return null; cursor = (cursor + 1) % history.Length; return history [cursor]; } public void CursorToEnd () { if (head == tail) return; cursor = head; } public void Dump () { Console.WriteLine ("Head={0} Tail={1} Cursor={2} count={3}", head, tail, cursor, count); for (int i = 0; i < history.Length;i++){ Console.WriteLine (" {0} {1}: {2}", i == cursor ? "==>" : " ", i, history[i]); } //log.Flush (); } public string SearchBackward (string term) { for (int i = 0; i < count; i++){ int slot = cursor-i-1; if (slot < 0) slot = history.Length+slot; if (slot >= history.Length) slot = 0; if (history [slot] != null && history [slot].IndexOf (term) != -1){ cursor = slot; return history [slot]; } } return null; } } } #if DEMO class Demo { static void Main () { LineEditor le = new LineEditor ("foo"); string s; while ((s = le.Edit ("shell> ", "")) != null){ Console.WriteLine ("----> [{0}]", s); } } } #endif }