#+vet explicit-allocators package tcom import rt "base:runtime" import hm "core:container/handle_map" import "core:flags" import "core:fmt" import "core:mem" import "core:log" import "core:os" /* How do we store terminal contents? Terminals are line-buffered, so we definitely want to split by newlines. But UTF-8 is a variable-width encoding. We have a number of grapheme clusters (runes) visible at a given time but that does not correspond to characters. Take a note from kakoune's design: an array of lines can work well! So [][]rune? If we use an array of runes then that takes up four bytes per cell when we will only need one most of the time. But that's what foot does... so it's fine? Do we want a child to be visible on multiple panes? No, so we can safely tie a VT to a child. Except that a child can suspend itself to have another child take over the VT... what should happen in that case? Yeah, we need to do [][]Cell, and attach other properties to each Cell as well. There are some escape codes which we must "sanitize" and others we ought to pass through. For example, fish shell starts out by switching to the alternative buffer, doing some crap, then switching back. Currently this causes the upstrema terminal to switch away from the alt buffer after we enter it. Therefore we must "sanitize" these codes from the output and handle them in our vt. */ EXE_NAME :: "tcom" VERSION :: #config(VERSION, "") #assert(VERSION != "") LOG_FILE :: EXE_NAME + ".log" VERSION_LINE :: EXE_NAME + " version " + VERSION Cli :: struct { cmd: string `usage:"Command to run on startup. Defaults to $SHELL."`, debug: bool `usage:"Enable debug logging."`, session: string `usage:"Set explicit session name. Defaults to PID."`, version: bool `usage:"Print version information."`, } Run_Error :: union { rt.Allocator_Error, string, } run :: proc() -> (err: Run_Error) { cli: Cli if flags_err := flags.parse(&cli, os.args[1:], allocator=context.allocator); flags_err != nil { flags.print_errors(Cli, flags_err, EXE_NAME) _, is_help_request := flags_err.(flags.Help_Request) os.exit(is_help_request ? 0 : 1) } if cli.version { fmt.println(VERSION_LINE) return } logger: log.Logger { // TODO: State directory and custom log path. // TODO: Log "buffer" in tcom itself (inspired by kakoune) log_file, log_file_err := os.create(LOG_FILE) if log_file_err == nil { // TODO: I really hate the default logging syntax... I want to replace the default logging proc! opts := log.Options{ .Level, .Short_File_Path, .Line, .Time, } lowest: log.Level = (cli.debug || ODIN_DEBUG) ? .Debug : .Info logger = log.create_file_logger(log_file, lowest, opts, allocator=context.allocator) } else { log.error("Failed to create log file:", log_file_err) fmt.eprintln("Failed to create log file:", log_file_err) } } context.logger = logger log.info(VERSION_LINE) env_term := os.lookup_env("TERM", context.temp_allocator) or_else "" env_tcom_session := os.lookup_env("TCOM_SESSION", context.temp_allocator) or_else "" log.infof("TERM={} TCOM_SESSION={}", env_term, env_tcom_session) log.infof("args: {}", os.args) log.debugf("cli: {}", cli) session: string if len(cli.session) > 0 { session = cli.session } else { pid := os.get_pid() session = fmt.tprint(pid) } os.set_env("TCOM_SESSION", "debug") log.infof("session: {}", session) // TODO: tmux sets its own terminfo, but zellij appears to pass through the host terminal's terminfo. // But zellij will continue to persist that same $TERM throughout the whole session, even if you change // viewing terminals! So I am more inclined to *sigh* make my own terminfo for tcom, or fall back to // tmux-256color. // Making my own terminfo is probably good because then I can make sure children see *all* available features, // even ones which the host terminal doesn't support. i.e. if the host terminal doesn't support multicursor, // tcom could emulate it. // Ghostty ships xterm-ghostty because apparently many archaic programs simply search for 'xterm' in the // $TERM string. We could do something similar as xterm-tcom. Or maybe we just use xterm-ghostty directly // since it is already propagating? See https://ghostty.org/docs/help/terminfo // os.set_env("TERM", "tcom") startup_cmd: string if len(cli.cmd) > 0 { startup_cmd = cli.cmd } else { startup_cmd, _ = os.lookup_env_alloc("SHELL", context.temp_allocator) } if len(startup_cmd) == 0 { return "Could not determine startup command. Either pass a -cmd or set $SHELL in your environment." } // TODO: shlex startup_cmd // TODO: Fork into server for session persistence! if !os.is_tty(os.stdout) { return "Must be run from a terminal" } if env_tcom_session != "" { return "Nesting sessions is not recommended; unset $TCOM_SESSION to force." } sys_init() panes_init(context.allocator) g_focused_pane = pane_init({startup_cmd}, sys_term_get_dims(), context.allocator) or_return // Main loop for { // TODO: Frame arena allocator? // TODO: Consider multithreading? if hm.len(g_panes) == 0 { log.info("All children are dead.") break } it := sys_event_iterator_create(context.allocator) for event in sys_event_iterator_iterate(&it) { log.debug("event:", event) switch e in event { case Sys_Event_Pane_Died: pane_fini(e.pane) case Sys_Event_TTY: pane_handle_event(g_focused_pane, e) case Sys_Event_TTY_Error: log.error(e) case Sys_Event_Input_Raw: // TODO: // assert(pane_write(g_focused_pane, e.text)) case Sys_Event_IO_Error: log.error(e.err) } } sys_event_iterator_destroy(&it) pane_draw(g_focused_pane) } sys_fini() log.info("Goodbye!") if logger != {} { log.destroy_file_logger(logger, context.allocator) } panes_fini() return } main :: proc() { // TODO: Consider reducing the scope of the tracking allocator so that we can exit quicker. when ODIN_DEBUG { track: mem.Tracking_Allocator mem.tracking_allocator_init(&track, context.allocator, context.allocator) context.allocator = mem.tracking_allocator(&track) defer { if len(track.allocation_map) > 0 { for _, entry in track.allocation_map { fmt.eprintfln("{} leaked {} bytes", entry.location, entry.size) } } // There is a leak here, but since the tracking allocator doesn't track itself, it's fine! } } if err := run(); err != nil { fmt.eprintln(err) os.exit(1) } }