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 terminalHistory: Array[String] = [""] var historyIndex: int = -1 var cursorIndexFromEnd: int = 0 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 ResetBlink() -> void: caret.visible = true blink_timer.start() # Resets the countdown # --- Input Handling --- func InputChar(input) -> void: if input == null: return var fullText = terminalLine.text var insertPos = fullText.length() - cursorIndexFromEnd var before = fullText.left(insertPos) var after = fullText.right(cursorIndexFromEnd) terminalLine.text = before + input + after UpdateCaretPos() await get_tree().physics_frame ResetBlink() func InputDelChar() -> void: var minLength = ("user@work " + directory).length() + 1 if terminalLine.text.length() <= minLength: return var fullText = terminalLine.text var deletePos = fullText.length() - cursorIndexFromEnd if deletePos > minLength: var before = fullText.left(deletePos - 1) var after = fullText.right(cursorIndexFromEnd) terminalLine.text = before + after UpdateCaretPos() await get_tree().physics_frame ResetBlink() 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) if userInput != "": terminalHistory.append(userInput) historyIndex = 0 cursorIndexFromEnd = 0 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() func MoveCursorLeft(): var full_text = terminalLine.get_parsed_text() var last_newline_pos = full_text.rfind("\n") # The prompt starts after the last newline (or at 0 if it's the first line) var prompt_start_index = last_newline_pos + 1 if last_newline_pos != -1 else 0 var prompt_length = ("user@work " + directory + " ").length() # The absolute index we aren't allowed to go before var min_allowed_index = prompt_start_index + prompt_length # Calculate current target index var current_target = full_text.length() - cursorIndexFromEnd if current_target > min_allowed_index: cursorIndexFromEnd += 1 UpdateCaretPos() ResetBlink() func MoveCursorRight(): cursorIndexFromEnd = clampi(cursorIndexFromEnd - 1, 0, terminalLine.text.length()) UpdateCaretPos() ResetBlink() # --- History and FileSystem Helpers --- func NavigateHistory(direction: int): if terminalHistory.is_empty(): return historyIndex += direction historyIndex = clamp(historyIndex, 0, terminalHistory.size() - 1) var historyCommand = terminalHistory[historyIndex] terminalLine.text = "user@work "+ directory + " " + historyCommand UpdateCaretPos() 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 visibleText = terminalLine.get_parsed_text() ruler.text = visibleText ruler.custom_minimum_size.x = terminalLine.size.x ruler.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART var totalLen = visibleText.length() var targetIndex = clampi(totalLen - cursorIndexFromEnd, 0, totalLen) if totalLen == 0: caret.position = Vector2.ZERO return var lastCharBounds = ruler.get_character_bounds(max(0, totalLen - 1)) var targetCharBounds = ruler.get_character_bounds(targetIndex) if cursorIndexFromEnd == 0: caret.position.x = lastCharBounds.end.x caret.position.y = lastCharBounds.position.y else: caret.position.x = targetCharBounds.position.x caret.position.y = targetCharBounds.position.y caret.position.x += 1