ANSI Printing Library/Collection - afmt

Most of what I do involves outputting to the terminal. Over the years I’ve used ANSI frequently to colorize my output. Even though I’m very familiar with ANSI, I still found that I was always needing to reference the codes and the correct order of things. Then of course came the debugging of fat fingered sequences, etc.

So I’ve done several versions of procedures that will do the ANSI for me, but I was still never satisfied. I’d still have to remember what I named the procedures, and ultimately found myself just wishing that the standard print procedures would support some kind of shorthand functionality for ANSI.

Well, I finally created such a thing. afmt mirrors many of the print procedures in fmt. The ones that are currently supported work exactly the same as expected if no ANSI format is provided. If an ANSI format definition is provided, it is intercepted, applied, and then passed on to the corresponding print procedure in fmt.

afmt is designed to be used as a collection. Place an “afmt” folder inside your collection folder or use the default collection folder “…/odin/shared”. Then place afmt.odin in that folder. Also Place the “examples” folder inside “afmt” folder, and then place “examples.odin” inside that folder.

To use the collection, it can be imported with:

import "shared:afmt"
// or
import "YourCollectionFolderName:afmt"

https://github.com/OnlyXuul/afmt

// change "odin/shared" to location of your collection folder
cd odin/shared
git clone https://github.com/OnlyXuul/afmt.git
cd afmt/examples
odin run .

Supported procedures:

  • print, println, printf, printfln
  • tprint, tprintln, tprintf, tprintfln
  • aprint, aprintln, aprintf, aprintfln
  • bprint, bprintln, bprintf, bprintfln
  • sbprint, sbprintln, sbprintf, sbprintfln
  • ctprint, ctprintf, ctprintfln (ctprintln not in fmt, so it’s currently excluded)
  • caprint, caprintf, caprintfln (caprintln not in fmt, so it’s currently excluded)

I provide an examples.odin file which details the usage. The following is found at the beginning of that file:

	//	All print procedures in afmt work the same as their respective fmt version when not using ANSI.
	//	If using ANSI, then the ANSI format is the first arg of the ..args variatic parameter for each procedure. i.e. arg[0]
	//	Then the remaining args are passed to the appropriate procedure. i.e. ..args[1:]
	//	There are two ways to define an ANSI format.
	//		1. Using ANSI_Format struct, which has 4 variants. ANSI_3Bit, ANSI_4Bit, ANSI_8Bit, and ANSI_24Bit.
	//		2. Using a single string:
	//			ANSI_4Bit  -> "-f[blue] -b[black] -a[bold, underline]"
	//			ANSI_8Bit  -> "-f[255] -b[50] -a[bold, underline]"
	//			ANSI_24Bit -> "-f[200, 220, 250] -b[0, 0, 0] -a[bold, underline]"
	//
	//	Rules:
	//		- Cannot combine color types between foreground and background in the same ANSI format definition.
	//			i.e. cannot do "-f[blue] -b[0, 0, 0]". This combines 4bit with 24bit.
	//		- Attributes are independant of foreground and background colors and will be applied even if color definitions are invalid.
	//		- Not all fields must be set. Fields not set are ignored, and the terminal will use it's default.
	//
	//	Notes: If you are using a terminal with a custom theme defined when using 3bit, or 4bit, the colors will be converted by your terminal
	//	to the theme's version of those colors. afmt has no control over this. To over-ride themes, use either 8bit or 24bit colors.
	//	afmt applies standard ANSI sequences. The accuracy of output depends on your terminal's support. If an ANSI sequence is not
	//	supported, the terminal should ignore it.

If there is a desire for it, support for panicf, ensuref, and assertf, could easily be added.

A screenshot from examples.odin

9 Likes

Thanks for the likes. I really do hope others enjoy using this as much as I do. :slight_smile:

Big Update:

All files updated, including examples.odin

New file: colors.odin

  • add this to root of afmt
  • Provides named colors (should be same or similar to HTML color names)

Procedures Added

  • hsl - overload of the next 2
  • hsl_to_rgb - hsl to rgb utility
  • hsl_from_rgb - hsl from rgb utility
  • hsl666 - overload of the next 2
  • hsl_to_rgb666 - hsl to base-6 [3]u8 rgb utility (for 8Bit colors)
  • hsl_from_rgb666 - hsl from base-6 [3]u8 rgb utility (for 8Bit colors)
  • rgb666 - overload of the next 2
  • rgb666_to_8bit - base-6 [3]u8 to 8bit (16-231)
  • rgb666_from_8bit- base-6 [3]u8 from 8bit (16-231)
  • print_3bit_color_test
  • print_4bit_color_test
  • print_8bit_color_test
  • print_24bit_color_test
  • print_8bit_color_spectrum_bar
  • print_24bit_color_spectrum_bar - same as print_24bit_color_test which is the brute force method kept for testing hsl used by spectrum_bar(s)
  • color_name_from_value - utility for working with color names from colors.odin
  • color_name_from_enum - utility for working with color names from colors.odin

2 Likes

I’ve made some updates worth noting here.

There is one code change that affects existing projects (if there are any). I’ll help anyone with making updates to their code if needed.

This change felt necessary. At the beginning I was trying to maintain swizzling, but in the end, that was never possible with a nil-able array (doi). It makes more sense that either the whole color is nil, or it is completely defined, instead of the old way of each individual color (R or G or B) being nil-able. This change simplifies type assertions a bit, but adds the requirement for 24 bit color literals to be defined as either ‘[3]u8’ or ‘afmt.RGB’.

// changed from
RGB :: distinct [3]Maybe(u8)
// to
RGB :: [3]u8

ANSI24 (previously named ANSI_24Bit)
// now uses the following for colors
ANSI24 :: struct {
	fg: Maybe(RGB),
	bg: Maybe(RGB),
	at: bit_set[Attribute],
}

Structure name changes. These all have aliases to the old names. After making this change and updating the examples, things felt much cleaner. Hope you agree.

ANSI_Format   renamed to ANSI
ANSI_3Bit     renamed to ANSI3
ANSI_4Bit     renamed to ANSI4
ANSI_8Bit     renamed to ANSI8
ANSI_24Bit    renamed to ANSI24
FG_Color_3Bit renamed to FGColor3
BG_Color_3Bit renamed to BGColor3
FG_Color_4Bit renamed to FGColor4
BG_Color_4Bit renamed to BGColor4

Procedure name changes. These feel more symmetrical and self explanatory.

hsl // overload of the next 2
hsl_to_rgb
hsl_from_rgb

hsl666 // overload of the next 2
hsl_to_rgb666
hsl_from_rgb666

rgb666 // overload of the next 2
rgb666_to_8bit
rgb666_from_8bit

New procedures and structure

Column :: struct($V: typeid) where intrinsics.type_is_variant_of(ANSI, V) {
	width:   u8,
	justify: enum {LEFT, CENTER, RIGHT},
	ansi:    V,
}

// Prints row of N Column(s)
// Must provide an array of Column(s)
// See examples.odin for usage

//	Overloads: printrow or printtable by slice, array, dynamic array  or ..any
printrow :: proc {
	print_slice_1d,
	print_array_1d,
	print_dynamic_1d,
	printrow_any
}

printtable :: proc {
	print_slice_1d,
	print_slice_slice_2d,
	print_slice_array_2d,
	print_slice_dynamic_2d,

	print_array_1d,
	print_array_slice_2d,
	print_array_array_2d,
	print_array_dynamic_2d,

	print_dynamic_1d,
	print_dynamic_slice_2d,
	print_dynamic_array_2d,
	print_dynamic_dynamic_2d,
}

// Some color utilities

relative_luminance ::proc(rgb: RGB)
contrast_ratio :: proc(c1, c2: RGB)
print_color_name_guide :: proc(group := "all")
4 Likes

Thanks for all your effort and good work!

1 Like

I just noticed on git a checklist for potential future features.

terminal - package to control the terminal/console, allowing for colouring and cursor moving.

If anyone is working on that, please feel free to either: take what you want from this library, or consider it temporary until those features are implemented. I do not wish to de-rail any current efforts.

As a side note, I’ve got several ANSI based procedures for controlling the cursor/terminal that I’ve considered adding to afmt. I’d be happy to share if anyone is wanting to see those for the above proposed features. Let me know.

So far I have:

  • scroll_up
  • scroll_down
  • cursor_return
  • cursor_hide
  • cursor_show
  • cursor_save
  • cursor_restore
  • cursor_place
  • cursor_bottom
  • cursor_position
  • get_terminal_size
1 Like

Updates:

A few minor bug fixes. On rare occasions, the string parsing would set a color to black, if both -f and -b were used, but one of them were invalid. Should now disregard invalid color strings.

** print_slice - removed and replaced with printrow and printtable respectively

printrow - Print a single indexed row from 1 dimension slice, array, dynamic array, or variadic input of …any

printtable - Print a single 1 dimension indexed row or multiple rows from 2 dimension slice, array, or dynamic array (no …any)

ANSI24 string method now supports 3 different interchangeable ways: rgb, hex, or predefined color name from colors.odin

afmt.println("-f[255,0,0]", "Printed using -f[255,0,0]")
afmt.println("-f[#FF0000]", "Printed using -f[#FF0000]")
// # to distinguish it from ANSI4 color names
afmt.println("-f[#crimson]", "Printed using -f[#crimson]")

Can now set persistent ANSI and manually reset afterwards:

afmt.set("-f[blue]")
afmt.println("All other lines from this point will be the same ANSI format ...")
afmt.println("... until we reset")
//	Read the comments for reset() to learn about best practices
afmt.reset()
afmt.println("All ANSI now reset.")

examples.odin updated …

2 Likes

I have been maintaining a library on and off since last year: GitHub - RaphGL/TermCL: Terminal Control Library for Odin

The base package assumes that you want to use it to write a TUI so it tries to optimize drawing to the terminal and changes the terminal to raw or cbreak modes. But if all you want to do is just draw something to the terminal without changing the mode and optimizing draws, there’s a termcl/raw which just writes all escape codes to a string builder which you can then print all at once.

1 Like

Very cool. There is very much a need for this type of terminal control in Odin. Glad you are working on it. afmt is simply an fmt wrapper for printing simple ACS, with a focus on feeling similar to fmt for ergonomics. I had no intention of tackling a terminal engine like this. Good work.

One thing you may want to be aware of. It seems os2 has now been merged with os. I was not aware of this at first because my original way of cloning Odin, would copy the compiled source into my Odin folder, leaving deprecated files in place. After deleting my entire Odin folder (backed up shared though) and compiling, there is no longer an os2 folder. My feedback below is likely due to this recent change. Please let us know when there is an update for current version of Odin. I’d love to play around some more.

Feedback:

  • git cloning repository creates folder “TermCL”, which I had to rename to “termcl” for imports. Minor thing, but might be better to rename project on git with the import name “termcl” for more streamlined cloning into Odin/shared folder.
  • os2 is gone. I attempted to update all os2 imports to use os. Everything compiled. Some of the weirdness I encountered is likely due to os2 now merged with os.
  • The main example on git home page did not seem to work. Got some terminal errors.
  • Could not quit out of fire example (I might not had found the magic keyboard key). Left cursor hidden after ctrl+c.
  • user_input example seemed to detect ctrl and shift periodically even when not pressed. Also a cursor square flashed periodically near middle to left quarter quadrant.
  • window example flickers.

Edit: In case it matters or helps. I’m on Linux kernel 6.17.0-14, using konsole with xterm and bash. Also use the xterm wrapper/interface (MS thingy) in VSCodium. Both exhibited the same symptoms. System is Kubuntu on Plasma 6 with wayland.

replying to your feedback:

  1. Yeah TermCL as an import is annoying but that’s just the convention I went with for my github repos, what you can do and what I do myself is to clone like this git clone https://github.com/RaphGL/TermCL termcl this tells git to clone it into termcl
  2. I target whatever is the latest tagged odin release. On the december update there was a bug that was causing stdio to behave weirdly on windows so I reverted back to os but on the january update they fixed it so I moved back to os2
  3. what is the “main example”?
  4. The fire example doesn’t listen for input iirc, you can just do ctrl + c and it should stop
  5. Yeah I’ll have to take a look, that shouldn’t happen
  6. This is terminal dependent and also just due to me not being very thorough with how I wrote these examples. Some terminals aren’t very fast or don’t handle input very well so if you’re spamming draws they’ll start flickering

Edit: I’ve created an issue (Investigate apparent issues by user · Issue #35 · RaphGL/TermCL · GitHub) so that I don’t forget to take a look at the issues you reported having to see if I can replicate them.

  1. Yeah. Good point. I should have thought of that.
  2. The example on TermCL main github page just under “Usage” heading. I copy pasted that into a scratch project. It compiled and ran. Never saw "Hello " or “from ANSI escapes”, but did see “Alles Ordnung” 10-11 lines down in terminal screen, then cursor went back up to top. I started with a fresh full screen konsole terminal. I’m also running it with the new core:os, since I no longer have os2 in my version of Odin. So I did have to update all termcl files that referenced os2 and changed to os.
  3. ctrl+c did stop it, but the cursor stayed hidden.
  4. Maybe a fps limiter could help with that (I didn’t check if there was all ready one present)? Or some other logical way to prevent erroneous updates to screen, like maybe only on content change, etc?
  1. When I wrote that example the library would constantly query the terminal for cursor position and it made no assumption that you would be writing a TUI but now it does. Because of that it automatically enables a alternate buffer but not every terminal handles those the same (it’s necessary because windows terminal behaves weirdly without it allowing you to scroll between frames).

All this to say that that example should be using termcl/raw instead which is good for inline styling and base termcl is better for TUIs because it makes assumptions that allow you to have higher FPS count (by caching and reducing terminal state).

  1. ctrl + c interrupts the process and exits without doing a cleanup so unless I add a signal handler I can’t cleanly exit and restore terminal state. There’s no cross platform way of doing that in the odin afaict? I would have to manually add it for every platform I support.

if you look into the fire example it’s doing t.hide_cursor(true) and it’s not in raw mode so the terminal will just send a signal to the process resulting in the process being “killed”.

  1. Yeah there’s a stopwatch that you can use to limit framerate in the odin core lib. I’ve tried to only update the cells that changed every frame but my benchmark indicated that that was actually slower than just dumping an entire frame because doing style changes and cursor moves and whatnot change internal terminal state and those are a lot slower than just writing letters to the screen. The different we’re talking between the 2 approaches is something like 29 FPS vs 105 FPS so it’s quite significant. I could compare current and previous frame and decide whether to print the frame at all but I don’t know what the user will do with the a window so I can’t just not print a frame when a user does t.blit(win) when I don’t even know the context in which it was called.

All this to say that the bugs are in the examples themselves and not library.
The wrote the examples as a way of showing how you could use the library and of me testing that things are still working and knowing when I introduced breaking changes.

(I still have to investigate the ctrl and shift modifiers apparently being erroneously detected though)

I didn’t bother making them very robust. But maybe adding the signal handlers is a good idea?

Edit: Seems you are right. Would need a signal handler.

Removed Code: Cleaning space in thread. The proof-of concept in post further down is now where I’m making updates on code.

I wonder how ncurses does it. Some research there may help? Personally, I would want to if there is cleanup that is needed in a ctrl+c situation. Most notably, showing cursor. Though, I do understand the reluctance. Could be a very deep rabbit hole :rabbit:. I’ve been down those enough times.

More info on sigint:
https://www.geeksforgeeks.org/c/signals-c-language/

Odin core:c/libc has these defined. You can raise and capture your own signals as well. The c code from the above link is easily convertible to Odin code.

1 Like

After digging a little bit on ncurses repo I found this: ncurses/ncurses/tty/lib_tstp.c at 87c2c84cbd2332d6d94b12a1dcaf12ad1a51a938 · mirror/ncurses · GitHub

So yeah, they also attach handlers when the initscr function is first called. I’ll do the same.

Proof-of-Concept
Edit: Further updates to this will be here → signal

package sighandler

import "base:runtime"
import "core:os"
import "core:fmt"
import "core:c/libc"
import "base:intrinsics"

//	signals / custom signals / signals not defined by libc
//	The range of real-time signals is typically from 34 to 64, but this can differ across systems.
SIGINT  :: i32(libc.SIGINT)  //	non-windows = 2		windows = 2		ctrl+c
SIGILL  :: i32(libc.SIGILL)  //	non-windows = 4		windows = 4		illegal instruction
SIGABRT :: i32(libc.SIGABRT) //	non-windows = 6		windows = 22	abnormal termination (abort)
SIGFPE  :: i32(libc.SIGFPE)  //	non-windows = 8		windows = 8		floating-point exception // seems to occur when a signal is raised that is not handled
SIGSEGV :: i32(libc.SIGSEGV) //	non-windows = 11	windows = 11	segmentation fault
SIGTERM :: i32(libc.SIGTERM) //	non-windows = 15	windows = 15	termination signal
SIGCONT :: i32(18) //	non-windows
SIGSTOP	:: i32(19) //	non-windows raised when system captures SIGTSTP // do not handle this, just raise it when capturing SIGTSTP
SIGTSTP :: i32(20) //	non-windows ctrl+z
SIGULTA :: i32(42) //	custom signal "ultimate answer"

//	add and remove those intended to be handled
SIG :: enum i32 {
	NONE    = 0,
	SIGINT  = SIGINT,
	SIGILL  = SIGILL,
	SIGABRT = SIGABRT,
	SIGFPE  = SIGFPE,
	SIGSEGV = SIGSEGV,
	SIGTERM = SIGTERM,
	SIGCONT = SIGCONT,
	SIGTSTP = SIGTSTP,
	SIGULTA = SIGULTA,
}

//	on invalid signal, returns (proc(_: i32))(rawptr(~uintptr(0))) - invalid proc pointer
signal :: proc(sig: $S, func: proc(S)) -> proc(_: i32) {
	return (proc(_: i32))(libc.signal(i32(sig), (proc "cdecl" (i32))(func)))
}

//	tell system there is no signal handler for the given signal
//	the system should use it's default for the given signal
default_signal :: proc(sig: $S) -> ((proc(_: i32))) where intrinsics.type_is_enum(S) {
	loop: for s in S {
		if s == sig {
			return (proc(_: i32))(libc.signal(i32(sig), (proc "cdecl" (i32))(libc.SIG_DFL)))
		}
	}
	return (proc(_: i32))(libc.SIG_ERR)
}

//	ignores a given signal (except the signals SIGKILL and SIGSTOP which can't be caught or ignored).
ignore_signal :: proc(sig: $S) -> ((proc(_: i32))) where intrinsics.type_is_enum(S) {
	loop: for s in S {
		if s == sig {
			return (proc(_: i32))(libc.signal(i32(sig), (proc "cdecl" (i32))(libc.SIG_IGN)))
		}
	}
	return (proc(_: i32))(libc.SIG_ERR)
}
//	libc.raise return -1 on fail, so this does too
raise :: proc(sig: $S) -> i32 where intrinsics.type_is_enum(S) {
	for s in S {
		if s == sig {
			return (i32)(libc.raise(i32(sig)))
		}
	}
	return i32(-1)
}

sig_handler :: proc (sig: SIG) {
	context = runtime.default_context()
	//sig := valid_sig(signum)
	#partial switch sig {
	case .SIGINT:
		cursor_show()
		os.exit(int(sig))
	case .SIGTERM:
		cursor_show()
		os.exit(int(sig))
	case .SIGULTA:
		fmt.println("What was the question again?")
	case .SIGTSTP:
		//	suspend other signals/processes in app
		//	then pass SIGSTOP to system
		fmt.println("\nSIGTSTP ctrl+z captured")
		libc.raise(SIGSTOP)
	case .SIGCONT:
		fmt.println("SIGCONT return from ctrl+z captured")
	}
}

main :: proc() {
	signal(SIG.SIGINT, sig_handler)
	signal(SIG.SIGULTA, sig_handler)
	when ODIN_OS != .Windows {
		signal(SIG.SIGTSTP, sig_handler)
		signal(SIG.SIGCONT, sig_handler)
	}

	fmt.println("Holding main process hostage and hiding the cursor! Muwaahahaa!!")
	fmt.print("\e[?25l")

	//	uncomment to test resetting ctrl+z back to system default
	//default_signal(SIG.SIGTSTP)

	proceed: bool
	for !proceed {
		fmt.println("Proceed? Y/N or y/n")
		fmt.println("... or ... sshh, don't tell. Try ctrl+c")
		buf: [12]byte
		os.read(os.stdin, buf[:])
		if buf[0] == 'Y' || buf[0] == 'y' {
			proceed = true
			fmt.println("proceeding ...")
		}
	}

	//	uncomment to test setting system to ignore raising of a signal
	//ignore_signal(SIG.SIGULTA)

	raise(SIG.SIGULTA)
}

@(fini) cursor_show :: proc "contextless" () {
	context = runtime.default_context()
	fmt.print("\e[?25h", flush=true)
}

I’ve decided to just use the windows and posix APIs separately since they’re quite different under the hood. But I’m struggling to get job control working properly on linux (and posix systems).

#+build linux, darwin, netbsd, freebsd, openbsd
#+private
package termcl

import "base:runtime"
import "core:sys/posix"

mode_before_restored: Term_Mode

_set_signal_handlers :: proc() {
	// resets signal to default and raises it
	raise_default_signal_handler :: proc(signal: posix.Signal) {
		default_sigaction := posix.sigaction_t {
			sa_handler = auto_cast posix.SIG_DFL,
		}
		posix.sigaction(signal, &default_sigaction, nil)
		posix.raise(signal)
	}

	// restores terminal to its default state without destroying screen,
	// in case that job control is being used and the program is started again
	default_terminal_state :: proc "c" (signal: posix.Signal) {
		context = runtime.default_context()
		mode_before_restored = g_screen.mode
		enable_alt_buffer(false)
		set_term_mode(&g_screen, .Restored)
		raise_default_signal_handler(signal)
	}

	// TODO: find out why restoring signal stops working the second time
	// restores the termcl screen configuration with all of its signal handlers
	restore_terminal_state :: proc "c" (signal: posix.Signal) {
		context = runtime.default_context()
		_set_signal_handlers()
		enable_alt_buffer(true)
		set_term_mode(&g_screen, mode_before_restored)
		_set_signal_handlers(false)
	}

	default_handler := posix.sigaction_t {
		sa_handler = default_terminal_state,
	}

	restore_handler := posix.sigaction_t {
		sa_handler = restore_terminal_state,
	}

	signals :: []posix.Signal{.SIGINT, .SIGTERM, .SIGQUIT, .SIGABRT, .SIGTSTP}
	for sig in signals {
		posix.sigaction(sig, &default_handler, nil)
	}

	posix.sigaction(.SIGCONT, &restore_handler, nil)
}

This is what I implemented. The _set_signal_handlers is called once in init_screen to set all of it. It seems to be working fine. But if you do ctrl + z and then run fg so it receives SIGCONT, signals either stop working completely or I get into an infinite loop with the program never rendering again depending on how I change the code.

Apparently that’s because if I set SIGCONT while SIGCONT is running the OS will just discard the current handler and start running the new one. I’m not really sure but I’m running out of patience for today :skull:

I’ve been updating my above sighandler proof-of-concept above all morning. Check if there is anything new that is of use. I got ctrl+z working for me, and was able to capture other signals afterward.

I’m not in the thick of it like you are, so may not be aware of some details, but I don’t think you need to raise SIGCONT yourself. Let the OS do it? Use your own custom signal for internal pausing/unpausing. SICONT is for the OS to remove the process from a paused state at the system level, I believe.

Also, I noticed in the ncurses documentation from the link you provided they were not happy with the behavior of sigaction and planned to move completely to libc.signal.

Be nice to the bunnies down in that rabbit hole. Unless of course they are killer bunnies, then run away!

I’ve updated my proof-of-concept above to fully wrap libc.signal and libc.raise. Now everything in that proof can be defined with Odin code and no libc references. Should make it easier to organize signals with enums and handlers using Odin syntax. Seems to work with what’s there. Could use a few other use-cases added to be sure.

Update: I’ve made more updates to proof-of-concept. Made it more extensible with parametric parameters. Should be able to use any matching enum and signal handler proc that uses that same enum definition. May need some more screws tightened, but it works. Also added ability to reset a signal to system default (default_signal) and tell system to ignore (ignore_signal) a signal if raised. The ignore is especially handy if you want to disable a custom signal, or other troublesome signal.

Update: ignore_signal and default_signal now return libc.signal values

I got to messing with signals so much, that I decided to just turn it into an Odin library and upload it to git. I’ll be placing any updates there for now on.

signal