Console
Console writes UTF-8 bytes to standard output or standard error, optionally with one of sixteen foreground colours. The surface is a handful of free functions; none of them allocates, and none of them reports a failure.
Colour is resolved once, per stream, when you call ConsoleInitialize. A write never re-decides — it emits escape codes or it does not.
Writing
Section titled “Writing”int main(){ ConsoleInitialize();
ConsoleWriteLine(ConsoleStream::Stdout, Str8FromCString("scene loaded")); ConsoleWrite(ConsoleStream::Stderr, Str8FromCString("missing texture\n"), ConsoleColor::Red, true);}ConsoleWrite emits the bytes verbatim. ConsoleWriteLine appends one '\n' and nothing else — no carriage return on Windows, on either stream. The colour overload wraps the text in an ANSI foreground code and a reset, for that one write.
Text is UTF-8 because String8 is UTF-8. Nothing is transcoded on the way out; the bytes you pass are the bytes the stream receives.
ConsoleInitialize belongs in main, before the first write. It is idempotent, and writing before it is safe — colour is simply off until it has run.
The colour policy
Section titled “The colour policy”ConsoleInitialize resolves each stream independently, in this order:
| Condition | Result |
|---|---|
FORCE_COLOR is set to a non-empty value |
Colour on |
Otherwise, NO_COLOR is set to a non-empty value |
Colour off |
| Otherwise | Colour follows whether the stream is a terminal |
A variable counts only when its value is non-empty, and the value itself is not read: NO_COLOR=0 disables colour, while NO_COLOR= (set but empty) counts as unset.
A stream that resolves to “on” still has to be capable. On Windows, enabling colour means putting a real console into virtual-terminal mode, and if that fails the stream stays plain. IsConsoleColorEnabled reports the answer after that second step, so it tells you what will actually be emitted rather than what was asked for.
SetConsoleColorEnabled overrides the resolution — this is where a --no-color flag of your own goes. It applies to both streams at once, and enabling is still subject to the same platform capability check.
ConsoleInitialize();if (options.no_color){ SetConsoleColorEnabled(false);}IsConsoleTty answers the separate question of whether the stream is attached to a terminal at all, which is what you want for deciding on progress bars or column widths rather than on colour.
What one write guarantees against another thread
Section titled “What one write guarantees against another thread”Every Console function may be called from any thread. There is no lock. Instead, each call hands the operating system one write: the colour escape, the text, the reset and the newline are composed into a buffer first.
| Call | Underlying writes |
|---|---|
ConsoleWrite(stream, text) |
One |
ConsoleWriteLine(stream, text) |
One when text and newline fit 4 KiB, otherwise two |
ConsoleWrite(stream, text, colour, bold) with colour off, or Default and not bold |
One |
ConsoleWrite(stream, text, colour, bold) with colour on |
One when escapes and text fit 4 KiB, otherwise three |
So two threads writing lines do not interleave inside each other’s lines, and a coloured run keeps its escapes. A composed message over 4 KiB is written in several pieces and can interleave with a concurrent writer. Order between threads is not defined: the write that reaches the OS first appears first.
A write that the OS accepts only in part, or that a signal interrupts, is resumed until every byte is out or the stream reports an error.
If output over 4 KiB must stay in one piece, or several calls must stay together, hold a Mutex around them, or funnel output through one thread.
The colour state itself is race-free — the per-stream flags are atomic — so calling SetConsoleColorEnabled while another thread writes is not a data race. It does not tell you which side of the change that write landed on.
Compose first, write once
Section titled “Compose first, write once”Building text and emitting it are separate jobs. Console only does the second one: it has no format overload, and it never takes a variable argument list.
Text is assembled with a String8List on an arena, joined once, and written once. A message up to 4 KiB then reaches the stream as a single write.
ScratchScope scratch;Arena& arena = scratch.GetArena();
String8List report = {};Str8ListPushFormat(arena, report, "%llu meshes, ", mesh_count);Str8ListPushFormat(arena, report, "%.2f ms\n", frame_milliseconds);
ConsoleWrite(ConsoleStream::Stdout, Str8ListJoin(arena, report));A function that produces text should return it — a String8, or a String8List it filled — rather than name a ConsoleStream in its own signature. A producer that writes to the console cannot be tested without one, and cannot be redirected to a file or a buffer later without changing its signature.
Failure is not reported
Section titled “Failure is not reported”Every write returns void. A stdout that has gone away — a closed pipe, a full device — produces no error and no exception, because there is nothing a caller could usefully do with one. This is a deliberate, narrow carve-out from Basalt’s rule that a fallible operation returns a Result, and it is shared with only a handful of teardown operations.
When this is the wrong tool
Section titled “When this is the wrong tool”| Situation | What to reach for |
|---|---|
| Levels, scopes, filtering, structured fields | Not this layer — logging is a separate module, not in v0.1 |
| Formatting values into text | The string layer: Str8ListPushFormat, Str8ListJoin |
| Reading input | Not this layer, and not planned here |
| Cursor movement, alternate screen, raw mode | Not this layer — emit your own escape sequences after checking IsConsoleTty |
| Keeping several calls, or output over 4 KiB, together across threads | Your own Mutex around the calls, or one writer thread |
| Background colours, underline, 256-colour or true colour | Not in the current set — sixteen foreground colours and bold |
| A component that should not know where its output goes | Return the text; let the caller write it |
Console may later be reframed as one concrete sink among several, sitting on a buffered writer that also serves files and in-memory buffers; nothing about that is designed or promised yet.
API summary
Section titled “API summary”Parameter contracts are documented in basalt/core/platform/Console.h; the normative contract is in docs/specs/console.md.
Setup and state
Section titled “Setup and state”| Signature | Purpose |
|---|---|
ConsoleInitialize() |
Detects terminals and resolves the colour policy. On a Windows console, sets the UTF-8 code page and enables virtual-terminal mode. Idempotent. |
SetConsoleColorEnabled(enabled) |
Forces colour on or off for both streams, overriding the policy. |
IsConsoleColorEnabled(stream) |
Whether escape codes are actually emitted for that stream. |
IsConsoleTty(stream) |
Whether the stream is attached to a terminal. |
Writing
Section titled “Writing”| Signature | Purpose |
|---|---|
ConsoleWrite(stream, text) |
Writes UTF-8 bytes verbatim. One underlying write. |
ConsoleWriteLine(stream, text) |
Writes the bytes, then one '\n', as one write up to 4 KiB. |
ConsoleWrite(stream, text, foreground, bold) |
Applies a foreground colour and optional bold for this write, then resets. One write up to 4 KiB. |
| Type | Values |
|---|---|
ConsoleStream |
Stdout, Stderr. |
ConsoleColor |
Default, the eight standard colours, and their eight Bright counterparts. Default leaves the terminal’s own foreground alone. |