Browse Source

Merge pull request #5189 from Feoramund/fix-ansi-log-terminal

Add `core:terminal`, fix test runner/`core:log` ANSI code issues
Jeroen van Rijn 3 months ago
parent
commit
f65e418dc9

+ 53 - 11
core/log/file_console_logger.odin

@@ -2,10 +2,12 @@
 #+build !orca
 #+build !orca
 package log
 package log
 
 
-import "core:encoding/ansi"
+import "base:runtime"
 import "core:fmt"
 import "core:fmt"
 import "core:strings"
 import "core:strings"
 import "core:os"
 import "core:os"
+import "core:terminal"
+import "core:terminal/ansi"
 import "core:time"
 import "core:time"
 
 
 Level_Headers := [?]string{
 Level_Headers := [?]string{
@@ -37,11 +39,36 @@ File_Console_Logger_Data :: struct {
 	ident: string,
 	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 {
 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 := new(File_Console_Logger_Data, allocator)
 	data.file_handle = h
 	data.file_handle = h
 	data.ident = ident
 	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) {
 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 := new(File_Console_Logger_Data, allocator)
 	data.file_handle = os.INVALID_HANDLE
 	data.file_handle = os.INVALID_HANDLE
 	data.ident = ident
 	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) {
 destroy_console_logger :: proc(log: Logger, allocator := context.allocator) {
 	free(log.data, 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.
 	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[:])
 	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())
 		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
 	//TODO(Hoej): When we have better atomics and such, make this thread-safe
 	fmt.fprintf(h, "%s%s\n", strings.to_string(buf), text)
 	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) {
 do_level_header :: proc(opts: Options, str: ^strings.Builder, level: Level) {
 
 
 	RESET     :: ansi.CSI + ansi.RESET           + ansi.SGR
 	RESET     :: ansi.CSI + ansi.RESET           + ansi.SGR

+ 0 - 0
core/encoding/ansi/ansi.odin → core/terminal/ansi/ansi.odin


+ 0 - 0
core/encoding/ansi/doc.odin → core/terminal/ansi/doc.odin


+ 4 - 0
core/terminal/doc.odin

@@ -0,0 +1,4 @@
+/*
+This package is for interacting with the command line interface of the system.
+*/
+package terminal

+ 87 - 0
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()
+}

+ 36 - 0
core/terminal/terminal.odin

@@ -0,0 +1,36 @@
+package terminal
+
+import "core:os"
+
+/*
+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)
+}
+
+/*
+This is true if the terminal is accepting any form of colored text output.
+*/
+color_enabled: bool
+
+/*
+This value reports the color depth support as reported by the terminal at the
+start of the program.
+*/
+color_depth: Color_Depth

+ 16 - 0
core/terminal/terminal_posix.odin

@@ -0,0 +1,16 @@
+#+private
+#+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)))
+}
+
+_init_terminal :: proc() {
+	color_depth = get_environment_color()
+}
+
+_fini_terminal :: proc() { }

+ 60 - 0
core/terminal/terminal_windows.odin

@@ -0,0 +1,60 @@
+#+private
+package terminal
+
+import "core:os"
+import "core:sys/windows"
+
+_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)
+	}
+}

+ 1 - 1
core/testing/reporting.odin

@@ -10,12 +10,12 @@ package testing
 */
 */
 
 
 import "base:runtime"
 import "base:runtime"
-import "core:encoding/ansi"
 import "core:fmt"
 import "core:fmt"
 import "core:io"
 import "core:io"
 import "core:mem"
 import "core:mem"
 import "core:path/filepath"
 import "core:path/filepath"
 import "core:strings"
 import "core:strings"
+import "core:terminal/ansi"
 
 
 // Definitions of colors for use in the test runner.
 // Definitions of colors for use in the test runner.
 SGR_RESET   :: ansi.CSI + ansi.RESET           + ansi.SGR
 SGR_RESET   :: ansi.CSI + ansi.RESET           + ansi.SGR

+ 94 - 70
core/testing/runner.odin

@@ -13,7 +13,6 @@ package testing
 import "base:intrinsics"
 import "base:intrinsics"
 import "base:runtime"
 import "base:runtime"
 import "core:bytes"
 import "core:bytes"
-import "core:encoding/ansi"
 @require import "core:encoding/base64"
 @require import "core:encoding/base64"
 @require import "core:encoding/json"
 @require import "core:encoding/json"
 import "core:fmt"
 import "core:fmt"
@@ -25,6 +24,8 @@ import "core:os"
 import "core:slice"
 import "core:slice"
 @require import "core:strings"
 @require import "core:strings"
 import "core:sync/chan"
 import "core:sync/chan"
+import "core:terminal"
+import "core:terminal/ansi"
 import "core:thread"
 import "core:thread"
 import "core:time"
 import "core:time"
 
 
@@ -44,6 +45,7 @@ PER_THREAD_MEMORY     : int    : #config(ODIN_TEST_THREAD_MEMORY, mem.ROLLBACK_S
 // The format is: `package.test_name,test_name_only,...`
 // The format is: `package.test_name,test_name_only,...`
 TEST_NAMES            : string : #config(ODIN_TEST_NAMES, "")
 TEST_NAMES            : string : #config(ODIN_TEST_NAMES, "")
 // Show the fancy animated progress report.
 // 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)
 FANCY_OUTPUT          : bool   : #config(ODIN_TEST_FANCY, true)
 // Copy failed tests to the clipboard when done.
 // Copy failed tests to the clipboard when done.
 USE_CLIPBOARD         : bool   : #config(ODIN_TEST_CLIPBOARD, false)
 USE_CLIPBOARD         : bool   : #config(ODIN_TEST_CLIPBOARD, false)
@@ -70,6 +72,9 @@ get_log_level :: #force_inline proc() -> runtime.Logger_Level {
 	}
 	}
 }
 }
 
 
+@(private) global_log_colors_disabled: bool
+@(private) global_ansi_disabled: bool
+
 JSON :: struct {
 JSON :: struct {
 	total:    int,
 	total:    int,
 	success:  int,
 	success:  int,
@@ -129,11 +134,16 @@ run_test_task :: proc(task: thread.Task) {
 	
 	
 	context.assertion_failure_proc = test_assertion_failure_proc
 	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 = {
 	context.logger = {
 		procedure = test_logger_proc,
 		procedure = test_logger_proc,
 		data = &data.t,
 		data = &data.t,
 		lowest_level = get_log_level(),
 		lowest_level = get_log_level(),
-		options = Default_Test_Logger_Opts,
+		options = logger_options,
 	}
 	}
 
 
 	random_generator_state: runtime.Default_Random_State
 	random_generator_state: runtime.Default_Random_State
@@ -204,13 +214,16 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 		}
 		}
 	}
 	}
 
 
-	when ODIN_OS == .Windows {
-		console_ansi_init()
-	}
-
 	stdout := io.to_writer(os.stream_from_handle(os.stdout))
 	stdout := io.to_writer(os.stream_from_handle(os.stdout))
 	stderr := io.to_writer(os.stream_from_handle(os.stderr))
 	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.
 	// -- Prepare test data.
 
 
 	alloc_error: mem.Allocator_Error
 	alloc_error: mem.Allocator_Error
@@ -268,12 +281,12 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 	total_done_count    := 0
 	total_done_count    := 0
 	total_test_count    := len(internal_tests)
 	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 {
 	if total_test_count == 0 {
 		// Exit early.
 		// Exit early.
@@ -342,31 +355,31 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 	fmt.assertf(alloc_error == nil, "Error allocating memory for test report: %v", alloc_error)
 	fmt.assertf(alloc_error == nil, "Error allocating memory for test report: %v", alloc_error)
 	defer destroy_report(&report)
 	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
 
 
-			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)
+	// 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
+
+		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: []Task_Data = ---
 	task_data_slots, alloc_error = make([]Task_Data, thread_count)
 	task_data_slots, alloc_error = make([]Task_Data, thread_count)
@@ -442,11 +455,16 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 	// digging through the source to divine everywhere it is used for that.
 	// digging through the source to divine everywhere it is used for that.
 	shared_log_allocator := context.allocator
 	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 = {
 	context.logger = {
 		procedure = runner_logger_proc,
 		procedure = runner_logger_proc,
 		data = &log_messages,
 		data = &log_messages,
 		lowest_level = get_log_level(),
 		lowest_level = get_log_level(),
-		options = Default_Test_Logger_Opts - {.Short_File_Path, .Line, .Procedure},
+		options = logger_options,
 	}
 	}
 
 
 	run_index: int
 	run_index: int
@@ -481,11 +499,13 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 
 
 	setup_signal_handler()
 	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)
 		redraw_report(stdout, report)
 		draw_status_bar(stdout, thread_count_status_string, total_done_count, total_test_count)
 		draw_status_bar(stdout, thread_count_status_string, total_done_count, total_test_count)
 	}
 	}
@@ -703,22 +723,22 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 			break main_loop
 			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 {
 		if test_index, reason, ok := should_stop_test(); ok {
 			#no_bounds_check report.all_test_states[test_index] = .Failed
 			#no_bounds_check report.all_test_states[test_index] = .Failed
@@ -752,7 +772,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)
 					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
 					bypass_progress_overwrite = true
 					signals_were_raised = true
 					signals_were_raised = true
 				}
 				}
@@ -766,7 +786,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 
 
 		// -- Redraw.
 		// -- Redraw.
 
 
-		when FANCY_OUTPUT {
+		if should_show_animations {
 			if len(log_messages) == 0 && !needs_to_redraw(report) {
 			if len(log_messages) == 0 && !needs_to_redraw(report) {
 				continue main_loop
 				continue main_loop
 			}
 			}
@@ -776,7 +796,9 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 			}
 			}
 		} else {
 		} else {
 			if total_done_count != last_done_count {
 			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
 				last_done_count = total_done_count
 			}
 			}
 
 
@@ -801,7 +823,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 		clear(&log_messages)
 		clear(&log_messages)
 		bytes.buffer_reset(&batch_buffer)
 		bytes.buffer_reset(&batch_buffer)
 
 
-		when FANCY_OUTPUT {
+		if should_show_animations {
 			redraw_report(batch_writer, report)
 			redraw_report(batch_writer, report)
 			draw_status_bar(batch_writer, thread_count_status_string, total_done_count, total_test_count)
 			draw_status_bar(batch_writer, thread_count_status_string, total_done_count, total_test_count)
 			fmt.wprint(stdout, bytes.buffer_to_string(&batch_buffer))
 			fmt.wprint(stdout, bytes.buffer_to_string(&batch_buffer))
@@ -822,7 +844,7 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 
 
 	finished_in := time.since(start_time)
 	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
 		// One line to space out the results, since we don't have the status
 		// bar in plain mode.
 		// bar in plain mode.
 		fmt.wprintln(batch_writer)
 		fmt.wprintln(batch_writer)
@@ -836,24 +858,28 @@ runner :: proc(internal_tests: []Internal_Test) -> bool {
 	
 	
 	if total_done_count != total_test_count {
 	if total_done_count != total_test_count {
 		not_run_count := total_test_count - total_done_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,
 		fmt.wprintf(batch_writer,
-			" " + SGR_READY + "%i" + SGR_RESET + " %s left undone.",
+			message,
 			not_run_count,
 			not_run_count,
 			"test was" if not_run_count == 1 else "tests were")
 			"test was" if not_run_count == 1 else "tests were")
 	}
 	}
 
 
 	if total_success_count == total_test_count {
 	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,
 		fmt.wprintfln(batch_writer,
-			" %s " + SGR_SUCCESS + "successful." + SGR_RESET,
+			message,
 			"The test was" if total_test_count == 1 else "All tests were")
 			"The test was" if total_test_count == 1 else "All tests were")
 	} else if total_failure_count > 0 {
 	} else if total_failure_count > 0 {
 		if total_failure_count == total_test_count {
 		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,
 			fmt.wprintfln(batch_writer,
-				" %s " + SGR_FAILED + "failed." + SGR_RESET,
+				message,
 				"The test" if total_test_count == 1 else "All tests")
 				"The test" if total_test_count == 1 else "All tests")
 		} else {
 		} else {
+			message := " %i test%s failed." if global_log_colors_disabled else " " + SGR_FAILED + "%i" + SGR_RESET + " test%s failed."
 			fmt.wprintfln(batch_writer,
 			fmt.wprintfln(batch_writer,
-				" " + SGR_FAILED + "%i" + SGR_RESET + " test%s failed.",
+				message,
 				total_failure_count,
 				total_failure_count,
 				"" if total_failure_count == 1 else "s")
 				"" if total_failure_count == 1 else "s")
 		}
 		}
@@ -907,9 +933,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 {
 		if signals_were_raised {
 			fmt.wprintln(batch_writer, `
 			fmt.wprintln(batch_writer, `
 Signals were raised during this test run. Log messages are likely to have collided with each other.
 Signals were raised during this test run. Log messages are likely to have collided with each other.
@@ -949,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)
 		fmt.assertf(err == nil, "Error writing JSON report: %v", err)
 	}
 	}
 
 
-	when ODIN_OS == .Windows {
-		console_ansi_fini()
-	}
-
 	return total_success_count == total_test_count
 	return total_success_count == total_test_count
 }
 }

+ 0 - 36
core/testing/runner_windows.odin

@@ -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)
-	}
-}

+ 7 - 5
core/testing/signal_handler_libc.odin

@@ -12,9 +12,9 @@ package testing
 
 
 import "base:intrinsics"
 import "base:intrinsics"
 import "core:c/libc"
 import "core:c/libc"
-import "core:encoding/ansi"
-import "core:sync"
 import "core:os"
 import "core:os"
+import "core:sync"
+import "core:terminal/ansi"
 
 
 @(private="file") stop_runner_flag: libc.sig_atomic_t
 @(private="file") stop_runner_flag: libc.sig_atomic_t
 
 
@@ -63,9 +63,11 @@ stop_test_callback :: proc "c" (sig: libc.int) {
 		// NOTE(Feoramund): Using these write calls in a signal handler is
 		// NOTE(Feoramund): Using these write calls in a signal handler is
 		// undefined behavior in C99 but possibly tolerated in POSIX 2008.
 		// undefined behavior in C99 but possibly tolerated in POSIX 2008.
 		// Either way, we may as well try to salvage what we can.
 		// 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.
 		// This is an attempt at being compliant by avoiding printf.
 		sigbuf: [8]byte
 		sigbuf: [8]byte

+ 5 - 2
examples/all/all_main.odin

@@ -58,7 +58,6 @@ import trace            "core:debug/trace"
 import dynlib           "core:dynlib"
 import dynlib           "core:dynlib"
 import net              "core:net"
 import net              "core:net"
 
 
-import ansi             "core:encoding/ansi"
 import base32           "core:encoding/base32"
 import base32           "core:encoding/base32"
 import base64           "core:encoding/base64"
 import base64           "core:encoding/base64"
 import cbor             "core:encoding/cbor"
 import cbor             "core:encoding/cbor"
@@ -129,6 +128,9 @@ import strings          "core:strings"
 import sync             "core:sync"
 import sync             "core:sync"
 import testing          "core:testing"
 import testing          "core:testing"
 
 
+import terminal         "core:terminal"
+import ansi             "core:terminal/ansi"
+
 import edit             "core:text/edit"
 import edit             "core:text/edit"
 import i18n             "core:text/i18n"
 import i18n             "core:text/i18n"
 import match            "core:text/match"
 import match            "core:text/match"
@@ -201,7 +203,6 @@ _ :: pe
 _ :: trace
 _ :: trace
 _ :: dynlib
 _ :: dynlib
 _ :: net
 _ :: net
-_ :: ansi
 _ :: base32
 _ :: base32
 _ :: base64
 _ :: base64
 _ :: csv
 _ :: csv
@@ -257,6 +258,8 @@ _ :: strconv
 _ :: strings
 _ :: strings
 _ :: sync
 _ :: sync
 _ :: testing
 _ :: testing
+_ :: terminal
+_ :: ansi
 _ :: scanner
 _ :: scanner
 _ :: i18n
 _ :: i18n
 _ :: match
 _ :: match