extends Node class_name TerminalControls @onready var historyContainer = $MarginContainer/ScrollContainer/VBoxContainer @onready var caret = $MarginContainer/ScrollContainer/VBoxContainer/Label/Caret @onready var blink_timer = $MarginContainer/ScrollContainer/VBoxContainer/Label/CaretTimer @onready var ruler: Label = $"caret-ruler" @onready var scroll: ScrollContainer = $MarginContainer/ScrollContainer @export var terminalLine: RichTextLabel var command: String var directory: String = "~/" var fileSystem: Dictionary = { "documents": { "special": { "notes.txt": "test", "secrets": { "password.txt": "123" } } }, "bin": { "sh": "binary data", } } func _ready() -> void: terminalLine.text = "user@work "+ directory + " " blink_timer.start() Help() UpdateCaretPos() # --- Caret Blinking Logic --- func _on_caret_timer_timeout() -> void: caret.visible = !caret.visible func reset_blink() -> void: caret.visible = true blink_timer.start() # Resets the countdown # --- Input Handling --- func InputChar(input) -> void: if input == null: return else: command = input terminalLine.text += command UpdateCaretPos() await get_tree().physics_frame reset_blink() func InputDelChar() -> void: if terminalLine.text.length() > ("user@work " + directory).length() + 1: terminalLine.text = terminalLine.text.left(-1) UpdateCaretPos() await get_tree().physics_frame reset_blink() func EnterCommand() -> void: var fullText = terminalLine.text var userInput = fullText.trim_prefix("user@work " + directory).strip_edges() var parts = userInput.to_lower().split(" ") if historyContainer.get_child_count() <24: CreateHistoryEntry(fullText) match parts[0]: "ls", "list": var currentDirData if parts.size() > 1: currentDirData = GetDirAtPath(directory + parts[1]) else: currentDirData = GetDirAtPath(directory) if currentDirData is Dictionary: var list = "" var file: String for key in currentDirData.keys(): file = key if file.ends_with(".txt"): list += key + " " else: list += key + "/ " CreateHistoryEntry(list) else: CreateHistoryEntry("error: Directory not found") "cd", "changedirectory": if parts.size() > 1: var targetPath = parts[1] var newDir = ResolvePath(directory, targetPath) if GetDirAtPath(newDir) != null: directory = newDir else: CreateHistoryEntry("cd: no such directory: " + targetPath) else: directory = "~/" "cat", "view": if parts.size() > 1: RetrieveData(parts[1]) else: CreateHistoryEntry("usage: cat [filename]") "clear", "cls": for child in historyContainer.get_children(): if child != terminalLine: child.queue_free() "help": Help() "": pass _: CreateHistoryEntry("Command not found: " + userInput) terminalLine.text = "user@work "+ directory + " " UpdateCaretPos() GetBottomScroll() # --- History and FileSystem Helpers --- func CreateHistoryEntry(content: String) -> void: var rtl = RichTextLabel.new() rtl.bbcode_enabled = true rtl.fit_content = true rtl.text = content rtl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART rtl.custom_minimum_size = Vector2(1400, 0) rtl.add_theme_font_size_override("normal_font_size", 42) historyContainer.add_child(rtl) historyContainer.move_child(rtl, historyContainer.get_child_count() - 2) func ResolvePath(current: String, target: String) -> String: if target.begins_with("~/"): return target if target.ends_with("/") else target + "/" if target == "..": if current == "~/": return "~/" var parts = current.trim_suffix("/").rsplit("/", true, 1) return parts[0] + "/" var joined = current + target return joined if joined.ends_with("/") else joined + "/" func GetDirAtPath(path: String): if path == "~/" or path == "": return fileSystem var cleanPath = path.trim_prefix("~/").trim_suffix("/") var folders = cleanPath.split("/") var current = fileSystem for folder in folders: if folder == "": continue if current is Dictionary and current.has(folder): if current[folder] is Dictionary: current = current[folder] else: # We hit a file, not a directory return null else: return null return current func RetrieveData(inputPath: String): var targetFileName: String var targetDirData: Dictionary if "/" in inputPath: var pathParts = inputPath.rsplit("/", true, 1) var dirPath = pathParts[0] targetFileName = pathParts[1] var resolvedDirPath = ResolvePath(directory, dirPath) var result = GetDirAtPath(resolvedDirPath) if result is Dictionary: targetDirData = result else: CreateHistoryEntry("cat: " + dirPath + ": No such directory") return else: targetFileName = inputPath targetDirData = GetDirAtPath(directory) if targetDirData.has(targetFileName): var content = targetDirData[targetFileName] if content is Dictionary: CreateHistoryEntry("cat: " + targetFileName + ": Is a directory") else: CreateHistoryEntry(str(content)) else: CreateHistoryEntry("cat: " + targetFileName + ": No such file or directory") func Help(commandName: String = "default"): match commandName: "default": CreateHistoryEntry("--- AVAILABLE COMMANDS --- \n ls (or list) [folder] ------------ : List all files and directories/folders cd [folder] ---------------------- : Change directory (use '..' to go up a directory/folder) cat (or view) [file] ------------- : Read the contents of a file clear (or cls) ------------------- : Clear the terminal screen help ----------------------------- : Show this menu \n ") func ScrollUp(): await get_tree().physics_frame scroll.set_deferred("scroll_vertical", scroll.get_v_scroll_bar().value - 100 ) func ScrollDown(): await get_tree().physics_frame scroll.set_deferred("scroll_vertical", scroll.get_v_scroll_bar().value + 100 ) func GetBottomScroll(): var scrollMax: float = scroll.get_v_scroll_bar().max_value await get_tree().create_timer(.01).timeout scroll.set_deferred("scroll_vertical", scrollMax) func UpdateCaretPos(): await get_tree().physics_frame var visible_text = terminalLine.get_parsed_text() ruler.autowrap_mode =TextServer.AUTOWRAP_WORD_SMART ruler.text = visible_text var last_char_index = ruler.text.length() - 1 var char_rect = ruler.get_character_bounds(max(0, last_char_index)) caret.position.x = char_rect.end.x + 1 caret.position.y = terminalLine.get_content_height() - 50