Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@gdamore
Copy link
Owner

@gdamore gdamore commented Dec 10, 2025

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

    • Full Windows support for terminal and screen management
    • Improved Windows keyboard/input handling and control-key processing
  • Bug Fixes

    • Fixed Windows terminal initialization that previously failed
    • Refined key event handling for better control-character recognition
  • Refactor

    • Removed the public API for toggling TTY blocking mode (behavior consolidated)

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 10, 2025

Walkthrough

Adds 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

Cohort / File(s) Summary
Windows terminal implementation
source/dcell/termio.d
Added a Windows-specific WinTty : TtyImpl with Win32 API bindings (ReadConsoleInputW, GetConsoleMode/SetConsoleMode, WriteConsoleW, GetConsoleScreenBufferInfo), lifecycle and I/O methods, resize/event handling, and Windows newDevTty() factory overload. Removed blocking(bool) from TtyImpl.
Windows screen init
source/dcell/common.d
Windows version block now imports dcell.ttyscreen and Screen newScreen() returns newTtyScreen() (removed std.exception import and the previous Windows error path).
Control-character key parsing
source/dcell/parser.d
Changed Windows key handling: preserve and map control characters (dch < ' ') to explicit keys (Enter/Tab/Backspace/Esc) or to CTRL-modified runes (apply Modifiers.ctrl, adjust k/dch) instead of forcing chr to zero.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • Review Win32 API usage (ReadConsoleInputW, WriteConsoleW, mode management, UTF-16 conversions)
  • Validate new WinTty lifecycle and thread/event interactions (resize signaling)
  • Audit removal of TtyImpl.blocking(bool) for remaining call sites and interface compatibility
  • Verify parser changes correctly map control characters across platforms

Possibly related PRs

Poem

🐰
I hopped into code with a nibble and cheer,
Windows consoles now whisper — the path is clear.
Keys and resizes dance in a neat little spin,
From tty to screen the events flow in.
Hooray — terminals hum, let the rabbit grin! 🥕✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add Windows support' directly and clearly summarizes the main change: introducing Windows platform support to dcell through new TtyImpl implementation and related infrastructure.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch gdamore/windows

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 newKeyEvent is called for arrows/function keys, dch is 0, so the new if (dch < ' ') block runs and rewrites keys like Key.right into a CTRL-modified Key.rune, which is why the \x1bOC unittest 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 Windows

On Posix, newDevTty is TtyImpl newDevTty(string dev = "/dev/tty"), while on Windows it’s TtyImpl newDevTty(); this works fine for newTtyScreen() (which calls newDevTty() 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 dummy string dev = null parameter 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

📥 Commits

Reviewing files that changed from the base of the PR and between b9be06d and 3cc6542.

📒 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.d
  • source/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.

Comment on lines +494 to +674
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -20

Repository: 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 d

Repository: 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 2

Repository: 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:

  1. KEY_EVENT handling in read
    ReadConsoleInput delivers both key-down and key-up events, and every event appends AsciiChar to result without filtering. According to Windows API documentation, bKeyDown is TRUE only on key press and FALSE on key release; additionally, uChar is typically zero for non-printable keys. The current code will emit spurious characters from key-up events and non-printable keys. Correct this by checking if (ev.KeyEvent.bKeyDown && ev.KeyEvent.AsciiChar != 0) before appending.

  2. Window size vs buffer size in windowSize()
    GetConsoleScreenBufferInfo populates dwSize with the total screen buffer dimensions, not the visible window. The visible window dimensions are in srWindow (a SMALL_RECT with Left, Top, Right, Bottom coordinates). Return Coord(oscreen.srWindow.Right - oscreen.srWindow.Left + 1, oscreen.srWindow.Bottom - oscreen.srWindow.Top + 1) to get the actual on-screen dimensions.

  3. 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 in newKeyEvent looks correct; consider guarding the implicit dch assumptions and adding tests

The 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 → a with CTRL)

One subtle point: the default branch relies on dch already holding the control code (1–26, …) when it fires. That’s true for the win32 handleWinKey ctrl-letter path (where you pass the control code in dch), 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 from dch == 0, not from the k code, and quietly generate the wrong character.

Two lightweight tweaks that would make this more robust:

  • Either assert or document that, when falling into the default case, dch is 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 unittest block:

  • A synthetic “ctrl+A” win32 case: newKeyEvent(cast(Key)'A', 1, Modifiers.none) should yield key == Key.rune, ch == 'a', mod has Modifiers.ctrl.
  • A control-space case from a CSI/modifyOtherKeys source: newKeyEvent(cast(Key)0, 0, Modifiers.none) should yield key == Key.rune, ch == ' ', mod has Modifiers.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

📥 Commits

Reviewing files that changed from the base of the PR and between 3cc6542 and 192496c.

📒 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").

@gdamore gdamore merged commit 192496c into main Dec 10, 2025
3 checks passed
@gdamore gdamore deleted the gdamore/windows branch December 10, 2025 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants