From 30c1b887414197268bc866aec770786023bd457e Mon Sep 17 00:00:00 2001 From: Feoramund <161657516+Feoramund@users.noreply.github.com> Date: Tue, 20 May 2025 15:32:01 -0400 Subject: [PATCH 1/7] Add `core:terminal` --- core/terminal/doc.odin | 4 ++ core/terminal/terminal.odin | 104 ++++++++++++++++++++++++++++ core/terminal/terminal_posix.odin | 9 +++ core/terminal/terminal_windows.odin | 9 +++ examples/all/all_main.odin | 3 + 5 files changed, 129 insertions(+) create mode 100644 core/terminal/doc.odin create mode 100644 core/terminal/terminal.odin create mode 100644 core/terminal/terminal_posix.odin create mode 100644 core/terminal/terminal_windows.odin diff --git a/core/terminal/doc.odin b/core/terminal/doc.odin new file mode 100644 index 000000000..490e9d398 --- /dev/null +++ b/core/terminal/doc.odin @@ -0,0 +1,4 @@ +/* +This package is for interacting with the command line interface of the system. +*/ +package terminal diff --git a/core/terminal/terminal.odin b/core/terminal/terminal.odin new file mode 100644 index 000000000..fae6f880a --- /dev/null +++ b/core/terminal/terminal.odin @@ -0,0 +1,104 @@ +package terminal + +import "core:os" +import "core:strings" + +/* +This describes the range of colors that a terminal is capable of supporting. +*/ +Color_Depth :: enum { + None, // No color support + Three_Bit, // 8 colors + Four_Bit, // 16 colors + Eight_Bit, // 256 colors + True_Color, // 24-bit true color +} + +/* +Returns true if the file `handle` is attached to a terminal. + +This is normally true for `os.stdout` and `os.stderr` unless they are +redirected to a file. +*/ +@(require_results) +is_terminal :: proc(handle: os.Handle) -> bool { + return _is_terminal(handle) +} + +/* +Get the color depth support for the terminal. +*/ +@(require_results) +get_color_depth :: proc() -> Color_Depth { + // Reference documentation: + // + // - [[ https://no-color.org/ ]] + // - [[ https://github.com/termstandard/colors ]] + // - [[ https://invisible-island.net/ncurses/terminfo.src.html ]] + + // Respect `NO_COLOR` above all. + if no_color, ok := os.lookup_env("NO_COLOR"); ok { + defer delete(no_color) + if no_color != "" { + return .None + } + } + + // `COLORTERM` is non-standard but widespread and unambiguous. + if colorterm, ok := os.lookup_env("COLORTERM"); ok { + defer delete(colorterm) + // These are the only values that are typically advertised that have + // anything to do with color depth. + if colorterm == "truecolor" || colorterm == "24bit" { + return .True_Color + } + } + + if term, ok := os.lookup_env("TERM"); ok { + defer delete(term) + if strings.contains(term, "-truecolor") { + return .True_Color + } + if strings.contains(term, "-256color") { + return .Eight_Bit + } + if strings.contains(term, "-16color") { + return .Four_Bit + } + + // The `terminfo` database, which is stored in binary on *nix + // platforms, has an undocumented format that is not guaranteed to be + // portable, so beyond this point, we can only make safe assumptions. + // + // This section should only be necessary for terminals that do not + // define any of the previous environment values. + // + // Only a small sampling of some common values are checked here. + switch term { + case "ansi": fallthrough + case "konsole": fallthrough + case "putty": fallthrough + case "rxvt": fallthrough + case "rxvt-color": fallthrough + case "screen": fallthrough + case "st": fallthrough + case "tmux": fallthrough + case "vte": fallthrough + case "xterm": fallthrough + case "xterm-color": + return .Three_Bit + } + } + + return .None +} + +/* +This is true if the terminal is accepting any form of colored text output. +*/ +color_enabled: bool + +@(init, private) +init_terminal_status :: proc() { + color_enabled = get_color_depth() > .None +} diff --git a/core/terminal/terminal_posix.odin b/core/terminal/terminal_posix.odin new file mode 100644 index 000000000..adfb6a0da --- /dev/null +++ b/core/terminal/terminal_posix.odin @@ -0,0 +1,9 @@ +#+build linux, darwin, netbsd, openbsd, freebsd, haiku +package terminal + +import "core:os" +import "core:sys/posix" + +_is_terminal :: proc(handle: os.Handle) -> bool { + return bool(posix.isatty(posix.FD(handle))) +} diff --git a/core/terminal/terminal_windows.odin b/core/terminal/terminal_windows.odin new file mode 100644 index 000000000..caab87cc7 --- /dev/null +++ b/core/terminal/terminal_windows.odin @@ -0,0 +1,9 @@ +package terminal + +import "core:os" +import "core:sys/windows" + +_is_terminal :: proc(handle: os.Handle) -> bool { + mode: windows.DWORD + return bool(windows.GetConsoleMode(windows.HANDLE(handle), &mode)) +} diff --git a/examples/all/all_main.odin b/examples/all/all_main.odin index 0a17227b8..97ecfee45 100644 --- a/examples/all/all_main.odin +++ b/examples/all/all_main.odin @@ -129,6 +129,8 @@ import strings "core:strings" import sync "core:sync" import testing "core:testing" +import terminal "core:terminal" + import edit "core:text/edit" import i18n "core:text/i18n" import match "core:text/match" @@ -257,6 +259,7 @@ _ :: strconv _ :: strings _ :: sync _ :: testing +_ :: terminal _ :: scanner _ :: i18n _ :: match From df5e64beebd02971292838acc4150471620bae24 Mon Sep 17 00:00:00 2001 From: Feoramund <161657516+Feoramund@users.noreply.github.com> Date: Tue, 20 May 2025 15:35:11 -0400 Subject: [PATCH 2/7] Add terminal color detection to `core:log` --- core/log/file_console_logger.odin | 62 ++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/core/log/file_console_logger.odin b/core/log/file_console_logger.odin index 6d93fb879..f807f321f 100644 --- a/core/log/file_console_logger.odin +++ b/core/log/file_console_logger.odin @@ -2,10 +2,12 @@ #+build !orca package log +import "base:runtime" import "core:encoding/ansi" import "core:fmt" import "core:strings" import "core:os" +import "core:terminal" import "core:time" Level_Headers := [?]string{ @@ -37,11 +39,36 @@ File_Console_Logger_Data :: struct { ident: string, } +@(private) global_subtract_stdout_options: Options +@(private) global_subtract_stderr_options: Options + +@(init, private) +init_standard_stream_status :: proc() { + // NOTE(Feoramund): While it is technically possible for these streams to + // be redirected during the runtime of the program, the cost of checking on + // every single log message is not worth it to support such an + // uncommonly-used feature. + if terminal.color_enabled { + // This is done this way because it's possible that only one of these + // streams could be redirected to a file. + if !terminal.is_terminal(os.stdout) { + global_subtract_stdout_options = {.Terminal_Color} + } + if !terminal.is_terminal(os.stderr) { + global_subtract_stderr_options = {.Terminal_Color} + } + } else { + // Override any terminal coloring. + global_subtract_stdout_options = {.Terminal_Color} + global_subtract_stderr_options = {.Terminal_Color} + } +} + create_file_logger :: proc(h: os.Handle, lowest := Level.Debug, opt := Default_File_Logger_Opts, ident := "", allocator := context.allocator) -> Logger { data := new(File_Console_Logger_Data, allocator) data.file_handle = h data.ident = ident - return Logger{file_console_logger_proc, data, lowest, opt} + return Logger{file_logger_proc, data, lowest, opt} } destroy_file_logger :: proc(log: Logger, allocator := context.allocator) { @@ -56,19 +83,15 @@ create_console_logger :: proc(lowest := Level.Debug, opt := Default_Console_Logg data := new(File_Console_Logger_Data, allocator) data.file_handle = os.INVALID_HANDLE data.ident = ident - return Logger{file_console_logger_proc, data, lowest, opt} + return Logger{console_logger_proc, data, lowest, opt} } destroy_console_logger :: proc(log: Logger, allocator := context.allocator) { free(log.data, allocator) } -file_console_logger_proc :: proc(logger_data: rawptr, level: Level, text: string, options: Options, location := #caller_location) { - data := cast(^File_Console_Logger_Data)logger_data - h: os.Handle = os.stdout if level <= Level.Error else os.stderr - if data.file_handle != os.INVALID_HANDLE { - h = data.file_handle - } +@(private) +_file_console_logger_proc :: proc(h: os.Handle, ident: string, level: Level, text: string, options: Options, location: runtime.Source_Code_Location) { backing: [1024]byte //NOTE(Hoej): 1024 might be too much for a header backing, unless somebody has really long paths. buf := strings.builder_from_bytes(backing[:]) @@ -86,13 +109,32 @@ file_console_logger_proc :: proc(logger_data: rawptr, level: Level, text: string fmt.sbprintf(&buf, "[{}] ", os.current_thread_id()) } - if data.ident != "" { - fmt.sbprintf(&buf, "[%s] ", data.ident) + if ident != "" { + fmt.sbprintf(&buf, "[%s] ", ident) } //TODO(Hoej): When we have better atomics and such, make this thread-safe fmt.fprintf(h, "%s%s\n", strings.to_string(buf), text) } +file_logger_proc :: proc(logger_data: rawptr, level: Level, text: string, options: Options, location := #caller_location) { + data := cast(^File_Console_Logger_Data)logger_data + _file_console_logger_proc(data.file_handle, data.ident, level, text, options, location) +} + +console_logger_proc :: proc(logger_data: rawptr, level: Level, text: string, options: Options, location := #caller_location) { + options := options + data := cast(^File_Console_Logger_Data)logger_data + h: os.Handle = --- + if level < Level.Error { + h = os.stdout + options -= global_subtract_stdout_options + } else { + h = os.stderr + options -= global_subtract_stderr_options + } + _file_console_logger_proc(h, data.ident, level, text, options, location) +} + do_level_header :: proc(opts: Options, str: ^strings.Builder, level: Level) { RESET :: ansi.CSI + ansi.RESET + ansi.SGR From a9df1b1cde1037d030f4e823ce576dfd9bcf9c97 Mon Sep 17 00:00:00 2001 From: Feoramund <161657516+Feoramund@users.noreply.github.com> Date: Tue, 20 May 2025 15:41:35 -0400 Subject: [PATCH 3/7] Rename `core:encoding/ansi` to `core:terminal/ansi` --- core/log/file_console_logger.odin | 2 +- core/{encoding => terminal}/ansi/ansi.odin | 0 core/{encoding => terminal}/ansi/doc.odin | 0 core/testing/reporting.odin | 2 +- core/testing/runner.odin | 2 +- core/testing/signal_handler_libc.odin | 4 ++-- examples/all/all_main.odin | 4 ++-- 7 files changed, 7 insertions(+), 7 deletions(-) rename core/{encoding => terminal}/ansi/ansi.odin (100%) rename core/{encoding => terminal}/ansi/doc.odin (100%) diff --git a/core/log/file_console_logger.odin b/core/log/file_console_logger.odin index f807f321f..0fe5c3477 100644 --- a/core/log/file_console_logger.odin +++ b/core/log/file_console_logger.odin @@ -3,11 +3,11 @@ package log import "base:runtime" -import "core:encoding/ansi" import "core:fmt" import "core:strings" import "core:os" import "core:terminal" +import "core:terminal/ansi" import "core:time" Level_Headers := [?]string{ diff --git a/core/encoding/ansi/ansi.odin b/core/terminal/ansi/ansi.odin similarity index 100% rename from core/encoding/ansi/ansi.odin rename to core/terminal/ansi/ansi.odin diff --git a/core/encoding/ansi/doc.odin b/core/terminal/ansi/doc.odin similarity index 100% rename from core/encoding/ansi/doc.odin rename to core/terminal/ansi/doc.odin diff --git a/core/testing/reporting.odin b/core/testing/reporting.odin index 6752cd79b..7c7eb7b2d 100644 --- a/core/testing/reporting.odin +++ b/core/testing/reporting.odin @@ -10,12 +10,12 @@ package testing */ import "base:runtime" -import "core:encoding/ansi" import "core:fmt" import "core:io" import "core:mem" import "core:path/filepath" import "core:strings" +import "core:terminal/ansi" // Definitions of colors for use in the test runner. SGR_RESET :: ansi.CSI + ansi.RESET + ansi.SGR diff --git a/core/testing/runner.odin b/core/testing/runner.odin index db0587370..c81d07109 100644 --- a/core/testing/runner.odin +++ b/core/testing/runner.odin @@ -13,7 +13,6 @@ package testing import "base:intrinsics" import "base:runtime" import "core:bytes" -import "core:encoding/ansi" @require import "core:encoding/base64" @require import "core:encoding/json" import "core:fmt" @@ -25,6 +24,7 @@ import "core:os" import "core:slice" @require import "core:strings" import "core:sync/chan" +import "core:terminal/ansi" import "core:thread" import "core:time" diff --git a/core/testing/signal_handler_libc.odin b/core/testing/signal_handler_libc.odin index 281fbde40..d17a6d6dc 100644 --- a/core/testing/signal_handler_libc.odin +++ b/core/testing/signal_handler_libc.odin @@ -12,9 +12,9 @@ package testing import "base:intrinsics" import "core:c/libc" -import "core:encoding/ansi" -import "core:sync" import "core:os" +import "core:sync" +import "core:terminal/ansi" @(private="file") stop_runner_flag: libc.sig_atomic_t diff --git a/examples/all/all_main.odin b/examples/all/all_main.odin index 97ecfee45..de037f6cd 100644 --- a/examples/all/all_main.odin +++ b/examples/all/all_main.odin @@ -58,7 +58,6 @@ import trace "core:debug/trace" import dynlib "core:dynlib" import net "core:net" -import ansi "core:encoding/ansi" import base32 "core:encoding/base32" import base64 "core:encoding/base64" import cbor "core:encoding/cbor" @@ -130,6 +129,7 @@ import sync "core:sync" import testing "core:testing" import terminal "core:terminal" +import ansi "core:terminal/ansi" import edit "core:text/edit" import i18n "core:text/i18n" @@ -203,7 +203,6 @@ _ :: pe _ :: trace _ :: dynlib _ :: net -_ :: ansi _ :: base32 _ :: base64 _ :: csv @@ -260,6 +259,7 @@ _ :: strings _ :: sync _ :: testing _ :: terminal +_ :: ansi _ :: scanner _ :: i18n _ :: match From 3c40a54dcd9ff8e1b608fac5c91ed0f1d0ed7d00 Mon Sep 17 00:00:00 2001 From: Feoramund <161657516+Feoramund@users.noreply.github.com> Date: Tue, 20 May 2025 15:43:47 -0400 Subject: [PATCH 4/7] Add terminal color detection to logging in `core:testing` --- core/testing/runner.odin | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/core/testing/runner.odin b/core/testing/runner.odin index c81d07109..ff8ca00b9 100644 --- a/core/testing/runner.odin +++ b/core/testing/runner.odin @@ -24,6 +24,7 @@ import "core:os" import "core:slice" @require import "core:strings" import "core:sync/chan" +import "core:terminal" import "core:terminal/ansi" import "core:thread" import "core:time" @@ -70,6 +71,8 @@ get_log_level :: #force_inline proc() -> runtime.Logger_Level { } } +@(private) global_log_colors_disabled: bool + JSON :: struct { total: int, success: int, @@ -129,11 +132,16 @@ run_test_task :: proc(task: thread.Task) { context.assertion_failure_proc = test_assertion_failure_proc + logger_options := Default_Test_Logger_Opts + if global_log_colors_disabled { + logger_options -= {.Terminal_Color} + } + context.logger = { procedure = test_logger_proc, data = &data.t, lowest_level = get_log_level(), - options = Default_Test_Logger_Opts, + options = logger_options, } random_generator_state: runtime.Default_Random_State @@ -211,6 +219,8 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { stdout := io.to_writer(os.stream_from_handle(os.stdout)) stderr := io.to_writer(os.stream_from_handle(os.stderr)) + global_log_colors_disabled = !terminal.color_enabled || !terminal.is_terminal(os.stderr) + // -- Prepare test data. alloc_error: mem.Allocator_Error @@ -442,11 +452,16 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { // digging through the source to divine everywhere it is used for that. shared_log_allocator := context.allocator + logger_options := Default_Test_Logger_Opts - {.Short_File_Path, .Line, .Procedure} + if global_log_colors_disabled { + logger_options -= {.Terminal_Color} + } + context.logger = { procedure = runner_logger_proc, data = &log_messages, lowest_level = get_log_level(), - options = Default_Test_Logger_Opts - {.Short_File_Path, .Line, .Procedure}, + options = logger_options, } run_index: int From 1b407ef20789b1d04d61b0a53ca8c98fe6621db2 Mon Sep 17 00:00:00 2001 From: Feoramund <161657516+Feoramund@users.noreply.github.com> Date: Tue, 20 May 2025 16:17:14 -0400 Subject: [PATCH 5/7] Add animation detection support to test runner --- core/testing/runner.odin | 133 +++++++++++++++----------- core/testing/signal_handler_libc.odin | 8 +- 2 files changed, 80 insertions(+), 61 deletions(-) diff --git a/core/testing/runner.odin b/core/testing/runner.odin index ff8ca00b9..a184eb28c 100644 --- a/core/testing/runner.odin +++ b/core/testing/runner.odin @@ -45,6 +45,7 @@ PER_THREAD_MEMORY : int : #config(ODIN_TEST_THREAD_MEMORY, mem.ROLLBACK_S // The format is: `package.test_name,test_name_only,...` TEST_NAMES : string : #config(ODIN_TEST_NAMES, "") // Show the fancy animated progress report. +// This requires terminal color support, as well as STDOUT to not be redirected to a file. FANCY_OUTPUT : bool : #config(ODIN_TEST_FANCY, true) // Copy failed tests to the clipboard when done. USE_CLIPBOARD : bool : #config(ODIN_TEST_CLIPBOARD, false) @@ -72,6 +73,7 @@ get_log_level :: #force_inline proc() -> runtime.Logger_Level { } @(private) global_log_colors_disabled: bool +@(private) global_ansi_disabled: bool JSON :: struct { total: int, @@ -219,7 +221,12 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { stdout := io.to_writer(os.stream_from_handle(os.stdout)) stderr := io.to_writer(os.stream_from_handle(os.stderr)) + // The animations are only ever shown through STDOUT; + // STDERR is used exclusively for logging regardless of error level. global_log_colors_disabled = !terminal.color_enabled || !terminal.is_terminal(os.stderr) + global_ansi_disabled = !terminal.is_terminal(os.stdout) + + should_show_animations := FANCY_OUTPUT && terminal.color_enabled && !global_ansi_disabled // -- Prepare test data. @@ -278,12 +285,12 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { total_done_count := 0 total_test_count := len(internal_tests) - when !FANCY_OUTPUT { - // This is strictly for updating the window title when the progress - // report is disabled. We're otherwise able to depend on the call to - // `needs_to_redraw`. - last_done_count := -1 - } + + // This is strictly for updating the window title when the progress + // report is disabled. We're otherwise able to depend on the call to + // `needs_to_redraw`. + last_done_count := -1 + if total_test_count == 0 { // Exit early. @@ -352,31 +359,31 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { fmt.assertf(alloc_error == nil, "Error allocating memory for test report: %v", alloc_error) defer destroy_report(&report) - when FANCY_OUTPUT { - // We cannot make use of the ANSI save/restore cursor codes, because they - // work by absolute screen coordinates. This will cause unnecessary - // scrollback if we print at the bottom of someone's terminal. - ansi_redraw_string := fmt.aprintf( - // ANSI for "go up N lines then erase the screen from the cursor forward." - ansi.CSI + "%i" + ansi.CPL + ansi.CSI + ansi.ED + - // We'll combine this with the window title format string, since it - // can be printed at the same time. - "%s", - // 1 extra line for the status bar. - 1 + len(report.packages), OSC_WINDOW_TITLE) - assert(len(ansi_redraw_string) > 0, "Error allocating ANSI redraw string.") - defer delete(ansi_redraw_string) - thread_count_status_string: string = --- - { - PADDING :: PROGRESS_COLUMN_SPACING + PROGRESS_WIDTH + // We cannot make use of the ANSI save/restore cursor codes, because they + // work by absolute screen coordinates. This will cause unnecessary + // scrollback if we print at the bottom of someone's terminal. + ansi_redraw_string := fmt.aprintf( + // ANSI for "go up N lines then erase the screen from the cursor forward." + ansi.CSI + "%i" + ansi.CPL + ansi.CSI + ansi.ED + + // We'll combine this with the window title format string, since it + // can be printed at the same time. + "%s", + // 1 extra line for the status bar. + 1 + len(report.packages), OSC_WINDOW_TITLE) + assert(len(ansi_redraw_string) > 0, "Error allocating ANSI redraw string.") + defer delete(ansi_redraw_string) - unpadded := fmt.tprintf("%i thread%s", thread_count, "" if thread_count == 1 else "s") - thread_count_status_string = fmt.aprintf("%- *[1]s", unpadded, report.pkg_column_len + PADDING) - assert(len(thread_count_status_string) > 0, "Error allocating thread count status string.") - } - defer delete(thread_count_status_string) + thread_count_status_string: string = --- + { + PADDING :: PROGRESS_COLUMN_SPACING + PROGRESS_WIDTH + + unpadded := fmt.tprintf("%i thread%s", thread_count, "" if thread_count == 1 else "s") + thread_count_status_string = fmt.aprintf("%- *[1]s", unpadded, report.pkg_column_len + PADDING) + assert(len(thread_count_status_string) > 0, "Error allocating thread count status string.") } + defer delete(thread_count_status_string) + task_data_slots: []Task_Data = --- task_data_slots, alloc_error = make([]Task_Data, thread_count) @@ -496,11 +503,13 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { setup_signal_handler() - fmt.wprint(stdout, ansi.CSI + ansi.DECTCEM_HIDE) + if !global_ansi_disabled { + fmt.wprint(stdout, ansi.CSI + ansi.DECTCEM_HIDE) + } - when FANCY_OUTPUT { - signals_were_raised := false + signals_were_raised := false + if should_show_animations { redraw_report(stdout, report) draw_status_bar(stdout, thread_count_status_string, total_done_count, total_test_count) } @@ -718,22 +727,22 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { break main_loop } - when FANCY_OUTPUT { - // Because the bounds checking procs send directly to STDERR with - // no way to redirect or handle them, we need to at least try to - // let the user see those messages when using the animated progress - // report. This flag may be set by the block of code below if a - // signal is raised. - // - // It'll be purely by luck if the output is interleaved properly, - // given the nature of non-thread-safe printing. - // - // At worst, if Odin did not print any error for this signal, we'll - // just re-display the progress report. The fatal log error message - // should be enough to clue the user in that something dire has - // occurred. - bypass_progress_overwrite := false - } + + // Because the bounds checking procs send directly to STDERR with + // no way to redirect or handle them, we need to at least try to + // let the user see those messages when using the animated progress + // report. This flag may be set by the block of code below if a + // signal is raised. + // + // It'll be purely by luck if the output is interleaved properly, + // given the nature of non-thread-safe printing. + // + // At worst, if Odin did not print any error for this signal, we'll + // just re-display the progress report. The fatal log error message + // should be enough to clue the user in that something dire has + // occurred. + bypass_progress_overwrite := false + if test_index, reason, ok := should_stop_test(); ok { #no_bounds_check report.all_test_states[test_index] = .Failed @@ -767,7 +776,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { log.fatalf("Caught signal to stop test #%i %s.%s for: %v.", test_index, it.pkg, it.name, reason) } - when FANCY_OUTPUT { + if should_show_animations { bypass_progress_overwrite = true signals_were_raised = true } @@ -781,7 +790,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { // -- Redraw. - when FANCY_OUTPUT { + if should_show_animations { if len(log_messages) == 0 && !needs_to_redraw(report) { continue main_loop } @@ -791,7 +800,9 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { } } else { if total_done_count != last_done_count { - fmt.wprintf(stdout, OSC_WINDOW_TITLE, total_done_count, total_test_count) + if !global_ansi_disabled { + fmt.wprintf(stdout, OSC_WINDOW_TITLE, total_done_count, total_test_count) + } last_done_count = total_done_count } @@ -816,7 +827,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { clear(&log_messages) bytes.buffer_reset(&batch_buffer) - when FANCY_OUTPUT { + if should_show_animations { redraw_report(batch_writer, report) draw_status_bar(batch_writer, thread_count_status_string, total_done_count, total_test_count) fmt.wprint(stdout, bytes.buffer_to_string(&batch_buffer)) @@ -837,7 +848,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { finished_in := time.since(start_time) - when !FANCY_OUTPUT { + if !should_show_animations || !terminal.is_terminal(os.stderr) { // One line to space out the results, since we don't have the status // bar in plain mode. fmt.wprintln(batch_writer) @@ -851,24 +862,28 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { if total_done_count != total_test_count { not_run_count := total_test_count - total_done_count + message := " %i %s left undone." if global_log_colors_disabled else " " + SGR_READY + "%i" + SGR_RESET + " %s left undone." fmt.wprintf(batch_writer, - " " + SGR_READY + "%i" + SGR_RESET + " %s left undone.", + message, not_run_count, "test was" if not_run_count == 1 else "tests were") } if total_success_count == total_test_count { + message := " %s successful." if global_log_colors_disabled else " %s " + SGR_SUCCESS + "successful." + SGR_RESET fmt.wprintfln(batch_writer, - " %s " + SGR_SUCCESS + "successful." + SGR_RESET, + message, "The test was" if total_test_count == 1 else "All tests were") } else if total_failure_count > 0 { if total_failure_count == total_test_count { + message := " %s failed." if global_log_colors_disabled else " %s " + SGR_FAILED + "failed." + SGR_RESET fmt.wprintfln(batch_writer, - " %s " + SGR_FAILED + "failed." + SGR_RESET, + message, "The test" if total_test_count == 1 else "All tests") } else { + message := " %i test%s failed." if global_log_colors_disabled else " " + SGR_FAILED + "%i" + SGR_RESET + " test%s failed." fmt.wprintfln(batch_writer, - " " + SGR_FAILED + "%i" + SGR_RESET + " test%s failed.", + message, total_failure_count, "" if total_failure_count == 1 else "s") } @@ -922,9 +937,11 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { } } - fmt.wprint(stdout, ansi.CSI + ansi.DECTCEM_SHOW) + if !global_ansi_disabled { + fmt.wprint(stdout, ansi.CSI + ansi.DECTCEM_SHOW) + } - when FANCY_OUTPUT { + if should_show_animations { if signals_were_raised { fmt.wprintln(batch_writer, ` Signals were raised during this test run. Log messages are likely to have collided with each other. diff --git a/core/testing/signal_handler_libc.odin b/core/testing/signal_handler_libc.odin index d17a6d6dc..f9527e22f 100644 --- a/core/testing/signal_handler_libc.odin +++ b/core/testing/signal_handler_libc.odin @@ -63,9 +63,11 @@ stop_test_callback :: proc "c" (sig: libc.int) { // NOTE(Feoramund): Using these write calls in a signal handler is // undefined behavior in C99 but possibly tolerated in POSIX 2008. // Either way, we may as well try to salvage what we can. - show_cursor := ansi.CSI + ansi.DECTCEM_SHOW - libc.fwrite(raw_data(show_cursor), size_of(byte), len(show_cursor), libc.stdout) - libc.fflush(libc.stdout) + if !global_ansi_disabled { + show_cursor := ansi.CSI + ansi.DECTCEM_SHOW + libc.fwrite(raw_data(show_cursor), size_of(byte), len(show_cursor), libc.stdout) + libc.fflush(libc.stdout) + } // This is an attempt at being compliant by avoiding printf. sigbuf: [8]byte From b6f1821bbabbb711724d309e4c62d4c866d44c67 Mon Sep 17 00:00:00 2001 From: Feoramund <161657516+Feoramund@users.noreply.github.com> Date: Wed, 21 May 2025 05:20:58 -0400 Subject: [PATCH 6/7] Fix terminal detection on Windows --- core/terminal/terminal_windows.odin | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/terminal/terminal_windows.odin b/core/terminal/terminal_windows.odin index caab87cc7..55a3903fe 100644 --- a/core/terminal/terminal_windows.odin +++ b/core/terminal/terminal_windows.odin @@ -4,6 +4,6 @@ import "core:os" import "core:sys/windows" _is_terminal :: proc(handle: os.Handle) -> bool { - mode: windows.DWORD - return bool(windows.GetConsoleMode(windows.HANDLE(handle), &mode)) + is_tty := windows.GetFileType(windows.HANDLE(handle)) == windows.FILE_TYPE_CHAR + return is_tty } From e659df1a3f1b57ee67600cdacf75e672d8cd3d9b Mon Sep 17 00:00:00 2001 From: Feoramund <161657516+Feoramund@users.noreply.github.com> Date: Wed, 21 May 2025 07:49:08 -0400 Subject: [PATCH 7/7] Restructure `core:terminal` for better Windows support --- core/terminal/internal.odin | 87 +++++++++++++++++++++++++++++ core/terminal/terminal.odin | 78 ++------------------------ core/terminal/terminal_posix.odin | 7 +++ core/terminal/terminal_windows.odin | 51 +++++++++++++++++ core/testing/runner.odin | 8 --- core/testing/runner_windows.odin | 36 ------------ 6 files changed, 150 insertions(+), 117 deletions(-) create mode 100644 core/terminal/internal.odin delete mode 100644 core/testing/runner_windows.odin diff --git a/core/terminal/internal.odin b/core/terminal/internal.odin new file mode 100644 index 000000000..485f6868d --- /dev/null +++ b/core/terminal/internal.odin @@ -0,0 +1,87 @@ +#+private +package terminal + +import "core:os" +import "core:strings" + +// Reference documentation: +// +// - [[ https://no-color.org/ ]] +// - [[ https://github.com/termstandard/colors ]] +// - [[ https://invisible-island.net/ncurses/terminfo.src.html ]] + +get_no_color :: proc() -> bool { + if no_color, ok := os.lookup_env("NO_COLOR"); ok { + defer delete(no_color) + return no_color != "" + } + return false +} + +get_environment_color :: proc() -> Color_Depth { + // `COLORTERM` is non-standard but widespread and unambiguous. + if colorterm, ok := os.lookup_env("COLORTERM"); ok { + defer delete(colorterm) + // These are the only values that are typically advertised that have + // anything to do with color depth. + if colorterm == "truecolor" || colorterm == "24bit" { + return .True_Color + } + } + + if term, ok := os.lookup_env("TERM"); ok { + defer delete(term) + if strings.contains(term, "-truecolor") { + return .True_Color + } + if strings.contains(term, "-256color") { + return .Eight_Bit + } + if strings.contains(term, "-16color") { + return .Four_Bit + } + + // The `terminfo` database, which is stored in binary on *nix + // platforms, has an undocumented format that is not guaranteed to be + // portable, so beyond this point, we can only make safe assumptions. + // + // This section should only be necessary for terminals that do not + // define any of the previous environment values. + // + // Only a small sampling of some common values are checked here. + switch term { + case "ansi": fallthrough + case "konsole": fallthrough + case "putty": fallthrough + case "rxvt": fallthrough + case "rxvt-color": fallthrough + case "screen": fallthrough + case "st": fallthrough + case "tmux": fallthrough + case "vte": fallthrough + case "xterm": fallthrough + case "xterm-color": + return .Three_Bit + } + } + + return .None +} + +@(init) +init_terminal :: proc() { + _init_terminal() + + // We respect `NO_COLOR` specifically as a color-disabler but not as a + // blanket ban on any terminal manipulation codes, hence why this comes + // after `_init_terminal` which will allow Windows to enable Virtual + // Terminal Processing for non-color control sequences. + if !get_no_color() { + color_enabled = color_depth > .None + } +} + +@(fini) +fini_terminal :: proc() { + _fini_terminal() +} diff --git a/core/terminal/terminal.odin b/core/terminal/terminal.odin index fae6f880a..1e5566295 100644 --- a/core/terminal/terminal.odin +++ b/core/terminal/terminal.odin @@ -1,7 +1,6 @@ package terminal import "core:os" -import "core:strings" /* This describes the range of colors that a terminal is capable of supporting. @@ -25,80 +24,13 @@ is_terminal :: proc(handle: os.Handle) -> bool { return _is_terminal(handle) } -/* -Get the color depth support for the terminal. -*/ -@(require_results) -get_color_depth :: proc() -> Color_Depth { - // Reference documentation: - // - // - [[ https://no-color.org/ ]] - // - [[ https://github.com/termstandard/colors ]] - // - [[ https://invisible-island.net/ncurses/terminfo.src.html ]] - - // Respect `NO_COLOR` above all. - if no_color, ok := os.lookup_env("NO_COLOR"); ok { - defer delete(no_color) - if no_color != "" { - return .None - } - } - - // `COLORTERM` is non-standard but widespread and unambiguous. - if colorterm, ok := os.lookup_env("COLORTERM"); ok { - defer delete(colorterm) - // These are the only values that are typically advertised that have - // anything to do with color depth. - if colorterm == "truecolor" || colorterm == "24bit" { - return .True_Color - } - } - - if term, ok := os.lookup_env("TERM"); ok { - defer delete(term) - if strings.contains(term, "-truecolor") { - return .True_Color - } - if strings.contains(term, "-256color") { - return .Eight_Bit - } - if strings.contains(term, "-16color") { - return .Four_Bit - } - - // The `terminfo` database, which is stored in binary on *nix - // platforms, has an undocumented format that is not guaranteed to be - // portable, so beyond this point, we can only make safe assumptions. - // - // This section should only be necessary for terminals that do not - // define any of the previous environment values. - // - // Only a small sampling of some common values are checked here. - switch term { - case "ansi": fallthrough - case "konsole": fallthrough - case "putty": fallthrough - case "rxvt": fallthrough - case "rxvt-color": fallthrough - case "screen": fallthrough - case "st": fallthrough - case "tmux": fallthrough - case "vte": fallthrough - case "xterm": fallthrough - case "xterm-color": - return .Three_Bit - } - } - - return .None -} - /* This is true if the terminal is accepting any form of colored text output. */ color_enabled: bool -@(init, private) -init_terminal_status :: proc() { - color_enabled = get_color_depth() > .None -} +/* +This value reports the color depth support as reported by the terminal at the +start of the program. +*/ +color_depth: Color_Depth diff --git a/core/terminal/terminal_posix.odin b/core/terminal/terminal_posix.odin index adfb6a0da..f578e12c6 100644 --- a/core/terminal/terminal_posix.odin +++ b/core/terminal/terminal_posix.odin @@ -1,3 +1,4 @@ +#+private #+build linux, darwin, netbsd, openbsd, freebsd, haiku package terminal @@ -7,3 +8,9 @@ import "core:sys/posix" _is_terminal :: proc(handle: os.Handle) -> bool { return bool(posix.isatty(posix.FD(handle))) } + +_init_terminal :: proc() { + color_depth = get_environment_color() +} + +_fini_terminal :: proc() { } diff --git a/core/terminal/terminal_windows.odin b/core/terminal/terminal_windows.odin index 55a3903fe..cc28add98 100644 --- a/core/terminal/terminal_windows.odin +++ b/core/terminal/terminal_windows.odin @@ -1,3 +1,4 @@ +#+private package terminal import "core:os" @@ -7,3 +8,53 @@ _is_terminal :: proc(handle: os.Handle) -> bool { is_tty := windows.GetFileType(windows.HANDLE(handle)) == windows.FILE_TYPE_CHAR return is_tty } + +old_modes: [2]struct{ + handle: windows.DWORD, + mode: windows.DWORD, +} = { + {windows.STD_OUTPUT_HANDLE, 0}, + {windows.STD_ERROR_HANDLE, 0}, +} + +@(init) +_init_terminal :: proc() { + vtp_enabled: bool + + for &v in old_modes { + handle := windows.GetStdHandle(v.handle) + if handle == windows.INVALID_HANDLE || handle == nil { + return + } + if windows.GetConsoleMode(handle, &v.mode) { + windows.SetConsoleMode(handle, v.mode | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) + + new_mode: windows.DWORD + windows.GetConsoleMode(handle, &new_mode) + + if new_mode & windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 { + vtp_enabled = true + } + } + } + + if vtp_enabled { + // This color depth is available on Windows 10 since build 10586. + color_depth = .Four_Bit + } else { + // The user may be on a non-default terminal emulator. + color_depth = get_environment_color() + } +} + +@(fini) +_fini_terminal :: proc() { + for v in old_modes { + handle := windows.GetStdHandle(v.handle) + if handle == windows.INVALID_HANDLE || handle == nil { + return + } + + windows.SetConsoleMode(handle, v.mode) + } +} diff --git a/core/testing/runner.odin b/core/testing/runner.odin index a184eb28c..56d561d3d 100644 --- a/core/testing/runner.odin +++ b/core/testing/runner.odin @@ -214,10 +214,6 @@ runner :: proc(internal_tests: []Internal_Test) -> bool { } } - when ODIN_OS == .Windows { - console_ansi_init() - } - stdout := io.to_writer(os.stream_from_handle(os.stdout)) stderr := io.to_writer(os.stream_from_handle(os.stderr)) @@ -981,9 +977,5 @@ To partly mitigate this, redirect STDERR to a file or use the -define:ODIN_TEST_ fmt.assertf(err == nil, "Error writing JSON report: %v", err) } - when ODIN_OS == .Windows { - console_ansi_fini() - } - return total_success_count == total_test_count } diff --git a/core/testing/runner_windows.odin b/core/testing/runner_windows.odin deleted file mode 100644 index b35914c72..000000000 --- a/core/testing/runner_windows.odin +++ /dev/null @@ -1,36 +0,0 @@ -#+private -package testing - -import win32 "core:sys/windows" - -old_stdout_mode: u32 -old_stderr_mode: u32 - -console_ansi_init :: proc() { - stdout := win32.GetStdHandle(win32.STD_OUTPUT_HANDLE) - if stdout != win32.INVALID_HANDLE && stdout != nil { - if win32.GetConsoleMode(stdout, &old_stdout_mode) { - win32.SetConsoleMode(stdout, old_stdout_mode | win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING) - } - } - - stderr := win32.GetStdHandle(win32.STD_ERROR_HANDLE) - if stderr != win32.INVALID_HANDLE && stderr != nil { - if win32.GetConsoleMode(stderr, &old_stderr_mode) { - win32.SetConsoleMode(stderr, old_stderr_mode | win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING) - } - } -} - -// Restore the cursor on exit -console_ansi_fini :: proc() { - stdout := win32.GetStdHandle(win32.STD_OUTPUT_HANDLE) - if stdout != win32.INVALID_HANDLE && stdout != nil { - win32.SetConsoleMode(stdout, old_stdout_mode) - } - - stderr := win32.GetStdHandle(win32.STD_ERROR_HANDLE) - if stderr != win32.INVALID_HANDLE && stderr != nil { - win32.SetConsoleMode(stderr, old_stderr_mode) - } -} \ No newline at end of file