class_name CustomLineEdit extends Control # Internal state var cursor_pos: int = 0 var selection: Vector2i = Vector2i(-1, -1) var cursor_visible: bool = false var has_focus: bool = false # Visual settings var cursor_color: Color = Color.BLACK @export var text: String = "": set(v): text = v.left(max_length) cursor_pos = clamp(cursor_pos, 0, text.length()) queue_redraw() @export_multiline var placeholder: String = "": set(v): placeholder = v queue_redraw() @export_enum("Left", "Center", "Right") var align: int = 0: set(v): align = v queue_redraw() @export_range(0, 1024) var max_length: int = 256 @export var secret: bool = false: set(v): secret = v queue_redraw() @export var secret_char: String = "*" : set(v): secret_char = v[0] if v.length() > 0 else "*" queue_redraw() @export var editable: bool = true @export var context_menu_enabled: bool = true @export var clear_button_enabled: bool = false @export var shortcut_keys_enabled: bool = true @export var virtual_keyboard_enabled: bool = true @export var caret_blink: bool = true @export_range(0.1, 2.0) var caret_blink_interval: float = 0.5 @export var expand_to_text_length: bool = false @export var select_all_on_focus: bool = false @export_group("Colors") @export var text_color: Color = Color.BLACK: set(v): text_color = v queue_redraw() @export var placeholder_color: Color = Color.DIM_GRAY: set(v): placeholder_color = v queue_redraw() @export var caret_color: Color = Color.BLACK: set(v): caret_color = v queue_redraw() @export var selection_color: Color = Color(0.7, 0.8, 1.0, 0.5): set(v): selection_color = v queue_redraw() @export_group("Font") @export var font: Font = ThemeDB.fallback_font: set(v): font = v queue_redraw() @export_range(8, 64) var font_size: int = 16: set(v): font_size = v queue_redraw() func _ready(): focus_entered.connect(_on_focus_entered) focus_exited.connect(_on_focus_exited) set_process_input(true) start_blink_timer() func _on_focus_entered(): has_focus = true cursor_visible = true queue_redraw() func _on_focus_exited(): has_focus = false cursor_visible = false queue_redraw() func start_blink_timer(): var timer = Timer.new() add_child(timer) timer.wait_time = 0.5 timer.timeout.connect(_toggle_cursor_visibility) timer.start() func _toggle_cursor_visibility(): if has_focus: cursor_visible = !cursor_visible queue_redraw() func _gui_input(event): if event is InputEventKey and has_focus: handle_key_input(event) if event is InputEventMouseButton and event.pressed: if get_rect().has_point(event.position) or true: grab_focus() handle_mouse_click(event) else: release_focus() func handle_mouse_click(event: InputEventMouseButton): var click_pos = get_local_mouse_position().x var text_to_use = get_display_text() var offset = get_text_offset(text_to_use) var pos = 0 var accumulated_width = 0.0 var found = false for i in range(text_to_use.length()): var char_width = font.get_string_size(text_to_use.substr(i, 1), HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x if accumulated_width + char_width/2 > click_pos - offset: pos = i found = true break accumulated_width += char_width if not found: pos = text_to_use.length() cursor_pos = pos selection = Vector2i(-1, -1) cursor_visible = true queue_redraw() func handle_key_input(event: InputEventKey): var shift_pressed = event.shift_pressed var ctrl_pressed = event.ctrl_pressed # Handle selection if shift_pressed and selection.x == -1: selection = Vector2i(cursor_pos, cursor_pos) # Handle text input if event.unicode != 0 and !event.echo: insert_text_at_cursor(char(event.unicode)) # Handle special keys match event.keycode: KEY_BACKSPACE: delete_prev_char() KEY_DELETE: delete_next_char() KEY_LEFT: move_cursor(-1, ctrl_pressed, shift_pressed) KEY_RIGHT: move_cursor(1, ctrl_pressed, shift_pressed) KEY_HOME: cursor_pos = 0 adjust_selection(shift_pressed) KEY_END: cursor_pos = text.length() adjust_selection(shift_pressed) KEY_C: if ctrl_pressed: copy_to_clipboard() KEY_V: if ctrl_pressed: paste_from_clipboard() queue_redraw() func insert_text_at_cursor(new_text: String): var new_text_filtered = new_text.replace("\n", "").replace("\t", "") if text.length() + new_text_filtered.length() > max_length: return if selection.x != -1: delete_selection() text = text.insert(cursor_pos, new_text_filtered) cursor_pos += new_text_filtered.length() selection = Vector2i(-1, -1) func delete_prev_char(): if cursor_pos > 0: if selection.x != -1: delete_selection() else: text = text.erase(cursor_pos - 1, 1) cursor_pos -= 1 func delete_next_char(): if cursor_pos < text.length(): if selection.x != -1: delete_selection() else: text = text.erase(cursor_pos, 1) func delete_selection(): var start = min(selection.x, selection.y) var end = max(selection.x, selection.y) text = text.erase(start, end - start) cursor_pos = start selection = Vector2i(-1, -1) func move_cursor(direction: int, ctrl: bool, shift: bool): var new_pos = cursor_pos if ctrl: new_pos = find_word_boundary(direction) else: new_pos += direction cursor_pos = clamp(new_pos, 0, text.length()) adjust_selection(shift) func adjust_selection(shift: bool): if shift: selection.y = cursor_pos else: selection = Vector2i(-1, -1) func find_word_boundary(direction: int) -> int: # Simplified word navigation var pos = cursor_pos if direction == -1: while pos > 0 and text[pos-1] == " ": pos -= 1 while pos > 0 and text[pos-1] != " ": pos -= 1 else: while pos < text.length() and text[pos] == " ": pos += 1 while pos < text.length() and text[pos] != " ": pos += 1 return pos func copy_to_clipboard(): if selection.x != -1: var start = min(selection.x, selection.y) var end = max(selection.x, selection.y) DisplayServer.clipboard_set(text.substr(start, end - start)) func paste_from_clipboard(): var clipboard = DisplayServer.clipboard_get() insert_text_at_cursor(clipboard) func get_display_text() -> String: if secret and text.length() > 0: return secret_char.repeat(text.length()) return text if text.length() > 0 else placeholder func get_text_offset(display_text: String) -> float: var text_width = font.get_string_size(display_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x match align: HorizontalAlignment.HORIZONTAL_ALIGNMENT_CENTER: return max(0, (size.x - text_width) / 2) HorizontalAlignment.HORIZONTAL_ALIGNMENT_RIGHT: return max(0, size.x - text_width) _: # Left align return 0.0 func _draw(): var display_text = get_display_text() var text_offset = get_text_offset(display_text) # Draw selection print(selection) if selection.x != -1: draw_selection(text_offset, display_text) # Draw text var text_color = Color.DIM_GRAY if text == "" else Color.BLACK draw_string(font, Vector2(text_offset, size.y / 2 + font_size / 2), display_text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, text_color) # Draw cursor if cursor_visible and has_focus: draw_cursor(text_offset, display_text) func draw_selection(offset: float, display_text: String): var start = min(selection.x, selection.y) var end = max(selection.x, selection.y) var start_pos = offset + font.get_string_size(display_text.left(start), HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x var end_pos = offset + font.get_string_size(display_text.left(end), HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x draw_rect(Rect2(start_pos, 2, end_pos - start_pos, size.y - 4), selection_color, true) func draw_cursor(offset: float, display_text: String): var cursor_x = offset + font.get_string_size(display_text.left(cursor_pos), HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x draw_line(Vector2(cursor_x, 2), Vector2(cursor_x, size.y - 2), cursor_color, 2.0)