-
Notifications
You must be signed in to change notification settings - Fork 3
Add Windows support #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
WalkthroughAdds native Windows terminal support: a WinTty implementation with Win32 console bindings, enables Screen creation on Windows, and refines parser handling for control characters to correctly map key events. Changes
Sequence DiagramsequenceDiagram
participant App as Application
participant Scr as Screen
participant TTY as WinTty
participant Cons as Windows Console
rect rgb(220,235,255)
Note over App,Cons: Initialization
App->>Scr: newScreen()
Scr->>TTY: newTtyScreen() → newDevTty()
TTY->>Cons: Save console mode / Create event
TTY->>Cons: SetConsoleMode(raw)
end
rect rgb(220,255,220)
Note over App,Cons: Input path
App->>TTY: read(timeout)
TTY->>Cons: ReadConsoleInputW
Cons-->>TTY: Key / Resize events
TTY->>TTY: map key events (control-char handling)
TTY-->>App: deliver KeyEvent / rune
end
rect rgb(255,245,200)
Note over App,Cons: Output & sizing
App->>TTY: write(string)
TTY->>Cons: WriteConsoleW (UTF-16)
App->>TTY: windowSize()
TTY->>Cons: GetConsoleScreenBufferInfo
Cons-->>TTY: buffer info → TTY-->>App: size
end
rect rgb(255,220,220)
Note over App,Cons: Cleanup
App->>TTY: close()
TTY->>Cons: Restore console mode / Close handles
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes
Possibly related PRs
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
source/dcell/parser.d (1)
1105-1127: Control-char remapping in newKeyEvent breaks non-rune keys (fix causes unittest failure)When
newKeyEventis called for arrows/function keys,dchis0, so the newif (dch < ' ')block runs and rewrites keys likeKey.rightinto a CTRL-modifiedKey.rune, which is why the\x1bOCunittest now fails. You can fix this by only remapping control characters that actually carry a non-zero code:Event newKeyEvent(Key k, dchar dch = 0, Modifiers mod = Modifiers.none) { if (escaped) { mod |= Modifiers.alt; escaped = false; } - if (dch < ' ') + // Only reinterpret real control characters that have a non-zero code; + // leave structural/non-rune events (dch == 0) alone. + if (dch != 0 && dch < ' ') { switch (k) { case Key.backspace, Key.tab, Key.esc, Key.enter: // these keys are directly typeable without CTRL break; default: // most likely entered with a CTRL keypress k = Key.rune; mod |= Modifiers.ctrl; dch = dch + '\x60'; break; } }This keeps arrow/F-key events intact while still normalizing true control characters into CTRL-modified runes, and should resolve the failing parser unittest.
🧹 Nitpick comments (1)
source/dcell/termio.d (1)
383-389: Consider keeping newDevTty’s signature consistent on WindowsOn Posix,
newDevTtyisTtyImpl newDevTty(string dev = "/dev/tty"), while on Windows it’sTtyImpl newDevTty(); this works fine fornewTtyScreen()(which callsnewDevTty()with no args per the existing design) but may surprise external callers that expect a uniform signature across platforms. You might consider giving the Windows version a dummystring dev = nullparameter to keep the API shape consistent, even if the argument is ignored.Based on learnings,
newTtyScreen()already hides the term/device selection for typical callers.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
source/dcell/common.d(1 hunks)source/dcell/parser.d(1 hunks)source/dcell/termio.d(2 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: gdamore
Repo: gdamore/dcell PR: 27
File: source/dcell/ttyscreen.d:763-766
Timestamp: 2025-12-08T19:10:45.974Z
Learning: In source/dcell/ttyscreen.d, the newTtyScreen() factory function intentionally does not accept a term parameter because the TtyScreen constructor handles environment variable resolution internally with an optional term parameter. Users needing to override the terminal type should call the constructor directly: new TtyScreen(newDevTty(), "custom-term").
📚 Learning: 2025-12-08T19:10:45.974Z
Learnt from: gdamore
Repo: gdamore/dcell PR: 27
File: source/dcell/ttyscreen.d:763-766
Timestamp: 2025-12-08T19:10:45.974Z
Learning: In source/dcell/ttyscreen.d, the newTtyScreen() factory function intentionally does not accept a term parameter because the TtyScreen constructor handles environment variable resolution internally with an optional term parameter. Users needing to override the terminal type should call the constructor directly: new TtyScreen(newDevTty(), "custom-term").
Applied to files:
source/dcell/common.dsource/dcell/termio.d
🪛 GitHub Actions: dmd
source/dcell/parser.d
[error] 1233-1233: unittest failure (core.exception.AssertError) during DMD DUB test run.
🪛 GitHub Actions: ldc
source/dcell/parser.d
[error] 1233-1233: core.exception.AssertError: Assertion failure at parser.d:1233 during unit tests.
🔇 Additional comments (1)
source/dcell/common.d (1)
28-35: Windows newScreen() now returns a real tty screen (LGTM)Using
newTtyScreen()on Windows mirrors the Posix path and cleanly removes the previous “windows not supported” error path; no issues here.
| version (Windows) | ||
| { | ||
|
|
||
| import core.sys.windows.windows; | ||
|
|
||
| // Kernel32.dll functions | ||
| extern (Windows) | ||
| { | ||
| BOOL ReadConsoleInputW(HANDLE hConsoleInput, INPUT_RECORD* lpBuffer, DWORD nLength, DWORD* lpNumEventsRead); | ||
|
|
||
| BOOL GetNumberOfConsoleInputEvents(HANDLE hConsoleInput, DWORD* lpcNumberOfEvents); | ||
|
|
||
| BOOL FlushConsoleInputBuffer(HANDLE hConsoleInput); | ||
|
|
||
| DWORD WaitForMultipleObjects(DWORD nCount, const HANDLE* lpHandles, BOOL bWaitAll, DWORD dwMilliseconds); | ||
|
|
||
| BOOL SetConsoleMode(HANDLE hConsoleHandle, DWORD dwMode); | ||
|
|
||
| BOOL GetConsoleMode(HANDLE hConsoleHandle, DWORD* lpMode); | ||
|
|
||
| BOOL GetConsoleScreenBufferInfo(HANDLE hConsoleOutput, CONSOLE_SCREEN_BUFFER_INFO* lpConsoleScreenBufferInfo); | ||
|
|
||
| HANDLE CreateEventW(SECURITY_ATTRIBUTES* secAttr, BOOL bManualReset, BOOL bInitialState, LPCWSTR lpName); | ||
|
|
||
| BOOL SetEvent(HANDLE hEvent); | ||
|
|
||
| BOOL WriteConsoleW(HANDLE hFile, LPCVOID buf, DWORD nNumBytesToWrite, LPDWORD lpNumBytesWritten, LPVOID rsvd); | ||
|
|
||
| BOOL CloseHandle(HANDLE hObject); | ||
| } | ||
|
|
||
| // WindowsTty use ReadConsoleInput, as that is the only | ||
| // way to get window resize events. | ||
| package class WinTty : TtyImpl | ||
| { | ||
|
|
||
| this() | ||
| { | ||
| input = GetStdHandle(STD_INPUT_HANDLE); | ||
| output = GetStdHandle(STD_OUTPUT_HANDLE); | ||
| eventH = CreateEventW(null, true, false, null); | ||
| } | ||
|
|
||
| void save() | ||
| { | ||
|
|
||
| GetConsoleMode(output, &omode); | ||
| GetConsoleMode(input, &imode); | ||
| } | ||
|
|
||
| void restore() | ||
| { | ||
| SetConsoleMode(output, omode); | ||
| SetConsoleMode(input, imode); | ||
| } | ||
|
|
||
| void start() | ||
| { | ||
| save(); | ||
| if (!started) | ||
| { | ||
| started = true; | ||
| FlushConsoleInputBuffer(input); | ||
| } | ||
| } | ||
|
|
||
| void stop() | ||
| { | ||
| SetEvent(eventH); | ||
| } | ||
|
|
||
| void close() | ||
| { | ||
| CloseHandle(input); | ||
| CloseHandle(output); | ||
| CloseHandle(eventH); | ||
| } | ||
|
|
||
| void raw() | ||
| { | ||
| SetConsoleMode(input, ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_WINDOW_INPUT | ENABLE_EXTENDED_FLAGS); | ||
| SetConsoleMode(output, | ||
| ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN); | ||
|
|
||
| } | ||
|
|
||
| void flush() | ||
| { | ||
| } | ||
|
|
||
| /** | ||
| * Read input. May return an empty slice if no data | ||
| * is present and blocking is disabled. | ||
| */ | ||
| string read(Duration dur = Duration.zero) | ||
| { | ||
| HANDLE[2] handles; | ||
| handles[0] = input; | ||
| handles[1] = eventH; | ||
|
|
||
| DWORD dly; | ||
| if (dur.isNegative || dur == Duration.max) | ||
| { | ||
| dly = INFINITE; | ||
| } | ||
| else | ||
| { | ||
| dly = cast(DWORD)(dur.total!"msecs"); | ||
| } | ||
|
|
||
| auto rv = WaitForMultipleObjects(2, handles.ptr, false, dly); | ||
| string result = null; | ||
|
|
||
| // WaitForMultipleObjects returns WAIT_OBJECT_0 + the index. | ||
| switch (rv) | ||
| { | ||
| case WAIT_OBJECT_0 + 1: // w.cancelFlag | ||
| return result; | ||
| case WAIT_OBJECT_0: // input | ||
| INPUT_RECORD[128] recs; | ||
| DWORD nrec; | ||
| ReadConsoleInput(input, recs.ptr, 128, &nrec); | ||
|
|
||
| foreach (ev; recs[0 .. nrec]) | ||
| { | ||
| switch (ev.EventType) | ||
| { | ||
| case KEY_EVENT: | ||
| auto chr = ev.KeyEvent.AsciiChar; | ||
| result ~= chr; | ||
| break; | ||
| case WINDOW_BUFFER_SIZE_EVENT: | ||
| wasResized = true; | ||
| break; | ||
| default: // we could process focus, etc. here, but we already | ||
| // get them inline via VT sequences | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| return result; | ||
| default: | ||
| return result; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Write output. | ||
| */ | ||
| void write(string s) | ||
| { | ||
| import std.utf; | ||
|
|
||
| wstring w = toUTF16(s); | ||
|
|
||
| WriteConsoleW(output, w.ptr, cast(uint) w.length, null, null); | ||
| } | ||
|
|
||
| Coord windowSize() | ||
| { | ||
| GetConsoleScreenBufferInfo(output, &oscreen); | ||
| return Coord(oscreen.dwSize.X, oscreen.dwSize.Y); | ||
| } | ||
|
|
||
| bool resized() | ||
| { | ||
| bool result = wasResized; | ||
| wasResized = false; | ||
| return result; | ||
| } | ||
|
|
||
| private: | ||
| HANDLE output; | ||
| HANDLE input; | ||
| HANDLE eventH; | ||
| DWORD omode; | ||
| DWORD imode; | ||
| CONSOLE_SCREEN_BUFFER_INFO oscreen; | ||
| bool started; | ||
| bool wasResized; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Windows KEY_EVENT_RECORD structure documentation - bKeyDown flag and character handling
💡 Result:
Short answer
- bKeyDown is TRUE when the key is pressed and FALSE when it is released; ReadConsoleInput/PeekConsoleInput will deliver both press and release KEY_EVENT_RECORDs. [1]
- The uChar union holds the translated character (UnicodeChar or AsciiChar). Printable characters are usually present in uChar on the key‑down event; non‑printable keys (Shift, Ctrl, arrows, etc.) typically have uChar == 0 and you can see character bytes only for events that produce characters. Use ReadConsoleInputW to get UTF‑16 characters. [1][2][3]
Notes / best practices
- If you only want character input, filter for KEY_EVENT records where bKeyDown is TRUE and uChar != 0. [1][3]
- ALT pressed/released by itself and Ctrl+C in processed input mode are handled specially by the system and may not be passed to the app. [1]
Sources
[1] Microsoft: KEY_EVENT_RECORD (Windows Console) — documentation of bKeyDown and uChar.
[2] Microsoft Q&A / ReadConsoleInput notes — uChar is nonzero only for printable characters; ReadConsoleInputA may return multi‑byte bytes for codepages.
[3] Stack Overflow — practical notes: ignore key‑up events and zero uChar, and prefer ReadConsoleInputW for Unicode.
🏁 Script executed:
# Check D core.sys.windows bindings for KEY_EVENT_RECORD and CONSOLE_SCREEN_BUFFER_INFO
fd -e d -x grep -l "KEY_EVENT_RECORD\|CONSOLE_SCREEN_BUFFER_INFO" {} \;Repository: gdamore/dcell
Length of output: 80
🌐 Web query:
CONSOLE_SCREEN_BUFFER_INFO structure - dwSize is buffer size, srWindow is visible window coordinates
💡 Result:
-
dwSize: a COORD giving the console screen buffer size in character columns and rows (i.e., total buffer width/height). [1]
-
srWindow: a SMALL_RECT that gives the visible window’s coordinates inside that buffer — Left/Top are the upper‑left cell, Right/Bottom the lower‑right cell; coordinates are in character‑cell units with origin (0,0) at the buffer’s upper‑left. You can modify srWindow and pass it to SetConsoleWindowInfo to move/resize/scroll the visible window. [1][2][3]
Sources: Microsoft Docs — CONSOLE_SCREEN_BUFFER_INFO; SMALL_RECT; GetConsoleScreenBufferInfo. [1][2][3]
🏁 Script executed:
# Search the D language runtime for the windows bindings
find . -path "*/core/sys/windows*" -name "*.d" 2>/dev/null | head -20Repository: gdamore/dcell
Length of output: 39
🏁 Script executed:
# Check if the repository has any Windows API structure definitions
rg "KEY_EVENT_RECORD|struct.*KEY_EVENT" -t dRepository: gdamore/dcell
Length of output: 39
🏁 Script executed:
# Look for how this code is used elsewhere or if there are tests
rg "WinTty\|windowSize\|KEY_EVENT" -t d -B 2 -A 2Repository: gdamore/dcell
Length of output: 39
Fix KEY_EVENT handling and window size calculation for correctness
The Windows TTY implementation has two bugs that affect functionality:
-
KEY_EVENT handling in
read
ReadConsoleInputdelivers both key-down and key-up events, and every event appendsAsciiChartoresultwithout filtering. According to Windows API documentation,bKeyDownis TRUE only on key press and FALSE on key release; additionally,uCharis typically zero for non-printable keys. The current code will emit spurious characters from key-up events and non-printable keys. Correct this by checkingif (ev.KeyEvent.bKeyDown && ev.KeyEvent.AsciiChar != 0)before appending. -
Window size vs buffer size in
windowSize()
GetConsoleScreenBufferInfopopulatesdwSizewith the total screen buffer dimensions, not the visible window. The visible window dimensions are insrWindow(aSMALL_RECTwithLeft,Top,Right,Bottomcoordinates). ReturnCoord(oscreen.srWindow.Right - oscreen.srWindow.Left + 1, oscreen.srWindow.Bottom - oscreen.srWindow.Top + 1)to get the actual on-screen dimensions. -
Error handling (optional)
Win32 calls ignore return values; adding minimal checks or asserts would catch failures during development.
🤖 Prompt for AI Agents
In source/dcell/termio.d around lines 494 to 674, the WinTty.read and windowSize
implementations are incorrect: modify KEY_EVENT handling to only append
characters when ev.KeyEvent.bKeyDown is true and ev.KeyEvent.AsciiChar != 0 to
avoid emitting key-up and non-printable events; change windowSize() to compute
visible dimensions from oscreen.srWindow as (Right - Left + 1, Bottom - Top + 1)
instead of using oscreen.dwSize; optionally add minimal checks of Win32 return
values (or asserts/logs) for ReadConsoleInput and GetConsoleScreenBufferInfo to
catch failures during development.
3cc6542 to
192496c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 Nitpick comments (1)
source/dcell/parser.d (1)
1112-1141: Control-char remapping innewKeyEventlooks correct; consider guarding the implicitdchassumptions and adding testsThe new
if (dch < ' ' && k < Key.rune)block nicely normalizes low ASCII control characters coming in as “raw keycodes” (win32-input-mode, modifyOtherKeys, etc.) into the same shapes as the legacy POSIX path:
- CR/LF →
Key.enter- TAB →
Key.tab- BS →
Key.backspace- ESC →
Key.esc- control-space →
Key.rune+Modifiers.ctrl+' '- other control chars →
Key.rune+Modifiers.ctrl+dch + '\x60'(e.g. ctrl+A →awith CTRL)One subtle point: the
defaultbranch relies ondchalready holding the control code (1–26, …) when it fires. That’s true for the win32handleWinKeyctrl-letter path (where you pass the control code indch), but not for callers that use the(cast(Key)code, 0)pattern (like the modifyOtherKeys path does for some control chars). If any future caller ever sends(cast(Key)X, 0)for a control other than the explicitly handled ones (0xd, 0xa, 0x9, 0x8, 0x1b, 0), this branch would compute the transformed rune fromdch == 0, not from thekcode, and quietly generate the wrong character.Two lightweight tweaks that would make this more robust:
- Either assert or document that, when falling into the
defaultcase,dchis expected to be the control code (non‑zero), and that callers using(cast(Key)code, 0)must be covered by one of the explicit cases; or- Derive the transformed rune from the keycode when
dch == 0, e.g.:- default: - // most likely entered with a CTRL keypress - k = Key.rune; - mod |= Modifiers.ctrl; - dch = dch + '\x60'; - break; + default: + // most likely entered with a CTRL keypress + k = Key.rune; + mod |= Modifiers.ctrl; + auto code = cast(int) k; + if (dch == 0) { + // fall back to keycode when callers only encoded the control in `k` + dch = cast(dchar)(code + '\x60'); + } else { + dch = dch + '\x60'; + } + break;And to lock in the intended behavior for the win32 path, you might add a couple of small unittests in the existing
unittestblock:
- A synthetic “ctrl+A” win32 case:
newKeyEvent(cast(Key)'A', 1, Modifiers.none)should yieldkey == Key.rune,ch == 'a',modhasModifiers.ctrl.- A control-space case from a CSI/modifyOtherKeys source:
newKeyEvent(cast(Key)0, 0, Modifiers.none)should yieldkey == Key.rune,ch == ' ',modhasModifiers.ctrl.This keeps the fix targeted while making future changes around new input modes less fragile.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
source/dcell/parser.d(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: gdamore
Repo: gdamore/dcell PR: 27
File: source/dcell/ttyscreen.d:763-766
Timestamp: 2025-12-08T19:10:45.974Z
Learning: In source/dcell/ttyscreen.d, the newTtyScreen() factory function intentionally does not accept a term parameter because the TtyScreen constructor handles environment variable resolution internally with an optional term parameter. Users needing to override the terminal type should call the constructor directly: new TtyScreen(newDevTty(), "custom-term").
This mostly adds a new TtyImpl for Windows, but it contains a necessary fix for control key handling found when testing Windows Terminal, and does a minor clean up.
This has been tested with Windows 11 Terminal, Alacritty, and WezTerm on Windows.
Summary by CodeRabbit
New Features
Bug Fixes
Refactor
✏️ Tip: You can customize this high-level summary in your review settings.