mirror of https://github.com/adamdruppe/arsd.git
getline class and function - user-editable lines with history and completition
This commit is contained in:
parent
0906e8f7b0
commit
0fa7f5db94
472
terminal.d
472
terminal.d
|
@ -1274,7 +1274,7 @@ struct RealTimeConsoleInput {
|
|||
/// events in the process.
|
||||
dchar getch() {
|
||||
auto event = nextEvent();
|
||||
while(event.type != InputEvent.Type.CharacterEvent) {
|
||||
while(event.type != InputEvent.Type.CharacterEvent || event.characterEvent.eventType == CharacterEvent.Type.Released) {
|
||||
if(event.type == InputEvent.Type.UserInterruptionEvent)
|
||||
throw new Exception("Ctrl+c");
|
||||
event = nextEvent();
|
||||
|
@ -1818,7 +1818,7 @@ struct RealTimeConsoleInput {
|
|||
if(c == -1)
|
||||
return null; // interrupted; give back nothing so the other level can recheck signal flags
|
||||
if(c == 0)
|
||||
throw new Exception("stdin has reached end of file");
|
||||
throw new Exception("stdin has reached end of file"); // FIXME: return this as an event instead
|
||||
if(c == '\033') {
|
||||
if(timedCheckForInput(50)) {
|
||||
// escape sequence
|
||||
|
@ -2078,6 +2078,16 @@ void main() {
|
|||
terminal.color(Color.green | Bright, Color.black);
|
||||
//terminal.color(Color.DEFAULT, Color.DEFAULT);
|
||||
|
||||
//
|
||||
auto getter = new LineGetter(&terminal);
|
||||
terminal.writeln("\n" ~ getter.getline());
|
||||
terminal.writeln("\n" ~ getter.getline());
|
||||
|
||||
input.getch();
|
||||
|
||||
return;
|
||||
//
|
||||
|
||||
terminal.write("test some long string to see if it wraps or what because i dont really know what it is going to do so i just want to test i think it will wrap but gotta be sure lolololololololol");
|
||||
terminal.writefln("%d %d", terminal.cursorX, terminal.cursorY);
|
||||
|
||||
|
@ -2156,6 +2166,464 @@ void main() {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FIXME: support lines that wrap
|
||||
FIXME: better controls maybe
|
||||
FIXME: tab completion
|
||||
FIXME: handle when the thing scrolls cuz of tab complete or something
|
||||
FIXME: insert mode vs overstrike mode????
|
||||
FIXME: read/save file
|
||||
FIXME: add a prompt
|
||||
*/
|
||||
class LineGetter {
|
||||
/* A note on the assumeSafeAppends in here: since these buffers are private, we can be
|
||||
pretty sure that stomping isn't an issue, so I'm using this liberally to keep the
|
||||
append/realloc code simple and hopefully reasonably fast. */
|
||||
|
||||
// saved to file
|
||||
string[] history;
|
||||
|
||||
// not saved
|
||||
Terminal* terminal;
|
||||
this(Terminal* tty) {
|
||||
this.terminal = tty;
|
||||
|
||||
line.reserve(128);
|
||||
|
||||
history = ["first", "second", "third"];
|
||||
|
||||
regularForeground = cast(Color) terminal._currentForeground;
|
||||
background = cast(Color) terminal._currentBackground;
|
||||
suggestionForeground = Color.blue;
|
||||
}
|
||||
|
||||
Color suggestionForeground;
|
||||
Color regularForeground;
|
||||
Color background;
|
||||
//bool reverseVideo;
|
||||
|
||||
/// Override this if you don't want all lines added to the history.
|
||||
/// You can return null to not add it at all, or you can transform it.
|
||||
string historyFilter(string candidate) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
void saveSettingsAndHistoryToFile() {
|
||||
assert(0); // FIXME
|
||||
}
|
||||
|
||||
void loadSettingsAndHistoryFromFile() {
|
||||
assert(0); // FIXME
|
||||
}
|
||||
|
||||
/// Turn on auto suggest if you want a greyed thing of what tab
|
||||
/// would be able to fill in as you type.
|
||||
///
|
||||
/// You might want to turn it off if generating a completion list is slow.
|
||||
bool autoSuggest = true;
|
||||
|
||||
/**
|
||||
Override this to provide tab completion. You may use the candidate
|
||||
argument to filter the list, but you don't have to (LineGetter will
|
||||
do it for you on the values you return).
|
||||
|
||||
Ideally, you wouldn't return more than about ten items since the list
|
||||
gets difficult to use if it is too long.
|
||||
|
||||
Default is to provide recent command history as autocomplete.
|
||||
*/
|
||||
protected string[] tabComplete(in dchar[] candidate) {
|
||||
return history.length > 20 ? history[0 .. 20] : history;
|
||||
}
|
||||
|
||||
private string[] filterTabCompleteList(string[] list) {
|
||||
if(list.length == 0)
|
||||
return list;
|
||||
|
||||
string[] f;
|
||||
f.reserve(list.length);
|
||||
|
||||
foreach(item; list) {
|
||||
import std.algorithm;
|
||||
if(startsWith(item, line[0 .. cursorPosition]))
|
||||
f ~= item;
|
||||
}
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
protected void showTabCompleteList(string[] list) {
|
||||
if(list.length) {
|
||||
// FIXME: allow mouse clicking of an item, that would be cool
|
||||
|
||||
//if(terminal.type == ConsoleOutputType.linear) {
|
||||
terminal.writeln();
|
||||
foreach(item; list) {
|
||||
terminal.color(suggestionForeground, background);
|
||||
import std.utf;
|
||||
auto idx = codeLength!char(line[0 .. cursorPosition]);
|
||||
terminal.write(" ", item[0 .. idx]);
|
||||
terminal.color(regularForeground, background);
|
||||
terminal.writeln(item[idx .. $]);
|
||||
}
|
||||
updateCursorPosition();
|
||||
redraw();
|
||||
//}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-call shop for the main workhorse
|
||||
/// If you already have a RealTimeConsoleInput ready to go, you
|
||||
/// should pass a pointer to yours here. Otherwise, LineGetter will
|
||||
/// make its own.
|
||||
public string getline(RealTimeConsoleInput* input = null) {
|
||||
startGettingLine();
|
||||
if(input is null) {
|
||||
auto i = RealTimeConsoleInput(terminal, ConsoleInputFlags.raw | ConsoleInputFlags.allInputEvents);
|
||||
while(workOnLine(i.nextEvent())) {}
|
||||
} else
|
||||
while(workOnLine(input.nextEvent())) {}
|
||||
return finishGettingLine();
|
||||
}
|
||||
|
||||
private int currentHistoryViewPosition = 0;
|
||||
private dchar[] uncommittedHistoryCandidate;
|
||||
void loadFromHistory(int howFarBack) {
|
||||
if(howFarBack < 0)
|
||||
howFarBack = 0;
|
||||
if(howFarBack > history.length) // lol signed/unsigned comparison here means if i did this first, before howFarBack < 0, it would totally cycle around.
|
||||
howFarBack = history.length;
|
||||
if(howFarBack == currentHistoryViewPosition)
|
||||
return;
|
||||
if(currentHistoryViewPosition == 0) {
|
||||
// save the current line so we can down arrow back to it later
|
||||
if(uncommittedHistoryCandidate.length < line.length) {
|
||||
uncommittedHistoryCandidate.length = line.length;
|
||||
}
|
||||
|
||||
uncommittedHistoryCandidate[0 .. line.length] = line[];
|
||||
uncommittedHistoryCandidate = uncommittedHistoryCandidate[0 .. line.length];
|
||||
uncommittedHistoryCandidate.assumeSafeAppend();
|
||||
}
|
||||
|
||||
currentHistoryViewPosition = howFarBack;
|
||||
|
||||
if(howFarBack == 0) {
|
||||
line.length = uncommittedHistoryCandidate.length;
|
||||
line.assumeSafeAppend();
|
||||
line[] = uncommittedHistoryCandidate[];
|
||||
} else {
|
||||
line = line[0 .. 0];
|
||||
line.assumeSafeAppend();
|
||||
foreach(dchar ch; history[$ - howFarBack])
|
||||
line ~= ch;
|
||||
}
|
||||
|
||||
cursorPosition = line.length;
|
||||
}
|
||||
|
||||
bool insertMode = true;
|
||||
|
||||
private dchar[] line;
|
||||
private int cursorPosition = 0;
|
||||
|
||||
// used for redrawing the line in the right place
|
||||
// and detecting mouse events on our line.
|
||||
private int startOfLineX;
|
||||
private int startOfLineY;
|
||||
|
||||
private string suggestion(string[] list = null) {
|
||||
import std.algorithm, std.utf;
|
||||
auto relevantLineSection = line[0 .. cursorPosition];
|
||||
// FIXME: see about caching the list if we easily can
|
||||
if(list is null)
|
||||
list = filterTabCompleteList(tabComplete(relevantLineSection));
|
||||
|
||||
if(list.length) {
|
||||
string commonality = list[0];
|
||||
foreach(item; list[1 .. $]) {
|
||||
commonality = commonPrefix(commonality, item);
|
||||
}
|
||||
|
||||
if(commonality.length) {
|
||||
return commonality[codeLength!char(relevantLineSection) .. $];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Adds a character at the current position in the line. You can call this too if you hook events for hotkeys or something.
|
||||
/// You'll probably want to call redraw() after adding chars.
|
||||
void addChar(dchar ch) {
|
||||
assert(cursorPosition >= 0 && cursorPosition <= line.length);
|
||||
if(cursorPosition == line.length)
|
||||
line ~= ch;
|
||||
else {
|
||||
assert(line.length);
|
||||
line ~= ' ';
|
||||
for(int i = line.length - 2; i >= cursorPosition; i --)
|
||||
line[i + 1] = line[i];
|
||||
line[cursorPosition] = ch;
|
||||
}
|
||||
cursorPosition++;
|
||||
}
|
||||
|
||||
void addString(string s) {
|
||||
// FIXME: this could be more efficient
|
||||
// but does it matter? these lines aren't super long anyway. But then again a paste could be excessively long (prolly accidental, but still)
|
||||
foreach(dchar ch; s)
|
||||
addChar(ch);
|
||||
}
|
||||
|
||||
/// Deletes the character at the current position in the line.
|
||||
/// You'll probably want to call redraw() after deleting chars.
|
||||
void deleteChar() {
|
||||
if(cursorPosition == line.length)
|
||||
return;
|
||||
for(int i = cursorPosition; i < line.length - 1; i++)
|
||||
line[i] = line[i + 1];
|
||||
line = line[0 .. $-1];
|
||||
line.assumeSafeAppend();
|
||||
}
|
||||
|
||||
int lastDrawLength = 0;
|
||||
void redraw() {
|
||||
terminal.moveTo(startOfLineX, startOfLineY);
|
||||
terminal.write(line);
|
||||
auto suggestion = ((cursorPosition == line.length) && autoSuggest) ? this.suggestion() : null;
|
||||
if(suggestion.length) {
|
||||
terminal.color(suggestionForeground, background);
|
||||
terminal.write(suggestion);
|
||||
terminal.color(regularForeground, background);
|
||||
}
|
||||
if(line.length < lastDrawLength)
|
||||
foreach(i; line.length + suggestion.length .. lastDrawLength)
|
||||
terminal.write(" ");
|
||||
lastDrawLength = line.length + suggestion.length; // FIXME: graphemes and utf-8 on suggestion
|
||||
|
||||
// FIXME: wrapping
|
||||
terminal.moveTo(startOfLineX + cursorPosition, startOfLineY);
|
||||
}
|
||||
|
||||
// Make sure that you've flushed your input and output before calling this
|
||||
// function or else you might lose events or get exceptions from this.
|
||||
void startGettingLine() {
|
||||
// reset from any previous call first
|
||||
cursorPosition = 0;
|
||||
lastDrawLength = 0;
|
||||
justHitTab = false;
|
||||
currentHistoryViewPosition = 0;
|
||||
if(line.length) {
|
||||
line = line[0 .. 0];
|
||||
line.assumeSafeAppend();
|
||||
}
|
||||
|
||||
updateCursorPosition();
|
||||
}
|
||||
|
||||
private void updateCursorPosition() {
|
||||
terminal.flush();
|
||||
|
||||
// then get the current cursor position to start fresh
|
||||
version(Windows) {
|
||||
CONSOLE_SCREEN_BUFFER_INFO info;
|
||||
GetConsoleScreenBufferInfo(terminal.hConsole, &info);
|
||||
startOfLineX = info.dwCursorPosition.X;
|
||||
startOfLineY = info.dwCursorPosition.Y;
|
||||
} else {
|
||||
// request current cursor position
|
||||
terminal.writeStringRaw("\033[6n");
|
||||
terminal.flush();
|
||||
|
||||
import core.sys.posix.unistd;
|
||||
// reading directly to bypass any buffering
|
||||
ubyte[16] buffer;
|
||||
auto len = read(terminal.fdIn, buffer.ptr, buffer.length);
|
||||
if(len <= 0)
|
||||
throw new Exception("Couldn't get cursor position to initialize get line");
|
||||
auto got = buffer[0 .. len];
|
||||
if(got.length < 6)
|
||||
throw new Exception("not enough cursor reply answer");
|
||||
if(got[0] != '\033' || got[1] != '[' || got[$-1] != 'R')
|
||||
throw new Exception("wrong answer for cursor position");
|
||||
auto gots = cast(char[]) got[2 .. $-1];
|
||||
|
||||
import std.conv;
|
||||
import std.string;
|
||||
|
||||
auto pieces = split(gots, ";");
|
||||
if(pieces.length != 2) throw new Exception("wtf wrong answer on cursor position");
|
||||
|
||||
startOfLineX = to!int(pieces[1]) - 1;
|
||||
startOfLineY = to!int(pieces[0]) - 1;
|
||||
}
|
||||
|
||||
// updating these too because I can with the more accurate info from above
|
||||
terminal._cursorX = startOfLineX;
|
||||
terminal._cursorY = startOfLineY;
|
||||
}
|
||||
|
||||
private bool justHitTab;
|
||||
|
||||
/// for integrating into another event loop
|
||||
/// you can pass individual events to this and
|
||||
/// the line getter will work on it
|
||||
///
|
||||
/// returns false when there's nothing more to do
|
||||
bool workOnLine(InputEvent e) {
|
||||
switch(e.type) {
|
||||
case InputEvent.Type.CharacterEvent:
|
||||
if(e.characterEvent.eventType == CharacterEvent.Type.Released)
|
||||
return true;
|
||||
/* Insert the character (unless it is backspace, tab, or some other control char) */
|
||||
auto ch = e.characterEvent.character;
|
||||
switch(ch) {
|
||||
case '\r':
|
||||
case '\n':
|
||||
justHitTab = false;
|
||||
return false;
|
||||
case '\t':
|
||||
auto relevantLineSection = line[0 .. cursorPosition];
|
||||
auto possibilities = filterTabCompleteList(tabComplete(relevantLineSection));
|
||||
import std.utf;
|
||||
|
||||
if(possibilities.length == 1) {
|
||||
auto toFill = possibilities[0][codeLength!char(relevantLineSection) .. $];
|
||||
if(toFill.length) {
|
||||
addString(toFill);
|
||||
redraw();
|
||||
}
|
||||
justHitTab = false;
|
||||
} else {
|
||||
if(justHitTab) {
|
||||
justHitTab = false;
|
||||
showTabCompleteList(possibilities);
|
||||
} else {
|
||||
justHitTab = true;
|
||||
/* fill it in with as much commonality as there is amongst all the suggestions */
|
||||
auto suggestion = this.suggestion(possibilities);
|
||||
if(suggestion.length) {
|
||||
addString(suggestion);
|
||||
redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '\b':
|
||||
justHitTab = false;
|
||||
if(cursorPosition) {
|
||||
cursorPosition--;
|
||||
for(int i = cursorPosition; i < line.length - 1; i++)
|
||||
line[i] = line[i + 1];
|
||||
line = line[0 .. $ - 1];
|
||||
line.assumeSafeAppend();
|
||||
redraw();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
justHitTab = false;
|
||||
addChar(ch);
|
||||
redraw();
|
||||
}
|
||||
break;
|
||||
case InputEvent.Type.NonCharacterKeyEvent:
|
||||
if(e.nonCharacterKeyEvent.eventType == NonCharacterKeyEvent.Type.Released)
|
||||
return true;
|
||||
justHitTab = false;
|
||||
/* Navigation */
|
||||
auto key = e.nonCharacterKeyEvent.key;
|
||||
switch(key) {
|
||||
case NonCharacterKeyEvent.Key.LeftArrow:
|
||||
if(cursorPosition)
|
||||
cursorPosition--;
|
||||
redraw();
|
||||
break;
|
||||
case NonCharacterKeyEvent.Key.RightArrow:
|
||||
if(cursorPosition < line.length)
|
||||
cursorPosition++;
|
||||
redraw();
|
||||
break;
|
||||
case NonCharacterKeyEvent.Key.UpArrow:
|
||||
loadFromHistory(currentHistoryViewPosition + 1);
|
||||
redraw();
|
||||
break;
|
||||
case NonCharacterKeyEvent.Key.DownArrow:
|
||||
loadFromHistory(currentHistoryViewPosition - 1);
|
||||
redraw();
|
||||
break;
|
||||
case NonCharacterKeyEvent.Key.PageUp:
|
||||
loadFromHistory(history.length);
|
||||
redraw();
|
||||
break;
|
||||
case NonCharacterKeyEvent.Key.PageDown:
|
||||
loadFromHistory(0);
|
||||
redraw();
|
||||
break;
|
||||
case NonCharacterKeyEvent.Key.Home:
|
||||
cursorPosition = 0;
|
||||
redraw();
|
||||
break;
|
||||
case NonCharacterKeyEvent.Key.End:
|
||||
cursorPosition = line.length;
|
||||
redraw();
|
||||
break;
|
||||
case NonCharacterKeyEvent.Key.Delete:
|
||||
deleteChar();
|
||||
redraw();
|
||||
break;
|
||||
default:
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
case InputEvent.Type.PasteEvent:
|
||||
justHitTab = false;
|
||||
addString(e.pasteEvent.pastedText);
|
||||
redraw();
|
||||
break;
|
||||
case InputEvent.Type.MouseEvent:
|
||||
/* Clicking with the mouse to move the cursor is so much easier than arrowing
|
||||
or even emacs/vi style movements much of the time, so I'ma support it. */
|
||||
|
||||
auto me = e.mouseEvent;
|
||||
if(me.eventType == MouseEvent.Type.Pressed) {
|
||||
if(me.buttons & MouseEvent.Button.Left) {
|
||||
if(me.y == startOfLineY) {
|
||||
int p = me.x - startOfLineX;
|
||||
if(p >= 0 && p < line.length) {
|
||||
justHitTab = false;
|
||||
cursorPosition = p;
|
||||
redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case InputEvent.Type.SizeChangedEvent:
|
||||
/* We'll adjust the bounding box. If you don't like this, handle SizeChangedEvent
|
||||
yourself and then don't pass it to this function. */
|
||||
// FIXME
|
||||
break;
|
||||
case InputEvent.Type.UserInterruptionEvent:
|
||||
/* I'll take this as canceling the line. */
|
||||
throw new Exception("user canceled"); // FIXME
|
||||
break;
|
||||
default:
|
||||
/* ignore. ideally it wouldn't be passed to us anyway! */
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
string finishGettingLine() {
|
||||
import std.conv;
|
||||
auto f = to!string(line);
|
||||
auto history = historyFilter(f);
|
||||
if(history !is null)
|
||||
this.history ~= history;
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
|
Loading…
Reference in New Issue