From: Joe Souza jsouza@yahoo.com
--- programs/cmd/Makefile.in | 1 + programs/cmd/batch.c | 2 +- programs/cmd/lineedit.c | 534 +++++++++++++++++++++++++++++++++++++++ programs/cmd/wcmd.h | 1 + 4 files changed, 537 insertions(+), 1 deletion(-) create mode 100755 programs/cmd/lineedit.c
diff --git a/programs/cmd/Makefile.in b/programs/cmd/Makefile.in index cca40b84011..0c4c283505d 100644 --- a/programs/cmd/Makefile.in +++ b/programs/cmd/Makefile.in @@ -8,5 +8,6 @@ SOURCES = \ builtins.c \ cmd.rc \ directory.c \ + lineedit.c \ wcmd.svg \ wcmdmain.c diff --git a/programs/cmd/batch.c b/programs/cmd/batch.c index 20ae894dbfd..10881334e3e 100644 --- a/programs/cmd/batch.c +++ b/programs/cmd/batch.c @@ -227,7 +227,7 @@ WCHAR *WCMD_fgets(WCHAR *buf, DWORD noChars, HANDLE h) /* We can't use the native f* functions because of the filename syntax differences between DOS and Unix. Also need to lose the LF (or CRLF) from the line. */
- if (VerifyConsoleIoHandle(h) && ReadConsoleW(h, buf, noChars, &charsRead, NULL) && charsRead) { + if (VerifyConsoleIoHandle(h) && WCMD_ReadConsole(h, buf, noChars, &charsRead, NULL) && charsRead) { if (!charsRead) return NULL;
/* Find first EOL */ diff --git a/programs/cmd/lineedit.c b/programs/cmd/lineedit.c new file mode 100755 index 00000000000..a56551d5707 --- /dev/null +++ b/programs/cmd/lineedit.c @@ -0,0 +1,534 @@ +/* + * CMD - Wine-compatible command line interface - Command line editing + * functions, including tab-completion support + * + * Copyright (C) 2025 Joe Souza + * + * 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 St, Fifth Floor, Boston, MA 02110-1301, USA + */ + +#define WIN32_LEAN_AND_MEAN + +#include "wcmd.h" +#include "wine/debug.h" + +WINE_DEFAULT_DEBUG_CHANNEL(cmd); + + +static int WriteCharacterToConsoleInput(const HANDLE hInput, const WCHAR inputChar) +{ + INPUT_RECORD inputRecord; + DWORD eventsWritten; + + /* Create an input record for a key press event */ + inputRecord.EventType = KEY_EVENT; + inputRecord.Event.KeyEvent.bKeyDown = TRUE; + inputRecord.Event.KeyEvent.wRepeatCount = 1; + inputRecord.Event.KeyEvent.wVirtualKeyCode = VkKeyScanW(inputChar); + inputRecord.Event.KeyEvent.wVirtualScanCode = MapVirtualKeyW(inputRecord.Event.KeyEvent.wVirtualKeyCode, MAPVK_VK_TO_VSC); + inputRecord.Event.KeyEvent.uChar.UnicodeChar = inputChar; + inputRecord.Event.KeyEvent.dwControlKeyState = 0; + + /* Write the input record to the console input buffer */ + if (!WriteConsoleInputW(hInput, &inputRecord, 1, &eventsWritten)) { + return 1; + } + + /* Create an input record for a key release event */ + inputRecord.Event.KeyEvent.bKeyDown = FALSE; + if (!WriteConsoleInputW(hInput, &inputRecord, 1, &eventsWritten)) { + return 1; + } + + return 0; +} + +static void WriteLineToConsoleInput(const HANDLE hInput, const WCHAR *inputBuffer, const ULONG inputBufferLength) +{ + int cc = 0; + + while (inputBuffer[cc] && cc < inputBufferLength) { + WriteCharacterToConsoleInput(hInput, inputBuffer[cc++]); + } +} + +static BOOL IsDirectoryOperation(const WCHAR *inputBuffer, const ULONG inputBufferLength) +{ + int cc = 0; + BOOL ret = FALSE; + + while (inputBuffer[cc] == L' ' || inputBuffer[cc] == L'\t') { + cc++; + } + + if (!_wcsnicmp(&inputBuffer[cc], L"cd", 2) || + !_wcsnicmp(&inputBuffer[cc], L"chdir", 5) || + !_wcsnicmp(&inputBuffer[cc], L"rd", 2) || + !_wcsnicmp(&inputBuffer[cc], L"rmdir", 5) || + !_wcsnicmp(&inputBuffer[cc], L"md", 2) || + !_wcsnicmp(&inputBuffer[cc], L"mkdir", 5)) { + + ret = TRUE; + } + + return ret; +} + +static void ClearConsoleCharacters(const HANDLE hOutput, const SHORT cCount) +{ + CONSOLE_SCREEN_BUFFER_INFO csbi; + DWORD written; + + GetConsoleScreenBufferInfo(hOutput, &csbi); + FillConsoleOutputCharacterW(hOutput, L' ', cCount, csbi.dwCursorPosition, &written); +} + +static DWORD GetCursorSize(const HANDLE hOutput) +{ + CONSOLE_CURSOR_INFO cursorInfo; + + if (!GetConsoleCursorInfo(hOutput, &cursorInfo)) { + cursorInfo.dwSize = 25; + } + + return cursorInfo.dwSize; +} + +static void SetCursorSize(const HANDLE hOutput, const DWORD dwSize) +{ + CONSOLE_CURSOR_INFO cursorInfo = {dwSize, TRUE}; + + SetConsoleCursorInfo(hOutput, &cursorInfo); +} + +static void SetCursorVisible(const HANDLE hOutput, const BOOL visible) +{ + CONSOLE_CURSOR_INFO cursorInfo; + + if (GetConsoleCursorInfo(hOutput, &cursorInfo)) { + cursorInfo.bVisible = visible; + SetConsoleCursorInfo(hOutput, &cursorInfo); + } +} + +/* Intended as a drop-in replacement for ReadConsole, but with tab-completion support. + */ +BOOL WCMD_ReadConsole(const HANDLE hInput, WCHAR *inputBuffer, const DWORD inputBufferLength, LPDWORD numRead, LPVOID pInputControl) +{ + HANDLE hOutput = GetStdHandle(STD_OUTPUT_HANDLE); + WCHAR searchstr[MAX_PATH]; + BOOL haveSearchResult = FALSE; + BOOL hadOneMatch = FALSE; + HANDLE hSearch = INVALID_HANDLE_VALUE; + WIN32_FIND_DATAW findData = {0}; + CONSOLE_SCREEN_BUFFER_INFO startConsoleInfo, consoleInfo; + DWORD numWritten, oldMode, origCursorSize; + UINT curPos; + int searchPos, insertPos, lastStartQuote; + BOOL isDirSearch; + BOOL haveMode; + BOOL haveQuotes = FALSE; + BOOL ignoreTrailingQuote = FALSE; + BOOL needQuotes = FALSE; + BOOL insert_mode = TRUE; + BOOL done = FALSE; + BOOL ret = FALSE; + + if (hInput == INVALID_HANDLE_VALUE || hOutput == INVALID_HANDLE_VALUE || !inputBuffer || !inputBufferLength) { + return FALSE; + } + + *inputBuffer = L'\0'; + curPos = 0; + + /* Get starting cursor position and size */ + GetConsoleScreenBufferInfo(hOutput, &startConsoleInfo); + origCursorSize = GetCursorSize(hOutput); + + while (!done) { + INPUT_RECORD inputRecord; + DWORD numEventsRead; + + ReadConsoleInputW(hInput, &inputRecord, 1, &numEventsRead); + if (inputRecord.EventType == KEY_EVENT && inputRecord.Event.KeyEvent.bKeyDown) { + int cc, len, oldLen; + + len = oldLen = lstrlenW(inputBuffer); + switch (inputRecord.Event.KeyEvent.wVirtualKeyCode) { + case VK_TAB: + haveSearchResult = needQuotes = FALSE; + /* If tab key was hit while cursor was not at EOL */ + if (curPos < len) { + TRACE("***** Reset search, tab hit before EOL\n"); + inputBuffer[curPos] = L'\0'; + if (hSearch != INVALID_HANDLE_VALUE) { + FindClose(hSearch); + hSearch = INVALID_HANDLE_VALUE; + } + } + /* If new search */ + if (hSearch == INVALID_HANDLE_VALUE) { + cc = searchPos = insertPos = lastStartQuote = 0; + haveQuotes = ignoreTrailingQuote = FALSE; + + /* Handle spaces in directory names. Need to quote paths if they contain spaces. + * Also, there can be multiple quoted paths on a command line, so find the current + * one. + */ + while (cc < curPos) { + if (inputBuffer[cc] == L'"') { + haveQuotes = !haveQuotes; + TRACE("Flip haveQuotes, now: %u\n", haveQuotes); + if (haveQuotes) { + lastStartQuote = cc; + } + } + cc++; + } + + /* Special case: If user entered a backslash after quotes, ignore the final quotes. */ + if (inputBuffer[curPos-2] == L'"' && inputBuffer[curPos-1] == L'\') { + TRACE("Ignore quote before trailing backslash\n"); + ignoreTrailingQuote = haveQuotes = TRUE; + } + + if (haveQuotes) { + /* Tab was hit inside a quoted path. Search specification is start of the path. */ + cc = lastStartQuote + 1; + } else { + /* Tab was hit outside of a quoted path. Find the start of this path, + * i.e. the first character after the previous spaces and tabs. + * This will be the search specification for tab-completion. + */ + cc = curPos; + while (cc > 0 && inputBuffer[cc] != L' ' && inputBuffer[cc] != L'\t') { + cc--; + } + while (cc < len && (inputBuffer[cc] == L' ' || inputBuffer[cc] == L'\t')) { + cc++; + } + } + searchPos = cc; + lstrcpynW(searchstr, &inputBuffer[searchPos], ARRAY_SIZE(searchstr) - 2); + if (ignoreTrailingQuote) { + len = lstrlenW(searchstr); + searchstr[len-2] = L'\'; + searchstr[len-1] = L'\0'; + } + lstrcatW(searchstr, L"*"); + TRACE("New search: [%s]\n", wine_dbgstr_w(searchstr)); + isDirSearch = IsDirectoryOperation(inputBuffer, inputBufferLength); +search_restart: + /* On Windows, the FindExSearchLimitToDirectories specification is merely a suggestion and an + * optimization apparently intended for network file systems. + */ + hSearch = FindFirstFileExW(searchstr, FindExInfoStandard, &findData, isDirSearch ? FindExSearchLimitToDirectories : FindExSearchNameMatch, NULL, 0); + if (hSearch != INVALID_HANDLE_VALUE) { + /* Always skip "." and ".." entries. */ + if (wcscmp(findData.cFileName, L".") && wcscmp(findData.cFileName, L"..")) { + if (!isDirSearch || findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + haveSearchResult = TRUE; + } + } + } + } + /* Subsequent search */ + if (hSearch != INVALID_HANDLE_VALUE && !haveSearchResult) { + while (!haveSearchResult && FindNextFileW(hSearch, &findData)) { + /* Always skip "." and ".." entries. */ + if (wcscmp(findData.cFileName, L".") && wcscmp(findData.cFileName, L"..")) { + if (!isDirSearch || findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + haveSearchResult = TRUE; + } + } + } + + if (!haveSearchResult && hadOneMatch) { + hadOneMatch = FALSE; + /* Reached the last match, start over. */ + FindClose(hSearch); + goto search_restart; + } + } + + if (haveSearchResult) { + hadOneMatch = TRUE; + + /* If this is our first time through here for this search, we need to find the insert position + * for the results. Note that this is very likely not the same location as the search position. + */ + if (!insertPos) { + /* If user entered a backslash after quotes, remove the quotes. */ + if (inputBuffer[curPos-2] == L'"' && inputBuffer[curPos-1] == L'\') { + TRACE("Remove quote before trailing backslash\n"); + inputBuffer[curPos-2] = L'\'; + inputBuffer[curPos-1] = L'\0'; + curPos--; + } + + len = lstrlenW(inputBuffer); + /* Handle paths here. Find last '\'. */ + cc = len; + /* Handle spaces in directory names. Look for '\' on first pass, then quotes on second pass + * if '\' isn't found, and finally whitespace on third pass. + */ + while (cc > searchPos && inputBuffer[cc] != L'\') { + cc--; + } + + if (cc == searchPos) { + /* Handle dir "Program Files" */ + TRACE("haveQuotes: %u, lastStartQuote: %u, curPos: %u\n", haveQuotes, lastStartQuote, curPos); + if (haveQuotes && lastStartQuote) { + cc = lastStartQuote; + } else { + cc = len; + while (cc > 0 && inputBuffer[cc] != L' ' && inputBuffer[cc] != L'\t') { + cc--; + } + } + } + + if (cc != searchPos) { + cc++; + } + + insertPos = cc; + } + + /* We have found the insert position for the results. Terminate the string here. */ + inputBuffer[insertPos] = L'\0'; + + needQuotes = wcschr(findData.cFileName, L' ') || findData.cAlternateFileName[0]; + /* Add starting quotes if needed. */ + if (needQuotes && !haveQuotes) { + len = lstrlenW(inputBuffer); + if (len < inputBufferLength - 1) { + if (searchPos <= len) { + memmove(&inputBuffer[searchPos+1], &inputBuffer[searchPos], (len - searchPos + 1) * sizeof(WCHAR)); + inputBuffer[searchPos] = L'"'; + haveQuotes = TRUE; + lastStartQuote = searchPos; + insertPos++; + } + } + } + lstrcatW(inputBuffer, findData.cFileName); + /* Add closing quotes if needed. */ + if (needQuotes || haveQuotes) { + len = lstrlenW(inputBuffer); + if (len < inputBufferLength - 1) { + inputBuffer[len] = L'"'; + inputBuffer[len+1] = L'\0'; + } + } + /* Update cursor position to end of buffer. */ + curPos = lstrlenW(inputBuffer); + } + break; + + case VK_RETURN: + if (hSearch != INVALID_HANDLE_VALUE) { + FindClose(hSearch); + hSearch = INVALID_HANDLE_VALUE; + } + TRACE("VK_RETURN: [%s]\n", wine_dbgstr_w(inputBuffer)); + /* Write current inputBuffer back to console input, then read it back. + * This will update console history. + */ + haveMode = GetConsoleMode(hInput, &oldMode); + if (haveMode) { + SetConsoleMode(hInput, ENABLE_LINE_INPUT); + } + FlushConsoleInputBuffer(hInput); + WriteLineToConsoleInput(hInput, inputBuffer, inputBufferLength); + WriteConsoleInputW(hInput, &inputRecord, 1, &numWritten); + inputRecord.Event.KeyEvent.bKeyDown = FALSE; + WriteConsoleInputW(hInput, &inputRecord, 1, &numWritten); + ret = ReadConsoleW(hInput, inputBuffer, inputBufferLength, numRead, pInputControl); + WriteConsoleW(hOutput, L"\n", 1, &numWritten, NULL); + if (haveMode) { + SetConsoleMode(hInput, oldMode); + } + SetCursorSize(hOutput, origCursorSize); + done = TRUE; + break; + + case VK_BACK: + if (len > 0) { + if (curPos < len) { + memmove(&inputBuffer[curPos-1], &inputBuffer[curPos], (len - curPos + 1) * sizeof(WCHAR)); + } + inputBuffer[len - 1] = L'\0'; + curPos -= 1; + } + if (hSearch != INVALID_HANDLE_VALUE) { + FindClose(hSearch); + hSearch = INVALID_HANDLE_VALUE; + } + break; + + case VK_DELETE: + if (len > 0) { + if (curPos < len) { + memmove(&inputBuffer[curPos], &inputBuffer[curPos+1], (len - curPos) * sizeof(WCHAR)); + } + inputBuffer[len - 1] = L'\0'; + } + if (hSearch != INVALID_HANDLE_VALUE) { + FindClose(hSearch); + hSearch = INVALID_HANDLE_VALUE; + } + break; + + case VK_ESCAPE: + /* Clear buffer and close any existing search. */ + if (len) { + /* In cases where text spans multiple console lines, FillConsoleOutputCharacter does not seem + * to be working properly so we just manually write out a string full of spaces here. + */ + memset(inputBuffer, L' ', len / sizeof(inputBuffer[0])); + SetCursorVisible(hOutput, FALSE); + SetConsoleCursorPosition(hOutput, startConsoleInfo.dwCursorPosition); + WriteConsoleW(hOutput, inputBuffer, len, &numWritten, NULL); + } + inputBuffer[0] = L'\0'; + curPos = 0; + if (hSearch != INVALID_HANDLE_VALUE) { + FindClose(hSearch); + hSearch = INVALID_HANDLE_VALUE; + haveQuotes = FALSE; + } + break; + + case VK_UP: + case VK_DOWN: + case VK_PRIOR: + case VK_NEXT: + /* Command history */ + if (len) { + /* In cases where text spans multiple console lines, FillConsoleOutputCharacter does not seem + * to be working properly so we just manually write out a string full of spaces here. + */ + memset(inputBuffer, L' ', len / sizeof(inputBuffer[0])); + SetConsoleCursorPosition(hOutput, startConsoleInfo.dwCursorPosition); + WriteConsoleW(hOutput, inputBuffer, len, &numWritten, NULL); + } + /* FIXME: Wine Console does not support obtaining command history. In order to + * support command history, Wine would need to implement GetConsoleCommandHistoryLength + * and GetConsoleCommandHistory, or we would need to implement our own history here. + * As a workaround, we chain-back to the console and let it handle history, but + * we lose tab-completion support when doing so. + */ + /* Push keystroke and pass control to console to handle history retrieval. */ + WriteConsoleInputW(hInput, &inputRecord, 1, &numWritten); + inputRecord.Event.KeyEvent.bKeyDown = FALSE; + WriteConsoleInputW(hInput, &inputRecord, 1, &numWritten); + ret = ReadConsoleW(hInput, inputBuffer, inputBufferLength, numRead, pInputControl); + done = TRUE; + break; + + case VK_LEFT: + if (curPos) { + curPos--; + } + if (inputRecord.Event.KeyEvent.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) { + while (curPos && (inputBuffer[curPos] == L' ' || inputBuffer[curPos] == L'\t')) { + curPos--; + } + while (curPos && (inputBuffer[curPos-1] != L' ' && inputBuffer[curPos-1] != L'\t')) { + curPos--; + } + } + break; + + case VK_RIGHT: + if (inputRecord.Event.KeyEvent.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED)) { + while (curPos < len && inputBuffer[curPos] != L' ' && inputBuffer[curPos] != L'\t') { + curPos++; + } + while (curPos < len && (inputBuffer[curPos] == L' ' || inputBuffer[curPos] == L'\t')) { + curPos++; + } + } else if (curPos < len) { + curPos++; + } + break; + + case VK_HOME: + curPos = 0; + break; + + case VK_END: + curPos = len; + break; + + case VK_INSERT: + insert_mode = !insert_mode; + SetCursorSize(hOutput, insert_mode ? origCursorSize : 100); + break; + + default: + /* Insert or add character to buffer */ + WCHAR ch = inputRecord.Event.KeyEvent.uChar.UnicodeChar; + if (iswprint(ch)) { + if (len < inputBufferLength - 1) { + if (insert_mode && curPos < len) { + memmove(&inputBuffer[curPos+1], &inputBuffer[curPos], (len - curPos + 1) * sizeof(WCHAR)); + } + inputBuffer[curPos++] = ch; + if (curPos > len) { + inputBuffer[curPos] = L'\0'; + } + } + + /* If user entered a delimiter then reset the search. */ + if (ch == L'\' || ch == L' ' || ch == L'\t') { + TRACE("***** Reset search, new delimiter\n"); + if (hSearch != INVALID_HANDLE_VALUE) { + FindClose(hSearch); + hSearch = INVALID_HANDLE_VALUE; + } + } + } + break; + } + + if (!done) { + len = lstrlenW(inputBuffer); + /* Update current input display in console */ + SetCursorVisible(hOutput, FALSE); + SetConsoleCursorPosition(hOutput, startConsoleInfo.dwCursorPosition); + WriteConsoleW(hOutput, inputBuffer, len, &numWritten, NULL); + if (oldLen > len) { + ClearConsoleCharacters(hOutput, oldLen - len); + } + + /* Move cursor if needed. */ + if (curPos < len) { + cc = len - curPos; + if (GetConsoleScreenBufferInfo(hOutput, &consoleInfo)) { + consoleInfo.dwCursorPosition.X -= cc; + SetConsoleCursorPosition(hOutput, consoleInfo.dwCursorPosition); + } + } + SetCursorVisible(hOutput, TRUE); + } + } + } + + return ret; +} + diff --git a/programs/cmd/wcmd.h b/programs/cmd/wcmd.h index d4adc7fd7c8..37572d07ce0 100644 --- a/programs/cmd/wcmd.h +++ b/programs/cmd/wcmd.h @@ -212,6 +212,7 @@ WCHAR *WCMD_LoadMessage(UINT id); WCHAR *WCMD_strsubstW(WCHAR *start, const WCHAR* next, const WCHAR* insert, int len); RETURN_CODE WCMD_wait_for_input(HANDLE hIn); BOOL WCMD_ReadFile(const HANDLE hIn, WCHAR *intoBuf, const DWORD maxChars, LPDWORD charsRead); +BOOL WCMD_ReadConsole(const HANDLE hInput, WCHAR *inputBuffer, const DWORD inputBufferLength, LPDWORD numRead, LPVOID pInputControl);
enum read_parse_line {RPL_SUCCESS, RPL_EOF, RPL_SYNTAXERROR}; enum read_parse_line WCMD_ReadAndParseLine(const WCHAR *initialcmd, CMD_NODE **output);