raiguard/tcom

git clone https://git.raiguard.me/raiguard/tcom

Caleb HeuerNo idea what I was working on...7992681

trunk
6.4 KiB209 linesraw
1#+vet explicit-allocators
2package tcom
3
4import rt "base:runtime"
5import hm "core:container/handle_map"
6import "core:flags"
7import "core:fmt"
8import "core:mem"
9import "core:log"
10import "core:os"
11
12/*
13	How do we store terminal contents?
14	Terminals are line-buffered, so we definitely want to split by newlines.
15	But UTF-8 is a variable-width encoding.
16	We have a number of grapheme clusters (runes) visible at a given time but that does not correspond to characters.
17	Take a note from kakoune's design: an array of lines can work well! So [][]rune?
18	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.
19	But that's what foot does... so it's fine?
20
21	Do we want a child to be visible on multiple panes?
22	No, so we can safely tie a VT to a child.
23	Except that a child can suspend itself to have another child take over the VT... what should happen in that case?
24
25	Yeah, we need to do [][]Cell, and attach other properties to each Cell as well.
26
27	There are some escape codes which we must "sanitize" and others we ought to pass through.
28	For example, fish shell starts out by switching to the alternative buffer, doing some crap, then switching back.
29	Currently this causes the upstrema terminal to switch away from the alt buffer after we enter it.
30	Therefore we must "sanitize" these codes from the output and handle them in our vt.
31*/
32
33EXE_NAME :: "tcom"
34
35VERSION :: #config(VERSION, "")
36#assert(VERSION != "")
37
38LOG_FILE :: EXE_NAME + ".log"
39VERSION_LINE :: EXE_NAME + " version " + VERSION
40
41Cli :: struct {
42	cmd: string     `usage:"Command to run on startup. Defaults to $SHELL."`,
43	debug: bool     `usage:"Enable debug logging."`,
44	session: string `usage:"Set explicit session name. Defaults to PID."`,
45	version: bool   `usage:"Print version information."`,
46}
47
48Run_Error :: union {
49	rt.Allocator_Error,
50	string,
51}
52
53run :: proc() -> (err: Run_Error) {
54	cli: Cli
55	if flags_err := flags.parse(&cli, os.args[1:], allocator=context.allocator); flags_err != nil {
56		flags.print_errors(Cli, flags_err, EXE_NAME)
57		_, is_help_request := flags_err.(flags.Help_Request)
58		os.exit(is_help_request ? 0 : 1)
59	}
60
61	if cli.version {
62		fmt.println(VERSION_LINE)
63		return
64	}
65
66	logger: log.Logger
67	{
68		// TODO: State directory and custom log path.
69		// TODO: Log "buffer" in tcom itself (inspired by kakoune)
70		log_file, log_file_err := os.create(LOG_FILE)
71		if log_file_err == nil {
72			// TODO: I really hate the default logging syntax... I want to replace the default logging proc!
73			opts := log.Options{
74				.Level,
75				.Short_File_Path,
76				.Line,
77				.Time,
78			}
79			lowest: log.Level = (cli.debug || ODIN_DEBUG) ? .Debug : .Info
80			logger = log.create_file_logger(log_file, lowest, opts, allocator=context.allocator)
81		} else {
82			log.error("Failed to create log file:", log_file_err)
83			fmt.eprintln("Failed to create log file:", log_file_err)
84		}
85	}
86	context.logger = logger
87
88	log.info(VERSION_LINE)
89
90	env_term := os.lookup_env("TERM", context.temp_allocator) or_else "<unset>"
91	env_tcom_session := os.lookup_env("TCOM_SESSION", context.temp_allocator) or_else "<unset>"
92	log.infof("TERM={} TCOM_SESSION={}", env_term, env_tcom_session)
93
94	log.infof("args: {}", os.args)
95	log.debugf("cli: {}", cli)
96
97	session: string
98	if len(cli.session) > 0 {
99		session = cli.session
100	} else {
101		pid := os.get_pid()
102		session = fmt.tprint(pid)
103	}
104	os.set_env("TCOM_SESSION", "debug")
105	log.infof("session: {}", session)
106	// TODO: tmux sets its own terminfo, but zellij appears to pass through the host terminal's terminfo.
107	// 		 But zellij will continue to persist that same $TERM throughout the whole session, even if you change
108	// 		 viewing terminals! So I am more inclined to *sigh* make my own terminfo for tcom, or fall back to
109	// 		 tmux-256color.
110	// 		 Making my own terminfo is probably good because then I can make sure children see *all* available features,
111	// 		 even ones which the host terminal doesn't support. i.e. if the host terminal doesn't support multicursor,
112	// 		 tcom could emulate it.
113	// 		 Ghostty ships xterm-ghostty because apparently many archaic programs simply search for 'xterm' in the
114	// 		 $TERM string. We could do something similar as xterm-tcom. Or maybe we just use xterm-ghostty directly
115	// 		 since it is already propagating? See https://ghostty.org/docs/help/terminfo
116	// os.set_env("TERM", "tcom")
117
118	startup_cmd: string
119	if len(cli.cmd) > 0 {
120		startup_cmd = cli.cmd
121	} else {
122		startup_cmd, _ = os.lookup_env_alloc("SHELL", context.temp_allocator)
123	}
124	if len(startup_cmd) == 0 {
125		return "Could not determine startup command. Either pass a -cmd or set $SHELL in your environment."
126	}
127
128	// TODO: shlex startup_cmd
129
130	// TODO: Fork into server for session persistence!
131
132	if !os.is_tty(os.stdout) {
133		return "Must be run from a terminal"
134	}
135
136	if env_tcom_session != "<unset>" {
137		return "Nesting sessions is not recommended; unset $TCOM_SESSION to force."
138	}
139
140	sys_init()
141
142	panes_init(context.allocator)
143
144	g_focused_pane = pane_init({startup_cmd}, sys_term_get_dims(), context.allocator) or_return
145
146	// Main loop
147	for {
148		// TODO: Frame arena allocator?
149		// TODO: Consider multithreading?
150		if hm.len(g_panes) == 0 {
151			log.info("All children are dead.")
152			break
153		}
154
155		it := sys_event_iterator_create(context.allocator)
156		for event in sys_event_iterator_iterate(&it) {
157			log.debug("event:", event)
158
159			switch e in event {
160			case Sys_Event_Pane_Died:
161				pane_fini(e.pane)
162			case Sys_Event_TTY:
163				pane_handle_event(g_focused_pane, e)
164			case Sys_Event_TTY_Error:
165				log.error(e)
166			case Sys_Event_Input_Raw:
167				// TODO:
168				// assert(pane_write(g_focused_pane, e.text))
169			case Sys_Event_IO_Error:
170				log.error(e.err)
171			}
172		}
173		sys_event_iterator_destroy(&it)
174
175		pane_draw(g_focused_pane)
176	}
177
178	sys_fini()
179
180	log.info("Goodbye!")
181	if logger != {} {
182		log.destroy_file_logger(logger, context.allocator)
183	}
184
185	panes_fini()
186
187	return
188}
189
190main :: proc() {
191	// TODO: Consider reducing the scope of the tracking allocator so that we can exit quicker.
192	when ODIN_DEBUG {
193		track: mem.Tracking_Allocator
194		mem.tracking_allocator_init(&track, context.allocator, context.allocator)
195		context.allocator = mem.tracking_allocator(&track)
196		defer {
197			if len(track.allocation_map) > 0 {
198				for _, entry in track.allocation_map {
199					fmt.eprintfln("{} leaked {} bytes", entry.location, entry.size)
200				}
201			}
202			// There is a leak here, but since the tracking allocator doesn't track itself, it's fine!
203		}
204	}
205	if err := run(); err != nil {
206		fmt.eprintln(err)
207		os.exit(1)
208	}
209}