"Just Works" 24bit Color Cross-Platform Terminal Output - why not?

Hello all,

It seems to me very strange that zig, by default, doesn’t appear to have an out-of-the-box cross-platform terminal capability that is useful for TUI for ANSI encoded outputs: stdout.write does not appear up to snuff.

OSX and Linux look great where stock terminals “just work.”

However, MS WIN seems to be a wholly different beast, appearing to require WriteConsoleA or WriteConsoleN API and lots of prep work for Microsoft’s solutions - CMD, PowerShell, etc.

What are our thoughts?

It is kinda driving me crazy that to write cross-platform zig TUI, it appears as if we need numerous if (builtin.os.tag == .windows) { conditionals…this level of detail gates zig’s cross-platform utility…a shift from pythons “write once, run anywhere” to more of a C-style “yeah, but…that’s a you problem.”

My 2 cents are that supporting that is up to TUI libraries and whether they want to spend time on supporting windows.

I think that is why a lot of people will just settle with using that and being fine with it not working everywhere. Sure you can be critical and say it should run properly on windows too, but in the end it is up to whoever writes the thing and whether they want to deal with that or not.

I don’t think we can expect batteries included in the standard library for everything (or a lot) until the language develops further, so I think having support for additional things as community libraries is fine.

I also don’t agree with the whole “the standard library should have everything”, there are multiple different TUI libraries (that probably have different approaches and goals), seems difficult to pick one in particular over another and the standard library isn’t focused on “being organized for 1.0” (for that I might agree that it would be nice to add some basic compatibility shims that work for the simple cases).

2 Likes

Sure you can be critical and say it should run properly on windows too,

We def agree here – stdout.write for MS WIN does not work the way it is intended. It is broken.

In the event you haven’t tried, it is not a question of choice. There is no other option but link to the Win32 API, which is something that SHOULD be in the standard library.

Secondly, the value of cross-architecture compilation is limited. Who cares? I can already write bespoke code for bespoke platforms without zig.

The value of cross-platform – such as stdout.write, write once, run everywhere - is limitless.

I haven’t tried, haven’t touched windows in a while, do you mean general output is broken, or color console rendering?

1 Like

For latest windows 10 or windows 11 you can use escape sequences for anything, including setting color.
You must call SetConsoleMode to enable output escape sequences.

For best compatibility, include a manifest resource with your application that sets the code page to utf8.
Also read the Microsoft Console Roadmap and Console APIs versus Virtual Terminal Sequences:

Our recommendation is to replace the classic Windows Console API with virtual terminal sequences.

3 Likes

do you mean general output is broken, or color console rendering?

In my view, not rendering ANSI codes is broken, because the console CAN, but with Zig stdout it doesn’t. And also, UTF8.

Shouldn’t stdout just…do this, for us?

Write once, run anywhere…if zig code works in OSX and Linux, and on a Microsoft architecture, if could, but doesn’t, would we agree it’s nott a Microsoft problem, but a zig std one?

Just seems crazy to me to do some basic console output I have to wire into WinAPI and all the fun that entails.

No, at least not always, because someone might don’t want the unix way but the windows way.

Even if someone wants to have the unix way in Windows, it is not easy. For example see #16526

I agree, there must be a better way.

No, at least not always, because someone might don’t want the unix way but the windows way.

Well, I was thinking this is where writers would have branching, to help resolve ambiguity!

var stdout: std.fs.File.Writer = std.io.getStdOut().writer();
const tty = stdout.context.handle;  // appropriate to Windows -or- MacOS -or- Linux

stdout.write(...) // If windows && ( cmd || ps) ; UF8 enabled, ANSi sequence activated, 

So the invoking function doesn’t have to care, but stdout write would a great deal, and could mature over time. It would also help clarify, I am unsure if the handle in windows is accurate or not; because I can’t invoke stdout w/ANSI codes, I have to worry about such things.

There is also the lldb use case as well, which appears to need a tty sourced from the device.

stdout.write should write bytes to stdout, unmodified from what you pass to it.

On Windows, you have to tell stdout that you are sending ansi escape sequences. You might even have to tell it you have UTF-8.

On POSIX, you have to set a bunch of IOCTLs so that ‘\n’ doesn’t move the cursor to column 0. You don’t have to do this on Windows.

Both platforms require a level of normalization to something that you want. But in all cases: write performs the same. It’s the underlying handle that has the state.

6 Likes

For a cross platform tty writer, you can pull the tty structs from libvaxis.

const writer = try vaxis.Tty.init();
defer writer.deinit();
try writer.anyWriter().writeAll("\x1b[38:2:30:30:30mHello, World!");

This will do what you are looking for on both posix and windows systems.

4 Likes

With only the standard library:

const stdout_file = std.io.getStdOut();
const supports_ansi_escapes = stdout_file.getOrEnableAnsiEscapeSupport();

var stdout = stdout_file.writer();
if (supports_ansi_escapes) {
    try stdout.writeAll("\x1b[36mHello, World!\x1b[0m");
}

or

const stdout_file = std.io.getStdOut();
// This will call `getOrEnableAnsiEscapeSupport` and check env vars, etc
const stdout_config = std.io.tty.detectConfig(stdout_file);

const stdout = stdout_file.writer();
try stdout_config.setColor(stdout, .cyan);
try stdout.writeAll("Hello, World!");
try stdout_config.setColor(stdout, .reset);

(the second example will work even on Windows consoles that do not support ANSI escape codes, as it’ll fall back to SetConsoleTextAttribute)

Relevant docs:

8 Likes

According to getOrEnableAnsiEscapeSupport docs (and my experience on Windows), ANSI escape codes are enabled by default when using the Windows Terminal. It’s not as great as the better terminals on other platforms, but still better than the old command prompt.

1 Like

vaxis is wonderful! Thank you for this lib. I was wishing the cfg/setup portion of vaxis was part of std when writing this post.

For my specific use case, i must use no dependencies, so that the source repo can be cloned + built with no, other requirements; in every other TUI project, I use vaxis lol!

My next stop was evaluating vaxis source to see how I could improve my solution.

stdout_file.getOrEnableAnsiEscapeSupport();

Awesome, thank you thank you. I will try getOrEnableAnsiEscapeSupport next.

For Windows to emit ANSI properly, I believed I had to abandon the stdout writer altogether, as CMD.EXE rendered the ANSI codes directly to screen unless invoked direct via Win API. The linux path also needed a tty handle fallback for lldb use-cases, and i just feel like these should…work.


// error handling and structures snipped for clarity

pub fn getTermSz() !TermSz {
    if (builtin.os.tag == .windows) {
        var info: win32.CONSOLE_SCREEN_BUFFER_INFO = undefined;

        g_tty_win = std.os.windows.GetStdHandle(win32.STD_OUTPUT_HANDLE) 
        rv = win32.GetConsoleScreenBufferInfo(g_tty_win, &info);
        rv = win32.SetConsoleMode(g_tty_win, win32.ENABLE_PROCESSED_OUTPUT | win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING);
        rv = win32.SetConsoleOutputCP(win32.CP_UTF8);
 
        ... error handling snipped ...
 } 
  else { 
    // linux path
    const tty_nix = stdout.context.handle;
    const rv = std.c.ioctl(tty_nix, TIOCGWINSZ, @intFromPtr(&winsz));

    if (rv >= 0) {
       if (winsz.ws_row == 0 or winsz.ws_col == 0) { // ltty IOCTL failed ie in lldb
          var lldb_tty_nix = try std.fs.cwd().openFile("/dev/tty", .{});
          const lldb_rv = std.c.ioctl(lldb_tty_nix, TIOCGWINSZ, @intFromPtr(&winsz));
          ....
       }
    }
 }

pub fn emit(s: []const u8) !void {
    if (builtin.os.tag == .windows) {
        var sz: win32.DWORD = 0;
        const rv = win32.WriteConsoleA(g_tty_win, s.ptr, @intCast(s.len), &sz, undefined);
        ...
   }  
   else {
       const sz = try stdout.write(s);
       ...
   } 
}