From 1f9be6f7ab7b01d389941ac71263019550d0f303 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Fri, 12 Jun 2026 20:04:41 +0100 Subject: [PATCH 01/10] Add Vim mode controls --- project.godot | 41 ++ scenes/Main.tscn | 33 ++ scripts/main.gd | 197 ++++++--- scripts/search_controller.gd | 100 ++++- scripts/vim_command_parser.gd | 191 ++++++++ scripts/vim_command_parser.gd.uid | 1 + scripts/vim_mode_controller.gd | 652 ++++++++++++++++++++++++++++ scripts/vim_mode_controller.gd.uid | 1 + tests/godot/run_tests.gd | 1 + tests/godot/test_vim_command.gd | 108 +++++ tests/godot/test_vim_command.gd.uid | 1 + 11 files changed, 1259 insertions(+), 67 deletions(-) create mode 100644 scripts/vim_command_parser.gd create mode 100644 scripts/vim_command_parser.gd.uid create mode 100644 scripts/vim_mode_controller.gd create mode 100644 scripts/vim_mode_controller.gd.uid create mode 100644 tests/godot/test_vim_command.gd create mode 100644 tests/godot/test_vim_command.gd.uid diff --git a/project.godot b/project.godot index a53f77e..b023a50 100644 --- a/project.godot +++ b/project.godot @@ -73,6 +73,47 @@ seqhiker_close_right_panel={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +seqhiker_vim_scroll_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":72,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_scroll_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":76,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_zoom_out={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":74,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_zoom_in={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":75,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_command={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":58,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":59,"physical_keycode":0,"key_label":0,"unicode":58,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_search={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":47,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_mark_save={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":77,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_mark_load={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":39,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} seqhiker_toggle_search={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":70,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/scenes/Main.tscn b/scenes/Main.tscn index 504bd6b..a455204 100644 --- a/scenes/Main.tscn +++ b/scenes/Main.tscn @@ -590,6 +590,11 @@ max_value = 3.0 step = 0.05 value = 1.5 +[node name="VimModeToggle" type="CheckButton" parent="Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent"] +custom_minimum_size = Vector2(0, 24) +layout_mode = 2 +text = "Vim Mode" + [node name="SoundsToggle" type="CheckButton" parent="Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent"] layout_mode = 2 tooltip_text = "Do your genoming like in the movies! @@ -668,3 +673,31 @@ autowrap_mode = 3 [node name="FeaturePanel" parent="Root/ContentMargin/ViewportLayer" instance=ExtResource("3_30wbj")] layout_mode = 1 + +[node name="VimCommandBar" type="PanelContainer" parent="Root"] +visible = false +custom_minimum_size = Vector2(0, 34) +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="VimCommandMargin" type="MarginContainer" parent="Root/VimCommandBar"] +layout_mode = 2 +theme_override_constants/margin_left = 10 +theme_override_constants/margin_top = 4 +theme_override_constants/margin_right = 10 +theme_override_constants/margin_bottom = 4 + +[node name="VimCommandRow" type="HBoxContainer" parent="Root/VimCommandBar/VimCommandMargin"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="VimCommandPrefix" type="Label" parent="Root/VimCommandBar/VimCommandMargin/VimCommandRow"] +custom_minimum_size = Vector2(12, 0) +layout_mode = 2 +text = "" + +[node name="VimCommandEdit" type="LineEdit" parent="Root/VimCommandBar/VimCommandMargin/VimCommandRow"] +layout_mode = 2 +size_flags_horizontal = 3 +editable = false +virtual_keyboard_enabled = false diff --git a/scripts/main.gd b/scripts/main.gd index 049bec3..b29adfd 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -6,6 +6,7 @@ const LocalZemManagerScript = preload("res://scripts/local_zem_manager.gd") const TileControllerScript = preload("res://scripts/tile_controller.gd") const SearchControllerScript = preload("res://scripts/search_controller.gd") const GoControllerScript = preload("res://scripts/go_controller.gd") +const VimModeControllerScript = preload("res://scripts/vim_mode_controller.gd") const TopBarControllerScript = preload("res://scripts/top_bar_controller.gd") const ContextPanelControllerScript = preload("res://scripts/context_panel_controller.gd") const TrackSettingsControllerScript = preload("res://scripts/track_settings_controller.gd") @@ -153,12 +154,16 @@ const READ_FILTER_FLAG_LABELS := [ @onready var play_speed_slider: HSlider = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/PlaySpeedRow/PlaySpeedSlider @onready var play_speed_value: Label = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/PlaySpeedRow/PlaySpeedValue @onready var animate_pan_zoom_slider: HSlider = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/AnimatePanZoomSlider +@onready var _vim_mode_cb: CheckButton = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/VimModeToggle @onready var _sounds_cb: CheckButton = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/SoundsToggle @onready var theme_option: OptionButton = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/ThemeOption @onready var ui_font_option: OptionButton = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/UIFontOption @onready var sequence_letter_font_option: OptionButton = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/SequenceLetterFontOption @onready var settings_scroll: ScrollContainer = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll @onready var settings_content: VBoxContainer = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent +@onready var _vim_command_bar: PanelContainer = $Root/VimCommandBar +@onready var _vim_command_prefix: Label = $Root/VimCommandBar/VimCommandMargin/VimCommandRow/VimCommandPrefix +@onready var _vim_command_edit: LineEdit = $Root/VimCommandBar/VimCommandMargin/VimCommandRow/VimCommandEdit # Owned here, used by TrackControlsController through its host reference. @warning_ignore("unused_private_class_variable") @onready var _track_order_label: Label = $Root/ContentMargin/ViewportLayer/SettingsPanel/SettingsMargin/SettingsLayout/SettingsScroll/SettingsPadding/SettingsContent/TrackVisibilityLabel @@ -345,6 +350,7 @@ var _settings_shared_label: Label var _settings_shared_box: VBoxContainer var _shared_colorize_nucleotides_cb: CheckButton var _sounds_enabled := false +var _vim_controller: RefCounted var _sound_controller: RefCounted var _theme_editor_controller: RefCounted var _track_controls_controller: RefCounted @@ -393,6 +399,7 @@ func _ready() -> void: _setup_track_visibility_controls() _sync_bam_read_tracks() _setup_debug_controls() + _setup_vim_command_bar() _setup_track_settings_panel() _connect_ui() _setup_settings_sections() @@ -780,18 +787,43 @@ func _setup_font_size_control() -> void: if not _font_size_slider.drag_ended.is_connected(_on_font_size_drag_ended): _font_size_slider.drag_ended.connect(_on_font_size_drag_ended) +func _scroll_left_by_step() -> void: + _play_ui_sound(SoundControllerScript.SOUND_PAN_LEFT) + if _app_mode == APP_MODE_COMPARISON: + if comparison_view != null and comparison_view.has_method("pan_all_by_fraction"): + comparison_view.pan_all_by_fraction(-_pan_step_percent / 100.0) + return + _pan_view_by_fraction(-_pan_step_percent / 100.0) + +func _scroll_right_by_step() -> void: + _play_ui_sound(SoundControllerScript.SOUND_PAN_RIGHT) + if _app_mode == APP_MODE_COMPARISON: + if comparison_view != null and comparison_view.has_method("pan_all_by_fraction"): + comparison_view.pan_all_by_fraction(_pan_step_percent / 100.0) + return + _pan_view_by_fraction(_pan_step_percent / 100.0) + +func _zoom_in_by_step() -> void: + _play_ui_sound(SoundControllerScript.SOUND_ZOOM_IN) + if _app_mode == APP_MODE_COMPARISON: + if comparison_view != null and comparison_view.has_method("zoom_by"): + comparison_view.zoom_by(0.78) + return + genome_view.zoom_by(0.78) + +func _zoom_out_by_step() -> void: + _play_ui_sound(SoundControllerScript.SOUND_ZOOM_OUT) + if _app_mode == APP_MODE_COMPARISON: + if comparison_view != null and comparison_view.has_method("zoom_by"): + comparison_view.zoom_by(1.28) + return + genome_view.zoom_by(1.28) + func _connect_ui() -> void: settings_toggle_button.pressed.connect(_toggle_settings) close_settings_button.pressed.connect(_close_settings) open_file_button.pressed.connect(_on_open_file_pressed) - pan_left_button.pressed.connect(func() -> void: - _play_ui_sound(SoundControllerScript.SOUND_PAN_LEFT) - if _app_mode == APP_MODE_COMPARISON: - if comparison_view != null and comparison_view.has_method("pan_all_by_fraction"): - comparison_view.pan_all_by_fraction(-_pan_step_percent / 100.0) - return - _pan_view_by_fraction(-_pan_step_percent / 100.0) - ) + pan_left_button.pressed.connect(_scroll_left_by_step) jump_start_button.pressed.connect(func() -> void: _play_ui_sound(SoundControllerScript.SOUND_JUMP) if _app_mode == APP_MODE_COMPARISON: @@ -800,14 +832,7 @@ func _connect_ui() -> void: return _navigate_to_boundary(false) ) - pan_right_button.pressed.connect(func() -> void: - _play_ui_sound(SoundControllerScript.SOUND_PAN_RIGHT) - if _app_mode == APP_MODE_COMPARISON: - if comparison_view != null and comparison_view.has_method("pan_all_by_fraction"): - comparison_view.pan_all_by_fraction(_pan_step_percent / 100.0) - return - _pan_view_by_fraction(_pan_step_percent / 100.0) - ) + pan_right_button.pressed.connect(_scroll_right_by_step) jump_end_button.pressed.connect(func() -> void: _play_ui_sound(SoundControllerScript.SOUND_JUMP) if _app_mode == APP_MODE_COMPARISON: @@ -816,22 +841,8 @@ func _connect_ui() -> void: return _navigate_to_boundary(true) ) - zoom_in_button.pressed.connect(func() -> void: - _play_ui_sound(SoundControllerScript.SOUND_ZOOM_IN) - if _app_mode == APP_MODE_COMPARISON: - if comparison_view != null and comparison_view.has_method("zoom_by"): - comparison_view.zoom_by(0.78) - return - genome_view.zoom_by(0.78) - ) - zoom_out_button.pressed.connect(func() -> void: - _play_ui_sound(SoundControllerScript.SOUND_ZOOM_OUT) - if _app_mode == APP_MODE_COMPARISON: - if comparison_view != null and comparison_view.has_method("zoom_by"): - comparison_view.zoom_by(1.28) - return - genome_view.zoom_by(1.28) - ) + zoom_in_button.pressed.connect(_zoom_in_by_step) + zoom_out_button.pressed.connect(_zoom_out_by_step) play_button.pressed.connect(_start_auto_play) play_left_button.pressed.connect(_start_auto_play_left) stop_button.pressed.connect(_stop_auto_play) @@ -940,6 +951,7 @@ func _disable_button_focus() -> void: jump_end_button, zoom_out_button, zoom_in_button, + screenshot_button, play_button, play_left_button, stop_button, @@ -952,7 +964,8 @@ func _disable_button_focus() -> void: mouse_wheel_pan_slider, pan_step_slider, play_speed_slider, - animate_pan_zoom_slider + animate_pan_zoom_slider, + _vim_mode_cb ] if _theme_editor_controller != null: for control in _theme_editor_controller.focus_controls(): @@ -1166,6 +1179,8 @@ func _apply_palette(palette: Dictionary, theme_name: String = "") -> void: feature_seq_label.add_theme_color_override("default_color", palette["text"]) _apply_panel_style(settings_panel, palette) _apply_panel_style(feature_panel, palette) + _apply_panel_style(_vim_command_bar, palette) + _apply_vim_bar_theme(palette) _apply_search_theme(palette) if _debug_stats_label != null: _debug_stats_label.add_theme_color_override("font_color", palette["text"]) @@ -1529,6 +1544,22 @@ func _setup_debug_controls() -> void: _debug_loaded_files_label.text = "" _update_loaded_files_debug_label() +func _setup_vim_command_bar() -> void: + if _vim_controller == null: + _vim_controller = VimModeControllerScript.new() + _vim_controller.setup(self, _vim_mode_cb, _vim_command_bar, _vim_command_prefix, _vim_command_edit) + +func _set_vim_mode_enabled(enabled: bool) -> void: + if _vim_controller != null: + _vim_controller.set_enabled(enabled) + +func _is_vim_mode_enabled() -> bool: + return _vim_controller != null and _vim_controller.is_enabled() + +func _show_vim_bar_message(message: String) -> void: + if _vim_controller != null: + _vim_controller.show_message(message) + func _on_bam_cov_cutoff_changed(value: float) -> void: _bam_cov_precompute_cutoff_bp = maxi(0, int(round(value))) @@ -1917,7 +1948,10 @@ func _update_window_min_height() -> void: var topbar_h := 0.0 if top_bar != null: topbar_h = top_bar.get_combined_minimum_size().y - var min_h: float = topbar_h + ROOT_VERTICAL_GAP + CONTENT_MARGIN_BOTTOM + tracks_h + var vim_bar_h := 0.0 + if _vim_command_bar != null and _vim_command_bar.visible: + vim_bar_h = ROOT_VERTICAL_GAP + _vim_command_bar.get_combined_minimum_size().y + var min_h: float = topbar_h + ROOT_VERTICAL_GAP + CONTENT_MARGIN_BOTTOM + tracks_h + vim_bar_h var w := get_window() if w != null: w.min_size.y = maxi(200, ceili(min_h)) @@ -2554,6 +2588,10 @@ func _apply_panel_style(panel: PanelContainer, palette: Dictionary) -> void: panel_sb.set_corner_radius_all(10) panel.add_theme_stylebox_override("panel", panel_sb) +func _apply_vim_bar_theme(palette: Dictionary) -> void: + if _vim_controller != null: + _vim_controller.apply_theme(palette) + func _apply_search_theme(palette: Dictionary) -> void: if _search_controller != null: _search_controller.apply_theme(palette) @@ -3027,25 +3065,42 @@ func _make_view_slot_button(action_name: String, callback: Callable) -> Button: add_child(b) return b +func _view_mark_slot_key(mark_letter: String) -> String: + return "mark:%s" % mark_letter + func _save_view_slot(slot_idx: int) -> void: + _save_view_state(slot_idx, "view slot %d" % slot_idx, "view slot") + +func _load_view_slot(slot_idx: int) -> void: + _load_view_state(slot_idx, "view slot %d" % slot_idx, "View slot %d" % slot_idx, "view slot") + +func _save_view_mark(mark_letter: String) -> void: + if _save_view_state(_view_mark_slot_key(mark_letter), "mark %s" % mark_letter, "mark"): + _show_vim_bar_message("mark %s set" % mark_letter) + +func _load_view_mark(mark_letter: String) -> void: + if _load_view_state(_view_mark_slot_key(mark_letter), "mark %s" % mark_letter, "Mark %s" % mark_letter, "mark"): + _show_vim_bar_message("jumped to mark %s" % mark_letter) + +func _save_view_state(slot_key: Variant, status_label: String, error_label: String) -> bool: var slot_bank: Dictionary = _view_slots.get(_app_mode, {}) if _app_mode == APP_MODE_COMPARISON: if _comparison_controller == null or not _comparison_controller.has_genomes(): - _set_status("No comparison genomes loaded: cannot save view slot.", true) - return - slot_bank[slot_idx] = { + _set_status("No comparison genomes loaded: cannot save %s." % error_label, true) + return false + slot_bank[slot_key] = { "app_mode": APP_MODE_COMPARISON, "scope_key": _comparison_controller.get_view_slot_scope_key(), "comparison_state": _comparison_controller.get_view_slot_state() } _view_slots[_app_mode] = slot_bank - _set_status("Saved view slot %d." % slot_idx) - return + _set_status("Saved %s." % status_label) + return true if _current_chr_len <= 0: - _set_status("No genome loaded: cannot save view slot.", true) - return + _set_status("No genome loaded: cannot save %s." % error_label, true) + return false var state: Dictionary = genome_view.get_view_state() - slot_bank[slot_idx] = { + slot_bank[slot_key] = { "scope_key": _scope_cache_key(), "seq_view_mode": _seq_view_mode, "seq_id": _selected_seq_id, @@ -3053,41 +3108,42 @@ func _save_view_slot(slot_idx: int) -> void: "bp_per_px": float(state.get("bp_per_px", _last_bp_per_px)) } _view_slots[_app_mode] = slot_bank - _set_status("Saved view slot %d." % slot_idx) + _set_status("Saved %s." % status_label) + return true -func _load_view_slot(slot_idx: int) -> void: +func _load_view_state(slot_key: Variant, status_label: String, sentence_label: String, error_label: String) -> bool: var slot_bank: Dictionary = _view_slots.get(_app_mode, {}) - if not slot_bank.has(slot_idx): - _set_status("View slot %d is empty." % slot_idx, true) - return - var slot_any = slot_bank[slot_idx] + if not slot_bank.has(slot_key): + _set_status("%s is empty." % sentence_label, true) + return false + var slot_any = slot_bank[slot_key] if typeof(slot_any) != TYPE_DICTIONARY: - _set_status("View slot %d is invalid." % slot_idx, true) - return + _set_status("%s is invalid." % sentence_label, true) + return false var slot: Dictionary = slot_any var slot_mode := int(slot.get("app_mode", APP_MODE_BROWSER)) if slot_mode == APP_MODE_COMPARISON: if _comparison_controller == null or not _comparison_controller.has_genomes(): - _set_status("No comparison genomes loaded: cannot load view slot.", true) - return + _set_status("No comparison genomes loaded: cannot load %s." % error_label, true) + return false var slot_scope_cmp := str(slot.get("scope_key", "")) if slot_scope_cmp != _comparison_controller.get_view_slot_scope_key(): - _set_status("View slot %d is from a different comparison scope." % slot_idx, true) - return + _set_status("%s is from a different comparison scope." % sentence_label, true) + return false var cmp_state_any: Variant = slot.get("comparison_state", {}) if typeof(cmp_state_any) != TYPE_DICTIONARY: - _set_status("View slot %d is invalid." % slot_idx, true) - return + _set_status("%s is invalid." % sentence_label, true) + return false _comparison_controller.apply_view_slot_state(cmp_state_any) - _set_status("Loaded view slot %d." % slot_idx) - return + _set_status("Loaded %s." % status_label) + return true if _current_chr_len <= 0: - _set_status("No genome loaded: cannot load view slot.", true) - return + _set_status("No genome loaded: cannot load %s." % error_label, true) + return false var slot_scope := str(slot.get("scope_key", "")) if slot_scope != _scope_cache_key(): - _set_status("View slot %d is from a different genome/session scope." % slot_idx, true) - return + _set_status("%s is from a different genome/session scope." % sentence_label, true) + return false var seq_slot_mode := int(slot.get("seq_view_mode", _seq_view_mode)) if seq_slot_mode != _seq_view_mode: _seq_view_option.select(seq_slot_mode) @@ -3100,7 +3156,8 @@ func _load_view_slot(slot_idx: int) -> void: _on_seq_selected(i) break _navigate_to_view(float(slot.get("start_bp", _last_start)), float(slot.get("bp_per_px", _last_bp_per_px))) - _set_status("Loaded view slot %d." % slot_idx) + _set_status("Loaded %s." % status_label) + return true func _is_viewport_cached(start_bp: int, end_bp: int, zoom: int, mode: int, need_reference: bool, scope_key: String) -> bool: if _cache_start < 0 || _cache_end < 0: @@ -3154,6 +3211,7 @@ func _load_or_init_config() -> void: mouse_wheel_pan_slider.value = float(cfg.get_value("input", "mouse_wheel_pan_sensitivity", mouse_wheel_pan_slider.value)) pan_step_slider.value = clampf(float(cfg.get_value("input", "pan_step_percent", 75.0)), 1.0, 100.0) _on_pan_step_changed(pan_step_slider.value) + _set_vim_mode_enabled(bool(cfg.get_value("input", "vim_mode_enabled", false))) _ui_font_size = clampi(int(cfg.get_value("ui", "font_size", DEFAULT_UI_FONT_SIZE)), MIN_UI_FONT_SIZE, MAX_UI_FONT_SIZE) if _font_size_slider != null: _font_size_slider.value = _ui_font_size @@ -3279,6 +3337,7 @@ func _save_config() -> void: cfg.set_value("input", "invert_mouse_wheel_zoom", invert_mouse_wheel_zoom_button.button_pressed) cfg.set_value("input", "mouse_wheel_pan_sensitivity", mouse_wheel_pan_slider.value) cfg.set_value("input", "pan_step_percent", _pan_step_percent) + cfg.set_value("input", "vim_mode_enabled", _is_vim_mode_enabled()) cfg.save(CONFIG_PATH) func _on_feature_clicked(feature: Dictionary) -> void: @@ -3416,7 +3475,19 @@ func _process(_delta: float) -> void: return _start_next_auto_play_segment() +func _handle_vim_input(event: InputEvent) -> bool: + return _vim_controller != null and _vim_controller.handle_input(event) + +func _handle_vim_command_escape(event: InputEvent) -> bool: + return _vim_controller != null and _vim_controller.handle_escape(event) + func _unhandled_input(event: InputEvent) -> void: + if _handle_vim_command_escape(event): + get_viewport().set_input_as_handled() + return + if _handle_vim_input(event): + get_viewport().set_input_as_handled() + return if event.is_action_pressed("seqhiker_close_right_panel"): if _feature_panel_open: _close_feature_panel() diff --git a/scripts/search_controller.gd b/scripts/search_controller.gd index b439118..e24f846 100644 --- a/scripts/search_controller.gd +++ b/scripts/search_controller.gd @@ -99,6 +99,20 @@ func focus_query() -> void: if _search_query_edit != null: _search_query_edit.grab_focus() +func run_browser_search(mode: int, query: String, chr_filter: PackedInt32Array, scope_label: String = "") -> void: + if _search_box == null or _search_query_edit == null: + return + show_panel() + _select_search_mode(mode) + _search_query_edit.text = query + if mode == SEARCH_MODE_DNA_EXACT and _search_min_len_spin != null: + var clean := query.to_upper().replace(" ", "").replace("\n", "").replace("\t", "") + var supported_min := maxi(1, int(_search_min_len_spin.min_value)) + if clean.length() >= supported_min and clean.length() < int(_search_min_len_spin.value): + _search_min_len_spin.value = clean.length() + _select_browser_chr_filter(chr_filter, scope_label) + _run_search(false) + func refresh_context() -> void: _refresh_scope_options() @@ -141,7 +155,84 @@ func _on_search_mode_changed(index: int) -> void: if _search_query_edit != null: _search_query_edit.placeholder_text = "ATGC..." if show_dna else "Name, ID, type, source..." -func _run_search() -> void: +func _select_search_mode(mode: int) -> void: + if _search_mode_option == null: + return + for i in range(_search_mode_option.item_count): + if int(_search_mode_option.get_item_id(i)) != mode: + continue + _search_mode_option.select(i) + _on_search_mode_changed(i) + return + +func _select_browser_chr_filter(chr_filter: PackedInt32Array, scope_label: String) -> void: + if _search_scope_option == null or _is_comparison_mode(): + return + if chr_filter.is_empty(): + return + var all_chr_ids := _search_chr_ids_for_scope(SEARCH_SCOPE_ALL) + if _same_chr_filter(chr_filter, all_chr_ids): + _select_browser_single_scope(SEARCH_SCOPE_ALL) + return + var label := scope_label.strip_edges() + if label.is_empty(): + label = "Selected sequence" + var idx := _search_scope_option.item_count + _search_scope_option.add_item(label) + _search_scope_option.set_item_metadata(idx, {"chr_ids": chr_filter}) + _search_scope_option.select(idx) + +func _select_browser_single_scope(scope: int) -> void: + if _search_scope_option == null: + return + for i in range(_search_scope_option.item_count): + var meta = _search_scope_option.get_item_metadata(i) + if typeof(meta) != TYPE_DICTIONARY: + continue + var data: Dictionary = meta + if int(data.get("single_scope", -1)) == scope: + _search_scope_option.select(i) + return + +func _selected_browser_scope_has_chr_filter() -> bool: + if _search_scope_option == null or _is_comparison_mode(): + return false + var idx := _search_scope_option.selected + if idx < 0: + return false + var meta = _search_scope_option.get_item_metadata(idx) + return typeof(meta) == TYPE_DICTIONARY and (meta as Dictionary).has("chr_ids") + +func _browser_scope_chr_ids() -> PackedInt32Array: + if _search_scope_option != null: + var idx := _search_scope_option.selected + if idx >= 0: + var meta = _search_scope_option.get_item_metadata(idx) + if typeof(meta) == TYPE_DICTIONARY: + var data: Dictionary = meta + if data.has("chr_ids"): + return _packed_ints_from_variant(data.get("chr_ids", PackedInt32Array())) + return _search_chr_ids_for_scope(int(data.get("single_scope", SEARCH_SCOPE_CURRENT))) + return _search_chr_ids_for_scope(SEARCH_SCOPE_CURRENT) + +func _packed_ints_from_variant(value: Variant) -> PackedInt32Array: + var out := PackedInt32Array() + if value is PackedInt32Array: + return value + if value is Array: + for id_any in value: + out.append(int(id_any)) + return out + +func _same_chr_filter(a: PackedInt32Array, b: PackedInt32Array) -> bool: + if a.size() != b.size(): + return false + for id in a: + if b.find(id) < 0: + return false + return true + +func _run_search(refresh_scope_options: bool = true) -> void: if _search_running: return if _search_query_edit == null or _search_status_label == null or _search_results_list == null: @@ -154,7 +245,8 @@ func _run_search() -> void: if zem == null: _search_status_label.text = "Not connected." return - _refresh_scope_options() + if refresh_scope_options and not _selected_browser_scope_has_chr_filter(): + _refresh_scope_options() _search_running = true _search_hits.clear() _search_results_list.clear() @@ -176,7 +268,7 @@ func _run_search() -> void: if is_comparison: dna_res = await _search_comparison_dna_exact(clean, include_revcomp) else: - dna_res = await _search_dna_exact(clean, _search_chr_ids_for_scope(_single_scope_value()), include_revcomp) + dna_res = await _search_dna_exact(clean, _browser_scope_chr_ids(), include_revcomp) ok = bool(dna_res.get("ok", false)) truncated = bool(dna_res.get("truncated", false)) else: @@ -185,7 +277,7 @@ func _run_search() -> void: if is_comparison: ann_res = await _search_comparison_annotations_text(q, case_sensitive) else: - ann_res = await _search_annotations_text(q, _search_chr_ids_for_scope(_single_scope_value()), case_sensitive) + ann_res = await _search_annotations_text(q, _browser_scope_chr_ids(), case_sensitive) ok = bool(ann_res.get("ok", false)) truncated = bool(ann_res.get("truncated", false)) if not ok: diff --git a/scripts/vim_command_parser.gd b/scripts/vim_command_parser.gd new file mode 100644 index 0000000..a096a1c --- /dev/null +++ b/scripts/vim_command_parser.gd @@ -0,0 +1,191 @@ +extends RefCounted + +const SEARCH_TARGET_ANNOTATION := "annotation" +const SEARCH_TARGET_DNA := "dna" +const COMMAND_COLORSCHEME := "colorscheme" + + +static func split_go_colon_sequence_and_range(args: String) -> Dictionary: + var clean := args.strip_edges() + var colon_idx := clean.rfind(":") + if colon_idx < 0: + return {} + var seq_name := clean.substr(0, colon_idx).strip_edges() + var range_text := clean.substr(colon_idx + 1).strip_edges() + if seq_name.is_empty() or range_text.is_empty(): + return {} + if parse_go_range(range_text).is_empty(): + return {} + return {"sequence": seq_name, "range": range_text} + + +static func split_go_sequence_and_range(args: String) -> Dictionary: + var clean := args.strip_edges() + var last_space := -1 + for i in range(clean.length() - 1, -1, -1): + var code := clean.unicode_at(i) + if code <= 32: + last_space = i + break + if last_space < 0: + return {} + var seq_name := clean.substr(0, last_space).strip_edges() + var range_text := clean.substr(last_space + 1).strip_edges() + if seq_name.is_empty() or range_text.is_empty(): + return {} + return {"sequence": seq_name, "range": range_text} + + +static func parse_go_range(text: String) -> Dictionary: + var clean := text.strip_edges().replace(",", "").replace(" ", "") + if clean.is_empty(): + return {} + var dash_idx := clean.find("-") + if dash_idx < 0: + var point := parse_bp(clean) + return {"start": point, "end": -1} if point >= 1 else {} + if clean.find("-", dash_idx + 1) >= 0: + return {} + var start_text := clean.substr(0, dash_idx) + var end_text := clean.substr(dash_idx + 1) + var start_display := parse_bp(start_text) + var end_display := parse_bp(end_text) + if start_display < 1 or end_display < 1: + return {} + if end_display < start_display: + var swap := start_display + start_display = end_display + end_display = swap + return {"start": start_display, "end": end_display} + + +static func parse_bp(text: String) -> int: + if text.is_empty() or not text.is_valid_int(): + return -1 + var value := int(text) + return value if value >= 1 else -1 + + +static func parse_search(text: String, sequence_names: PackedStringArray = PackedStringArray()) -> Dictionary: + var clean := text.strip_edges() + if clean.is_empty(): + return _search_error() + var mode_sep := first_whitespace_index(clean) + if mode_sep < 0: + if is_dna_query(clean): + return { + "ok": true, + "target": SEARCH_TARGET_DNA, + "sequence": "", + "query": clean + } + return _search_error("enter a search query") + var target := search_target_from_token(clean.substr(0, mode_sep)) + if target.is_empty(): + return _search_error("search type must be annotation or dna") + var rest := clean.substr(mode_sep + 1).strip_edges() + if rest.is_empty(): + return _search_error("enter a search query") + var sequence_name := sequence_name_at_start(rest, sequence_names) + if sequence_name.is_empty(): + return { + "ok": true, + "target": target, + "sequence": "", + "query": rest + } + var query := rest.substr(sequence_name.length()).strip_edges() + if query.is_empty(): + return _search_error("enter a search query") + return { + "ok": true, + "target": target, + "sequence": sequence_name, + "query": query + } + + +static func search_target_from_token(token: String) -> String: + var clean := token.strip_edges().to_lower() + if clean.is_empty(): + return "" + var is_annotation := SEARCH_TARGET_ANNOTATION.begins_with(clean) + var is_dna := SEARCH_TARGET_DNA.begins_with(clean) + if is_annotation and not is_dna: + return SEARCH_TARGET_ANNOTATION + if is_dna and not is_annotation: + return SEARCH_TARGET_DNA + return "" + + +static func is_dna_query(text: String) -> bool: + var clean := text.strip_edges() + if clean.is_empty(): + return false + for i in range(clean.length()): + match clean.unicode_at(i): + 65, 67, 71, 84, 97, 99, 103, 116: + continue + _: + return false + return true + + +static func first_whitespace_index(text: String) -> int: + for i in range(text.length()): + if text.unicode_at(i) <= 32: + return i + return -1 + + +static func sequence_name_at_start(text: String, sequence_names: PackedStringArray) -> String: + var clean := text.strip_edges() + var lower := clean.to_lower() + var names: Array[String] = [] + for name_any in sequence_names: + var name := str(name_any).strip_edges() + if not name.is_empty(): + names.append(name) + names.sort_custom(func(a: String, b: String) -> bool: + return a.length() > b.length() + ) + for name in names: + var lower_name := name.to_lower() + if lower == lower_name: + return name + if lower.length() <= lower_name.length(): + continue + if not lower.begins_with(lower_name): + continue + if clean.unicode_at(lower_name.length()) <= 32: + return name + return "" + + +static func _search_error(message: String = "usage: /annotation [sequence] query") -> Dictionary: + return { + "ok": false, + "error": message + } + + +static func parse_colorscheme(text: String) -> Dictionary: + var clean := text.strip_edges() + if clean == COMMAND_COLORSCHEME: + return _colorscheme_error("usage: colorscheme ") + if not clean.to_lower().begins_with("%s " % COMMAND_COLORSCHEME): + return _colorscheme_error() + var theme_name := clean.substr(COMMAND_COLORSCHEME.length()).strip_edges() + if theme_name.is_empty(): + return _colorscheme_error("usage: colorscheme ") + return { + "ok": true, + "theme": theme_name + } + + +static func _colorscheme_error(message: String = "not a colorscheme command") -> Dictionary: + return { + "ok": false, + "error": message + } diff --git a/scripts/vim_command_parser.gd.uid b/scripts/vim_command_parser.gd.uid new file mode 100644 index 0000000..4271e27 --- /dev/null +++ b/scripts/vim_command_parser.gd.uid @@ -0,0 +1 @@ +uid://bk23de5ov4g6s diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd new file mode 100644 index 0000000..c4dbd71 --- /dev/null +++ b/scripts/vim_mode_controller.gd @@ -0,0 +1,652 @@ +extends RefCounted +class_name VimModeController + +const VimCommandParserScript = preload("res://scripts/vim_command_parser.gd") +const SearchControllerScript = preload("res://scripts/search_controller.gd") + +const VIM_MARK_LETTERS := "abcdefghijklmnopqrstuvwxyz" +const VIM_MARK_ACTION_SAVE := "save" +const VIM_MARK_ACTION_LOAD := "load" +const VIM_COMMAND_PREFIX_COMMAND := ":" +const VIM_COMMAND_PREFIX_SEARCH := "/" +const VIM_COLON_COMMANDS := ["go", "colorscheme", "q", "quit"] + +var host: Node = null +var mode_cb: CheckButton = null +var command_bar: PanelContainer = null +var command_prefix: Label = null +var command_edit: LineEdit = null + +var _enabled := false +var _command_active := false +var _command_prefix_text := VIM_COMMAND_PREFIX_COMMAND +var _pending_mark_action := "" +var _completion_matches := PackedStringArray() +var _completion_index := -1 + + +func setup(next_host: Node, next_mode_cb: CheckButton, next_command_bar: PanelContainer, next_command_prefix: Label, next_command_edit: LineEdit) -> void: + host = next_host + mode_cb = next_mode_cb + command_bar = next_command_bar + command_prefix = next_command_prefix + command_edit = next_command_edit + _command_active = false + _command_prefix_text = VIM_COMMAND_PREFIX_COMMAND + if command_prefix != null: + command_prefix.text = "" + if command_edit != null: + command_edit.text = "" + command_edit.editable = false + command_edit.focus_mode = Control.FOCUS_NONE + if not command_edit.text_submitted.is_connected(_on_command_submitted): + command_edit.text_submitted.connect(_on_command_submitted) + if not command_edit.focus_exited.is_connected(_on_command_focus_exited): + command_edit.focus_exited.connect(_on_command_focus_exited) + if not command_edit.gui_input.is_connected(_on_command_edit_gui_input): + command_edit.gui_input.connect(_on_command_edit_gui_input) + if mode_cb != null and not mode_cb.toggled.is_connected(_on_mode_toggled): + mode_cb.toggled.connect(_on_mode_toggled) + sync_command_bar() + + +func is_enabled() -> bool: + return _enabled + + +func set_enabled(enabled: bool) -> void: + _enabled = enabled + if mode_cb != null and mode_cb.button_pressed != enabled: + mode_cb.set_pressed_no_signal(enabled) + if not enabled: + _command_active = false + _command_prefix_text = VIM_COMMAND_PREFIX_COMMAND + _pending_mark_action = "" + if command_edit != null: + command_edit.text = "" + if command_edit.has_focus(): + command_edit.release_focus() + sync_command_bar() + if host != null: + host._update_window_min_height() + + +func sync_command_bar() -> void: + if command_bar != null: + command_bar.visible = _enabled + if command_prefix != null: + command_prefix.text = _command_prefix_text if _command_active else "" + if command_edit != null: + command_edit.editable = _command_active + command_edit.focus_mode = Control.FOCUS_ALL if _command_active else Control.FOCUS_NONE + if _command_active: + command_edit.grab_focus() + command_edit.caret_column = command_edit.text.length() + elif command_edit.has_focus(): + command_edit.release_focus() + + +func show_message(message: String) -> void: + if not _enabled or _command_active: + return + if command_prefix != null: + command_prefix.text = "" + if command_edit != null: + command_edit.text = message + + +func apply_theme(palette: Dictionary) -> void: + if command_prefix != null: + command_prefix.add_theme_color_override("font_color", palette["text"]) + if command_edit == null: + return + var text_color: Color = palette["text"] + command_edit.add_theme_color_override("font_color", text_color) + command_edit.add_theme_color_override("font_uneditable_color", text_color) + command_edit.add_theme_color_override("font_placeholder_color", text_color) + command_edit.add_theme_color_override("caret_color", text_color) + + +func handle_escape(event: InputEvent) -> bool: + if not _command_active: + return false + if not _is_escape_key_event(event): + return false + _finish_command() + return true + + +func handle_input(event: InputEvent) -> bool: + if not _enabled: + return false + if not (event is InputEventKey): + return false + var key_event := event as InputEventKey + if not key_event.pressed: + return false + if key_event.alt_pressed or key_event.ctrl_pressed or key_event.meta_pressed: + _pending_mark_action = "" + return false + if _command_active or _is_text_entry_focused(): + _pending_mark_action = "" + return false + if _handle_mark_target(key_event): + return true + if event.is_action_pressed("seqhiker_vim_command"): + _begin_command(VIM_COMMAND_PREFIX_COMMAND) + return true + if event.is_action_pressed("seqhiker_vim_search"): + _begin_command(VIM_COMMAND_PREFIX_SEARCH) + return true + if event.is_action_pressed("seqhiker_vim_mark_save"): + _begin_mark_action(VIM_MARK_ACTION_SAVE) + return true + if event.is_action_pressed("seqhiker_vim_mark_load"): + _begin_mark_action(VIM_MARK_ACTION_LOAD) + return true + if key_event.shift_pressed: + return false + if event.is_action_pressed("seqhiker_vim_scroll_left"): + host._scroll_left_by_step() + return true + if event.is_action_pressed("seqhiker_vim_scroll_right"): + host._scroll_right_by_step() + return true + if event.is_action_pressed("seqhiker_vim_zoom_out"): + host._zoom_out_by_step() + return true + if event.is_action_pressed("seqhiker_vim_zoom_in"): + host._zoom_in_by_step() + return true + return false + + +func _begin_command(prefix_text: String = VIM_COMMAND_PREFIX_COMMAND) -> void: + if not _enabled or command_edit == null: + return + _pending_mark_action = "" + _reset_completion() + _command_prefix_text = prefix_text + _command_active = true + command_edit.text = "" + sync_command_bar() + + +func _finish_command() -> void: + _command_active = false + _reset_completion() + if command_edit != null: + command_edit.text = "" + sync_command_bar() + + +func _execute_command(command: String) -> void: + var clean := command.strip_edges() + if clean.is_empty(): + return + var lower := clean.to_lower() + if lower == "go" or lower.begins_with("go "): + _execute_go_command(clean) + elif lower == VimCommandParserScript.COMMAND_COLORSCHEME or lower.begins_with("%s " % VimCommandParserScript.COMMAND_COLORSCHEME): + _execute_colorscheme_command(clean) + elif lower == "q" or lower == "quit": + _execute_quit_command() + + +func _execute_colorscheme_command(command: String) -> void: + var parsed := VimCommandParserScript.parse_colorscheme(command) + if not bool(parsed.get("ok", false)): + _show_bar_error(str(parsed.get("error", "usage: colorscheme "))) + return + var theme_name := _theme_name_for_input(str(parsed.get("theme", ""))) + if theme_name.is_empty(): + _show_bar_error("unknown theme: %s" % str(parsed.get("theme", ""))) + return + host._apply_classic_font_defaults_for_theme(theme_name) + host._select_theme_option(theme_name) + host._apply_theme(theme_name) + host._save_config() + host._set_status("Theme: %s" % theme_name) + show_message("colorscheme %s" % theme_name) + + +func _execute_quit_command() -> void: + if host == null or host.get_tree() == null: + return + host.get_tree().quit() + + +func _show_bar_error(message: String) -> void: + host._set_status(message, true) + show_message(message) + + +func _theme_name_for_input(theme_name: String) -> String: + var clean := theme_name.strip_edges() + if clean.is_empty() or host._themes_lib == null: + return "" + var lower := clean.to_lower() + for name in host._themes_lib.theme_names(): + if str(name) == clean: + return str(name) + for name in host._themes_lib.theme_names(): + if str(name).to_lower() == lower: + return str(name) + return "" + + +func _execute_search(command: String) -> void: + if host._app_mode != host.APP_MODE_BROWSER: + _show_search_error("search is only available in browser view") + return + if host._search_controller == null or not host._search_controller.has_method("run_browser_search"): + _show_search_error("search unavailable") + return + if host._chromosomes.is_empty(): + _show_search_error("no sequences loaded") + return + var parsed := VimCommandParserScript.parse_search(command, _sequence_names()) + if not bool(parsed.get("ok", false)): + _show_search_error(str(parsed.get("error", "usage: /annotation [sequence] query"))) + return + var target := str(parsed.get("target", "")) + var mode := SearchControllerScript.SEARCH_MODE_ANNOTATION + if target == VimCommandParserScript.SEARCH_TARGET_DNA: + mode = SearchControllerScript.SEARCH_MODE_DNA_EXACT + var sequence_name := str(parsed.get("sequence", "")) + var query := str(parsed.get("query", "")).strip_edges() + var chr_filter := PackedInt32Array() + var scope_label := "All sequences" + if sequence_name.is_empty(): + for chr_any in host._chromosomes: + var chromosome: Dictionary = chr_any + var chr_id := int(chromosome.get("id", -1)) + if chr_id >= 0: + chr_filter.append(chr_id) + else: + var chromosome := _chromosome_for_go_name(sequence_name) + if chromosome.is_empty(): + _show_search_error("unknown sequence: %s" % sequence_name) + return + var chr_id := int(chromosome.get("id", -1)) + if chr_id < 0: + _show_search_error("sequence unavailable") + return + chr_filter.append(chr_id) + scope_label = str(chromosome.get("name", sequence_name)) + if chr_filter.is_empty(): + _show_search_error("no sequences loaded") + return + if not _show_search_panel(): + _show_search_error("search unavailable") + return + host._search_controller.run_browser_search(mode, query, chr_filter, scope_label) + + +func _show_search_error(message: String) -> void: + host._set_status(message, true) + show_message(message) + + +func _show_search_panel() -> bool: + if host._theme_editor_controller != null and host._theme_editor_controller.is_open(): + return false + if host._context_panel_controller == null or host._search_controller == null: + return false + host._context_panel_controller.prepare_context_panel(host.CONTEXT_PANEL_SEARCH, "Search", false) + host._search_controller.show_panel() + host._feature_panel_open = true + host._slide_feature_panel(true, true) + return true + + +func _sequence_names() -> PackedStringArray: + var out := PackedStringArray() + for chr_any in host._chromosomes: + var chromosome: Dictionary = chr_any + var seq_name := str(chromosome.get("name", "")).strip_edges() + if not seq_name.is_empty() and out.find(seq_name) < 0: + out.append(seq_name) + return out + + +func _execute_go_command(command: String) -> void: + if host._app_mode != host.APP_MODE_BROWSER: + _show_go_error("go is only available in browser view") + return + if host._chromosomes.is_empty(): + _show_go_error("no sequences loaded") + return + var args := command.substr(2).strip_edges() + if args.is_empty(): + _show_go_error("usage: go [sequence] start[-end]") + return + var target_seq_name := "" + var range_text := args + var parsed_range := VimCommandParserScript.parse_go_range(range_text) + if parsed_range.is_empty(): + var split := VimCommandParserScript.split_go_colon_sequence_and_range(args) + if split.is_empty(): + split = VimCommandParserScript.split_go_sequence_and_range(args) + if split.is_empty(): + _show_go_error("usage: go [sequence] start[-end]") + return + target_seq_name = str(split.get("sequence", "")) + range_text = str(split.get("range", "")) + parsed_range = VimCommandParserScript.parse_go_range(range_text) + if parsed_range.is_empty(): + _show_go_error("enter a valid position or range") + return + var chromosome := _current_go_chromosome() if target_seq_name.is_empty() else _chromosome_for_go_name(target_seq_name) + if chromosome.is_empty(): + _show_go_error("unknown sequence: %s" % target_seq_name) + return + var chr_len := int(chromosome.get("length", 0)) + if chr_len <= 0: + _show_go_error("sequence length unavailable") + return + var start_display := int(parsed_range.get("start", -1)) + var end_display := int(parsed_range.get("end", -1)) + if start_display > chr_len: + _show_go_error("start position beyond sequence length") + return + if end_display >= 0 and end_display > chr_len: + end_display = chr_len + var chr_id := int(chromosome.get("id", -1)) + if chr_id < 0: + _show_go_error("sequence unavailable") + return + host._go_on_browser_request(chr_id, start_display, end_display) + var chr_name := str(chromosome.get("name", "chr")) + var message := "%s:%d" % [chr_name, start_display] + if end_display >= 0: + message = "%s:%d-%d" % [chr_name, start_display, end_display] + host._set_status(message) + show_message("jumped to %s" % message) + + +func _show_go_error(message: String) -> void: + host._set_status(message, true) + show_message(message) + + +func _current_go_chromosome() -> Dictionary: + var target_id: int = int(host._go_get_browser_target_chr_id()) + for chr_any in host._chromosomes: + var chromosome: Dictionary = chr_any + if int(chromosome.get("id", -1)) == target_id: + return chromosome + if not host._chromosomes.is_empty(): + return host._chromosomes[0] + return {} + + +func _chromosome_for_go_name(seq_name: String) -> Dictionary: + var target := seq_name.strip_edges().to_lower() + if target.is_empty(): + return {} + for chr_any in host._chromosomes: + var chromosome: Dictionary = chr_any + if str(chromosome.get("name", "")).strip_edges().to_lower() == target: + return chromosome + return {} + + +func _reset_completion() -> void: + _completion_matches = PackedStringArray() + _completion_index = -1 + + +func _handle_command_completion(event: InputEvent) -> bool: + if not _command_active or command_edit == null: + return false + if not (event is InputEventKey): + return false + var key_event := event as InputEventKey + if not key_event.pressed or key_event.keycode != KEY_TAB: + return false + if key_event.alt_pressed or key_event.ctrl_pressed or key_event.meta_pressed: + return true + var context := _command_completion_context() + if context.is_empty(): + return true + var token := str(context.get("token", "")) + var matches := _completion_matches if _completion_matches.find(token) >= 0 else _completion_matches_for_context(context, token) + if matches.is_empty(): + _reset_completion() + return true + if _completion_matches != matches: + _completion_matches = matches + _completion_index = -1 + _completion_index = (_completion_index + 1) % _completion_matches.size() + var replacement := _completion_matches[_completion_index] + var head := str(context.get("head", "")) + var tail := str(context.get("tail", "")) + command_edit.text = head + replacement + tail + command_edit.caret_column = head.length() + replacement.length() + return true + + +func _command_completion_context() -> Dictionary: + if _command_prefix_text == VIM_COMMAND_PREFIX_SEARCH: + return _search_completion_context() + var command_context := _colon_command_completion_context() + if not command_context.is_empty(): + return command_context + var theme_context := _colorscheme_completion_context() + if not theme_context.is_empty(): + return theme_context + return _go_completion_context() + + +func _completion_matches_for_context(context: Dictionary, prefix: String) -> PackedStringArray: + match str(context.get("kind", "")): + "command": + return _colon_command_completion_matches(prefix) + "theme": + return _theme_completion_matches(prefix) + _: + return _contig_completion_matches(prefix) + + +func _colon_command_completion_context() -> Dictionary: + var text := command_edit.text + var caret := clampi(command_edit.caret_column, 0, text.length()) + var before := text.substr(0, caret) + var after := text.substr(caret) + var token_tail_len := 0 + while token_tail_len < after.length(): + if after.unicode_at(token_tail_len) <= 32: + break + token_tail_len += 1 + var token_tail := after.substr(0, token_tail_len) + var tail := after.substr(token_tail_len) + var token := before + token_tail + if token.find(" ") >= 0 or token.find("\t") >= 0: + return {} + return {"kind": "command", "head": "", "token": token, "tail": tail} + + +func _go_completion_context() -> Dictionary: + if host._app_mode != host.APP_MODE_BROWSER: + return {} + var text := command_edit.text + var caret := clampi(command_edit.caret_column, 0, text.length()) + var before := text.substr(0, caret) + var after := text.substr(caret) + if not before.to_lower().begins_with("go "): + return {} + var token_before := before.substr(3) + if token_before.find(" ") >= 0 or token_before.find("\t") >= 0: + return {} + var token_tail_len := 0 + while token_tail_len < after.length(): + if after.unicode_at(token_tail_len) <= 32: + break + token_tail_len += 1 + var token_tail := after.substr(0, token_tail_len) + var tail := after.substr(token_tail_len) + var token := token_before + token_tail + if not VimCommandParserScript.parse_go_range(token).is_empty(): + return {} + var range_sep := token.rfind(":") + if range_sep >= 0 and not VimCommandParserScript.parse_go_range(token.substr(range_sep + 1)).is_empty(): + return {} + return {"kind": "contig", "head": "go ", "token": token, "tail": tail} + + +func _colorscheme_completion_context() -> Dictionary: + var command := VimCommandParserScript.COMMAND_COLORSCHEME + var text := command_edit.text + var caret := clampi(command_edit.caret_column, 0, text.length()) + var before := text.substr(0, caret) + var after := text.substr(caret) + var lower_before := before.to_lower() + if lower_before == command: + return {"kind": "theme", "head": "%s " % command, "token": after, "tail": ""} + if not lower_before.begins_with("%s " % command): + return {} + var token := before.substr(command.length() + 1) + after + return {"kind": "theme", "head": before.substr(0, command.length() + 1), "token": token, "tail": ""} + + +func _search_completion_context() -> Dictionary: + if host._app_mode != host.APP_MODE_BROWSER: + return {} + var text := command_edit.text + var caret := clampi(command_edit.caret_column, 0, text.length()) + var before := text.substr(0, caret) + var after := text.substr(caret) + var mode_sep := VimCommandParserScript.first_whitespace_index(before) + if mode_sep < 0: + return {} + var target := VimCommandParserScript.search_target_from_token(before.substr(0, mode_sep)) + if target.is_empty(): + return {} + var token_before := before.substr(mode_sep + 1) + if token_before.find(" ") >= 0 or token_before.find("\t") >= 0: + return {} + var token_tail_len := 0 + while token_tail_len < after.length(): + if after.unicode_at(token_tail_len) <= 32: + break + token_tail_len += 1 + var token_tail := after.substr(0, token_tail_len) + var tail := after.substr(token_tail_len) + var token := token_before + token_tail + return {"kind": "contig", "head": before.substr(0, mode_sep + 1), "token": token, "tail": tail} + + +func _colon_command_completion_matches(prefix: String) -> PackedStringArray: + var out := PackedStringArray() + var lower_prefix := prefix.to_lower() + for command_any in VIM_COLON_COMMANDS: + var command := str(command_any) + if not lower_prefix.is_empty() and not command.begins_with(lower_prefix): + continue + if out.find(command) < 0: + out.append(command) + return out + + +func _contig_completion_matches(prefix: String) -> PackedStringArray: + var out := PackedStringArray() + var lower_prefix := prefix.to_lower() + for chr_any in host._chromosomes: + var chromosome: Dictionary = chr_any + var seq_name := str(chromosome.get("name", "")).strip_edges() + if seq_name.is_empty(): + continue + if not lower_prefix.is_empty() and not seq_name.to_lower().begins_with(lower_prefix): + continue + if out.find(seq_name) < 0: + out.append(seq_name) + return out + + +func _theme_completion_matches(prefix: String) -> PackedStringArray: + var out := PackedStringArray() + if host._themes_lib == null: + return out + var lower_prefix := prefix.to_lower() + for theme_name_any in host._themes_lib.theme_names(): + var theme_name := str(theme_name_any).strip_edges() + if theme_name.is_empty(): + continue + if not lower_prefix.is_empty() and not theme_name.to_lower().begins_with(lower_prefix): + continue + if out.find(theme_name) < 0: + out.append(theme_name) + return out + + +func _on_command_submitted(command: String) -> void: + var prefix_text := _command_prefix_text + _finish_command() + if prefix_text == VIM_COMMAND_PREFIX_SEARCH: + _execute_search(command) + else: + _execute_command(command) + + +func _on_command_edit_gui_input(event: InputEvent) -> void: + if handle_escape(event): + command_edit.accept_event() + return + if _handle_command_completion(event): + command_edit.accept_event() + + +func _on_command_focus_exited() -> void: + if _command_active: + _finish_command() + + +func _on_mode_toggled(enabled: bool) -> void: + host._play_toggle_sound(enabled) + set_enabled(enabled) + + +func _is_escape_key_event(event: InputEvent) -> bool: + if not (event is InputEventKey): + return false + var key_event := event as InputEventKey + return key_event.pressed and (key_event.keycode == KEY_ESCAPE or event.is_action_pressed("seqhiker_close_right_panel")) + + +func _begin_mark_action(action: String) -> void: + _pending_mark_action = action + + +func _handle_mark_target(key_event: InputEventKey) -> bool: + if _pending_mark_action.is_empty(): + return false + var mark_letter := _mark_letter_from_event(key_event) + if mark_letter.is_empty(): + _pending_mark_action = "" + return false + var action := _pending_mark_action + _pending_mark_action = "" + if action == VIM_MARK_ACTION_SAVE: + host._save_view_mark(mark_letter) + else: + host._load_view_mark(mark_letter) + return true + + +func _mark_letter_from_event(key_event: InputEventKey) -> String: + if key_event.keycode >= KEY_A and key_event.keycode <= KEY_Z: + return VIM_MARK_LETTERS.substr(key_event.keycode - KEY_A, 1) + var typed_code := key_event.unicode + if typed_code >= 65 and typed_code <= 90: + return VIM_MARK_LETTERS.substr(typed_code - 65, 1) + if typed_code >= 97 and typed_code <= 122: + return VIM_MARK_LETTERS.substr(typed_code - 97, 1) + return "" + + +func _is_text_entry_focused() -> bool: + if host == null: + return false + var focus_owner := host.get_viewport().gui_get_focus_owner() + return (focus_owner is LineEdit) or (focus_owner is TextEdit) diff --git a/scripts/vim_mode_controller.gd.uid b/scripts/vim_mode_controller.gd.uid new file mode 100644 index 0000000..305bec6 --- /dev/null +++ b/scripts/vim_mode_controller.gd.uid @@ -0,0 +1 @@ +uid://bq6h21dbufx0m diff --git a/tests/godot/run_tests.gd b/tests/godot/run_tests.gd index d693caa..78b4b0d 100644 --- a/tests/godot/run_tests.gd +++ b/tests/godot/run_tests.gd @@ -6,6 +6,7 @@ const TEST_SCRIPTS := [ "res://tests/godot/test_local_zem_manager.gd", "res://tests/godot/test_track_controls_controller.gd", "res://tests/godot/test_track_settings_controller.gd", + "res://tests/godot/test_vim_command.gd", "res://tests/godot/test_zem_client.gd" ] diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd new file mode 100644 index 0000000..7e7a86e --- /dev/null +++ b/tests/godot/test_vim_command.gd @@ -0,0 +1,108 @@ +extends "res://tests/godot/test_case.gd" + +const VimCommandParserScript = preload("res://scripts/vim_command_parser.gd") +const VimModeControllerScript = preload("res://scripts/vim_mode_controller.gd") + + +func test_vim_go_range_parser_accepts_points_and_ranges() -> void: + assert_eq(VimCommandParserScript.parse_go_range("123"), {"start": 123, "end": -1}) + assert_eq(VimCommandParserScript.parse_go_range("1,234-5,678"), {"start": 1234, "end": 5678}) + assert_eq(VimCommandParserScript.parse_go_range("200-100"), {"start": 100, "end": 200}) + assert_true(VimCommandParserScript.parse_go_range("0").is_empty()) + assert_true(VimCommandParserScript.parse_go_range("abc").is_empty()) + + +func test_vim_go_parser_supports_last_colon_sequence_ranges() -> void: + var split := VimCommandParserScript.split_go_colon_sequence_and_range("chr1:100-200") + assert_eq(split, {"sequence": "chr1", "range": "100-200"}) + + split = VimCommandParserScript.split_go_colon_sequence_and_range("sample:chr1:42") + assert_eq(split, {"sequence": "sample:chr1", "range": "42"}) + + assert_true(VimCommandParserScript.split_go_colon_sequence_and_range("chr1:abc").is_empty()) + + +func test_vim_go_parser_keeps_space_fallback_for_colon_names() -> void: + var split := VimCommandParserScript.split_go_sequence_and_range("sample:chr1 100-200") + assert_eq(split, {"sequence": "sample:chr1", "range": "100-200"}) + + +func test_vim_colorscheme_parser_accepts_standard_command() -> void: + var parsed := VimCommandParserScript.parse_colorscheme("colorscheme Solarized Dark") + assert_eq(parsed, { + "ok": true, + "theme": "Solarized Dark" + }) + + +func test_vim_colorscheme_parser_rejects_set_theme_alias() -> void: + var parsed := VimCommandParserScript.parse_colorscheme("set theme=Slate") + assert_false(bool(parsed.get("ok", false))) + + +func test_vim_colon_completion_includes_quit_commands() -> void: + var controller := VimModeControllerScript.new() + assert_eq(controller._colon_command_completion_matches("q"), PackedStringArray(["q", "quit"])) + + +func test_vim_search_parser_accepts_annotation_and_dna_prefixes() -> void: + var parsed := VimCommandParserScript.parse_search("a gene1") + assert_eq(parsed, { + "ok": true, + "target": "annotation", + "sequence": "", + "query": "gene1" + }) + + parsed = VimCommandParserScript.parse_search("dn ACGT") + assert_eq(parsed, { + "ok": true, + "target": "dna", + "sequence": "", + "query": "ACGT" + }) + + +func test_vim_search_parser_infers_bare_dna_query() -> void: + var parsed := VimCommandParserScript.parse_search("ACGT") + assert_eq(parsed, { + "ok": true, + "target": "dna", + "sequence": "", + "query": "ACGT" + }) + + parsed = VimCommandParserScript.parse_search("acgt") + assert_eq(parsed, { + "ok": true, + "target": "dna", + "sequence": "", + "query": "acgt" + }) + + +func test_vim_search_parser_does_not_infer_non_dna_single_token() -> void: + var parsed := VimCommandParserScript.parse_search("gene1") + assert_false(bool(parsed.get("ok", false))) + + +func test_vim_search_parser_detects_optional_sequence_name() -> void: + var sequence_names := PackedStringArray(["contig1", "contig2"]) + var parsed := VimCommandParserScript.parse_search("annot contig2 foo bar", sequence_names) + assert_eq(parsed, { + "ok": true, + "target": "annotation", + "sequence": "contig2", + "query": "foo bar" + }) + + +func test_vim_search_parser_keeps_non_sequence_prefix_in_query() -> void: + var sequence_names := PackedStringArray(["contig1"]) + var parsed := VimCommandParserScript.parse_search("an missing foo", sequence_names) + assert_eq(parsed, { + "ok": true, + "target": "annotation", + "sequence": "", + "query": "missing foo" + }) diff --git a/tests/godot/test_vim_command.gd.uid b/tests/godot/test_vim_command.gd.uid new file mode 100644 index 0000000..915e074 --- /dev/null +++ b/tests/godot/test_vim_command.gd.uid @@ -0,0 +1 @@ +uid://brje7r8g41s17 From 611270b78f6128c1b5e65050af09b67d6056669d Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Fri, 12 Jun 2026 20:19:30 +0100 Subject: [PATCH 02/10] Add Vim search result navigation --- project.godot | 20 +++++++++ scripts/main.gd | 55 ++++++++++++++++++----- scripts/search_controller.gd | 25 +++++++++++ scripts/vim_mode_controller.gd | 80 +++++++++++++++++++++++++++++++++ tests/godot/test_vim_command.gd | 51 +++++++++++++++++++++ 5 files changed, 219 insertions(+), 12 deletions(-) diff --git a/project.godot b/project.godot index b023a50..8f686a8 100644 --- a/project.godot +++ b/project.godot @@ -104,6 +104,26 @@ seqhiker_vim_search={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":47,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +seqhiker_vim_search_next={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":78,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_search_previous={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":78,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_go_prefix={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_go_end={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} seqhiker_vim_mark_save={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":77,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/scripts/main.gd b/scripts/main.gd index b29adfd..c62fe63 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -1816,7 +1816,8 @@ func _setup_track_settings_panel() -> void: "get_chromosomes": Callable(self, "_search_get_chromosomes"), "get_comparison_genomes": Callable(self, "_search_get_comparison_genomes"), "get_selected_seq_id": Callable(self, "_search_get_selected_seq_id"), - "on_hit_selected": Callable(self, "_jump_to_search_hit") + "on_hit_selected": Callable(self, "_jump_to_search_hit"), + "on_hit_position_changed": Callable(self, "_on_search_hit_position_changed") }) _go_controller = GoControllerScript.new() _go_controller.setup(feature_content, { @@ -2244,26 +2245,56 @@ func _jump_to_search_hit(hit_any: Dictionary) -> void: var chr_id := int(hit.get("chr_id", -1)) var start_bp := int(hit.get("start", 0)) var end_bp := int(hit.get("end", start_bp + 1)) - if _seq_view_mode != SEQ_VIEW_SINGLE: - _seq_view_option.select(SEQ_VIEW_SINGLE) - _on_seq_view_selected(SEQ_VIEW_SINGLE) - for i in range(_seq_option.item_count): - if int(_seq_option.get_item_id(i)) == chr_id: - _seq_option.select(i) - _on_seq_selected(i) - break + var display_range := _search_hit_display_range(chr_id, start_bp, end_bp) + if display_range.is_empty(): + _set_status("Search hit sequence unavailable.", true) + return + var display_start_bp := int(display_range.get("start", start_bp)) + var display_end_bp := int(display_range.get("end", end_bp)) var current_bp_per_px := clampf(_last_bp_per_px, genome_view.min_bp_per_px, genome_view.max_bp_per_px) _play_ui_sound(SoundControllerScript.SOUND_JUMP) - _navigate_to_centered_range(start_bp, end_bp, current_bp_per_px) + _navigate_to_centered_range(display_start_bp, display_end_bp, current_bp_per_px) if hit_kind == "dna": _pending_annotation_highlight = {} genome_view.clear_selected_feature() - genome_view.set_region_selection(start_bp, maxi(start_bp, end_bp - 1)) + genome_view.set_region_selection(display_start_bp, maxi(display_start_bp, display_end_bp - 1)) else: - _pending_annotation_highlight = hit.duplicate(true) + var display_hit := hit.duplicate(true) + display_hit["start"] = display_start_bp + display_hit["end"] = display_end_bp + _pending_annotation_highlight = display_hit genome_view.clear_region_selection() +func _search_hit_display_range(chr_id: int, start_bp: int, end_bp: int) -> Dictionary: + if chr_id < 0: + return {} + if _seq_view_mode == SEQ_VIEW_CONCAT: + for seg in _concat_segments: + if int(seg.get("id", -1)) != chr_id: + continue + var offset := int(seg.get("start", 0)) + return { + "start": offset + start_bp, + "end": offset + end_bp + } + return {} + for i in range(_seq_option.item_count): + if int(_seq_option.get_item_id(i)) == chr_id: + _seq_option.select(i) + _on_seq_selected(i) + return { + "start": start_bp, + "end": end_bp + } + return {} + + +func _on_search_hit_position_changed(index: int, count: int) -> void: + if _vim_controller != null: + _vim_controller.show_search_hit_position(index, count) + + func _on_map_jump_requested(bp_center: float) -> void: var current_bp_per_px := clampf(_last_bp_per_px, genome_view.min_bp_per_px, genome_view.max_bp_per_px) var target_start := maxi(0, int(floor(bp_center - genome_view.get_visible_span_bp() * 0.5))) diff --git a/scripts/search_controller.gd b/scripts/search_controller.gd index e24f846..4d8c37d 100644 --- a/scripts/search_controller.gd +++ b/scripts/search_controller.gd @@ -113,6 +113,28 @@ func run_browser_search(mode: int, query: String, chr_filter: PackedInt32Array, _select_browser_chr_filter(chr_filter, scope_label) _run_search(false) +func step_result(delta: int) -> Dictionary: + if _search_running: + return {"ok": false, "error": "search still running"} + if _search_hits.is_empty() or _search_results_list == null: + return {"ok": false, "error": "no search results"} + var selected := _search_results_list.get_selected_items() + var current := -1 + if not selected.is_empty(): + current = int(selected[0]) + var next_idx := current + delta + if current < 0: + next_idx = 0 if delta >= 0 else _search_hits.size() - 1 + next_idx = posmod(next_idx, _search_hits.size()) + _search_results_list.select(next_idx) + _search_results_list.ensure_current_is_visible() + _on_search_result_selected(next_idx) + return { + "ok": true, + "index": next_idx, + "count": _search_hits.size() + } + func refresh_context() -> void: _refresh_scope_options() @@ -562,6 +584,9 @@ func _on_search_result_selected(index: int) -> void: var callback: Callable = _callbacks.get("on_hit_selected", Callable()) if callback.is_valid(): callback.call(_search_hits[index]) + var position_callback: Callable = _callbacks.get("on_hit_position_changed", Callable()) + if position_callback.is_valid(): + position_callback.call(index, _search_hits.size()) func _call0(name: String): var callback: Callable = _callbacks.get(name, Callable()) diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd index c4dbd71..d4cb58b 100644 --- a/scripts/vim_mode_controller.gd +++ b/scripts/vim_mode_controller.gd @@ -21,6 +21,7 @@ var _enabled := false var _command_active := false var _command_prefix_text := VIM_COMMAND_PREFIX_COMMAND var _pending_mark_action := "" +var _pending_go_start := false var _completion_matches := PackedStringArray() var _completion_index := -1 @@ -62,6 +63,7 @@ func set_enabled(enabled: bool) -> void: _command_active = false _command_prefix_text = VIM_COMMAND_PREFIX_COMMAND _pending_mark_action = "" + _pending_go_start = false if command_edit != null: command_edit.text = "" if command_edit.has_focus(): @@ -95,6 +97,14 @@ func show_message(message: String) -> void: command_edit.text = message +func show_search_hit_position(index: int, count: int) -> void: + if count <= 0 or index < 0: + return + var message := "search hit %d/%d" % [index + 1, count] + host._set_status(message) + show_message(message) + + func apply_theme(palette: Dictionary) -> void: if command_prefix != null: command_prefix.add_theme_color_override("font_color", palette["text"]) @@ -126,11 +136,16 @@ func handle_input(event: InputEvent) -> bool: return false if key_event.alt_pressed or key_event.ctrl_pressed or key_event.meta_pressed: _pending_mark_action = "" + _pending_go_start = false return false if _command_active or _is_text_entry_focused(): _pending_mark_action = "" + _pending_go_start = false return false if _handle_mark_target(key_event): + _pending_go_start = false + return true + if _handle_pending_go_start(key_event): return true if event.is_action_pressed("seqhiker_vim_command"): _begin_command(VIM_COMMAND_PREFIX_COMMAND) @@ -144,6 +159,18 @@ func handle_input(event: InputEvent) -> bool: if event.is_action_pressed("seqhiker_vim_mark_load"): _begin_mark_action(VIM_MARK_ACTION_LOAD) return true + if event.is_action_pressed("seqhiker_vim_search_previous"): + _step_search_result(-1) + return true + if event.is_action_pressed("seqhiker_vim_search_next"): + _step_search_result(1) + return true + if event.is_action_pressed("seqhiker_vim_go_end"): + _jump_current_sequence_boundary(true) + return true + if event.is_action_pressed("seqhiker_vim_go_prefix"): + _begin_go_start_action() + return true if key_event.shift_pressed: return false if event.is_action_pressed("seqhiker_vim_scroll_left"): @@ -216,6 +243,44 @@ func _execute_quit_command() -> void: host.get_tree().quit() +func _step_search_result(delta: int) -> void: + if host._search_controller == null or not host._search_controller.has_method("step_result"): + _show_search_error("no search results") + return + var result: Dictionary = host._search_controller.step_result(delta) + if not bool(result.get("ok", false)): + _show_search_error(str(result.get("error", "no search results"))) + return + show_search_hit_position(int(result.get("index", -1)), int(result.get("count", 0))) + + +func _jump_current_sequence_boundary(at_end: bool) -> void: + if host._app_mode != host.APP_MODE_BROWSER: + _show_go_error("jump is only available in browser view") + return + if host._chromosomes.is_empty(): + _show_go_error("no sequences loaded") + return + var chromosome := _current_go_chromosome() + if chromosome.is_empty(): + _show_go_error("sequence unavailable") + return + var chr_id := int(chromosome.get("id", -1)) + var chr_len := int(chromosome.get("length", 0)) + if chr_id < 0: + _show_go_error("sequence unavailable") + return + if chr_len <= 0: + _show_go_error("sequence length unavailable") + return + var display_pos := chr_len if at_end else 1 + host._go_on_browser_request(chr_id, display_pos, -1) + var chr_name := str(chromosome.get("name", "chr")) + var message := "%s:%d" % [chr_name, display_pos] + host._set_status(message) + show_message("jumped to %s" % message) + + func _show_bar_error(message: String) -> void: host._set_status(message, true) show_message(message) @@ -618,6 +683,21 @@ func _begin_mark_action(action: String) -> void: _pending_mark_action = action +func _begin_go_start_action() -> void: + _pending_go_start = true + _pending_mark_action = "" + + +func _handle_pending_go_start(key_event: InputEventKey) -> bool: + if not _pending_go_start: + return false + _pending_go_start = false + if key_event.keycode != KEY_G or key_event.shift_pressed: + return false + _jump_current_sequence_boundary(false) + return true + + func _handle_mark_target(key_event: InputEventKey) -> bool: if _pending_mark_action.is_empty(): return false diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd index 7e7a86e..9f0ac9f 100644 --- a/tests/godot/test_vim_command.gd +++ b/tests/godot/test_vim_command.gd @@ -2,6 +2,10 @@ extends "res://tests/godot/test_case.gd" const VimCommandParserScript = preload("res://scripts/vim_command_parser.gd") const VimModeControllerScript = preload("res://scripts/vim_mode_controller.gd") +const SearchControllerScript = preload("res://scripts/search_controller.gd") + +var _selected_search_hits: Array[Dictionary] = [] +var _selected_search_positions: Array[Dictionary] = [] func test_vim_go_range_parser_accepts_points_and_ranges() -> void: @@ -45,6 +49,45 @@ func test_vim_colon_completion_includes_quit_commands() -> void: assert_eq(controller._colon_command_completion_matches("q"), PackedStringArray(["q", "quit"])) +func test_vim_search_result_step_wraps_and_selects_hits() -> void: + _selected_search_hits.clear() + _selected_search_positions.clear() + var results := ItemList.new() + results.add_item("hit 1") + results.add_item("hit 2") + var controller := SearchControllerScript.new() + controller._callbacks = { + "on_hit_selected": Callable(self, "_record_selected_search_hit"), + "on_hit_position_changed": Callable(self, "_record_selected_search_position") + } + controller._search_results_list = results + controller._search_hits = [ + {"label": "hit 1"}, + {"label": "hit 2"} + ] + + var stepped := controller.step_result(1) + assert_eq(stepped, {"ok": true, "index": 0, "count": 2}) + assert_eq(_selected_search_hits[-1], {"label": "hit 1"}) + assert_eq(_selected_search_positions[-1], {"index": 0, "count": 2}) + + stepped = controller.step_result(1) + assert_eq(stepped, {"ok": true, "index": 1, "count": 2}) + assert_eq(_selected_search_hits[-1], {"label": "hit 2"}) + assert_eq(_selected_search_positions[-1], {"index": 1, "count": 2}) + + stepped = controller.step_result(1) + assert_eq(stepped, {"ok": true, "index": 0, "count": 2}) + assert_eq(_selected_search_hits[-1], {"label": "hit 1"}) + assert_eq(_selected_search_positions[-1], {"index": 0, "count": 2}) + + stepped = controller.step_result(-1) + assert_eq(stepped, {"ok": true, "index": 1, "count": 2}) + assert_eq(_selected_search_hits[-1], {"label": "hit 2"}) + assert_eq(_selected_search_positions[-1], {"index": 1, "count": 2}) + results.free() + + func test_vim_search_parser_accepts_annotation_and_dna_prefixes() -> void: var parsed := VimCommandParserScript.parse_search("a gene1") assert_eq(parsed, { @@ -106,3 +149,11 @@ func test_vim_search_parser_keeps_non_sequence_prefix_in_query() -> void: "sequence": "", "query": "missing foo" }) + + +func _record_selected_search_hit(hit: Dictionary) -> void: + _selected_search_hits.append(hit.duplicate(true)) + + +func _record_selected_search_position(index: int, count: int) -> void: + _selected_search_positions.append({"index": index, "count": count}) From 6f79225dec3fae1ea232ce08634c99d895645e84 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Fri, 12 Jun 2026 21:17:26 +0100 Subject: [PATCH 03/10] Improve Vim mode navigation --- project.godot | 20 ++ scripts/main.gd | 21 +- scripts/vim_mode_controller.gd | 348 +++++++++++++++++++++++++++++--- tests/godot/test_vim_command.gd | 190 +++++++++++++++++ 4 files changed, 547 insertions(+), 32 deletions(-) diff --git a/project.godot b/project.godot index 8f686a8..1040ce3 100644 --- a/project.godot +++ b/project.godot @@ -124,6 +124,26 @@ seqhiker_vim_go_end={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +seqhiker_vim_contig_next={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":67,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_contig_previous={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":67,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_contig_next_prefix={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":93,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_contig_previous_prefix={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":91,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} seqhiker_vim_mark_save={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":77,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/scripts/main.gd b/scripts/main.gd index c62fe63..f3c46a5 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -787,21 +787,23 @@ func _setup_font_size_control() -> void: if not _font_size_slider.drag_ended.is_connected(_on_font_size_drag_ended): _font_size_slider.drag_ended.connect(_on_font_size_drag_ended) -func _scroll_left_by_step() -> void: +func _scroll_left_by_step(count: int = 1) -> void: _play_ui_sound(SoundControllerScript.SOUND_PAN_LEFT) + var steps := maxi(1, count) if _app_mode == APP_MODE_COMPARISON: if comparison_view != null and comparison_view.has_method("pan_all_by_fraction"): - comparison_view.pan_all_by_fraction(-_pan_step_percent / 100.0) + comparison_view.pan_all_by_fraction(-_pan_step_percent * float(steps) / 100.0) return - _pan_view_by_fraction(-_pan_step_percent / 100.0) + _pan_view_by_fraction(-_pan_step_percent * float(steps) / 100.0) -func _scroll_right_by_step() -> void: +func _scroll_right_by_step(count: int = 1) -> void: _play_ui_sound(SoundControllerScript.SOUND_PAN_RIGHT) + var steps := maxi(1, count) if _app_mode == APP_MODE_COMPARISON: if comparison_view != null and comparison_view.has_method("pan_all_by_fraction"): - comparison_view.pan_all_by_fraction(_pan_step_percent / 100.0) + comparison_view.pan_all_by_fraction(_pan_step_percent * float(steps) / 100.0) return - _pan_view_by_fraction(_pan_step_percent / 100.0) + _pan_view_by_fraction(_pan_step_percent * float(steps) / 100.0) func _zoom_in_by_step() -> void: _play_ui_sound(SoundControllerScript.SOUND_ZOOM_IN) @@ -3512,6 +3514,13 @@ func _handle_vim_input(event: InputEvent) -> bool: func _handle_vim_command_escape(event: InputEvent) -> bool: return _vim_controller != null and _vim_controller.handle_escape(event) +func _input(event: InputEvent) -> void: + if _handle_vim_command_escape(event): + get_viewport().set_input_as_handled() + return + if _handle_vim_input(event): + get_viewport().set_input_as_handled() + func _unhandled_input(event: InputEvent) -> void: if _handle_vim_command_escape(event): get_viewport().set_input_as_handled() diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd index d4cb58b..0f161f4 100644 --- a/scripts/vim_mode_controller.gd +++ b/scripts/vim_mode_controller.gd @@ -22,6 +22,11 @@ var _command_active := false var _command_prefix_text := VIM_COMMAND_PREFIX_COMMAND var _pending_mark_action := "" var _pending_go_start := false +var _pending_bracket_contig_delta := 0 +var _count_prefix := "" +var _contig_navigation_anchor_chr_id := -1 +var _contig_navigation_anchor_view_start := -1 +var _contig_navigation_anchor_view_end := -1 var _completion_matches := PackedStringArray() var _completion_index := -1 @@ -64,6 +69,8 @@ func set_enabled(enabled: bool) -> void: _command_prefix_text = VIM_COMMAND_PREFIX_COMMAND _pending_mark_action = "" _pending_go_start = false + _pending_bracket_contig_delta = 0 + _count_prefix = "" if command_edit != null: command_edit.text = "" if command_edit.has_focus(): @@ -97,6 +104,95 @@ func show_message(message: String) -> void: command_edit.text = message +func _show_pending_count() -> void: + if _count_prefix.is_empty() or command_edit == null: + return + if command_prefix != null: + command_prefix.text = "" + command_edit.text = _count_prefix + + +func _clear_pending_state(clear_count: bool = true) -> void: + _pending_mark_action = "" + _pending_go_start = false + _pending_bracket_contig_delta = 0 + if clear_count: + _clear_count_prefix() + + +func _clear_count_prefix() -> void: + var old_prefix := _count_prefix + _count_prefix = "" + if command_edit != null and not old_prefix.is_empty() and command_edit.text == old_prefix: + command_edit.text = "" + + +func _has_pending_state() -> bool: + return _pending_go_start or _pending_bracket_contig_delta != 0 or not _pending_mark_action.is_empty() or not _count_prefix.is_empty() + + +static func _is_shift_modifier_event(key_event: InputEventKey) -> bool: + return key_event.keycode == KEY_SHIFT or key_event.physical_keycode == KEY_SHIFT or key_event.key_label == KEY_SHIFT + + +func _command_count(default_value: int = 1) -> int: + if _count_prefix.is_empty(): + return default_value + return maxi(1, int(_count_prefix)) + + +func _consume_count(default_value: int = 1) -> int: + var count := _command_count(default_value) + _clear_count_prefix() + return count + + +func _handle_count_prefix(key_event: InputEventKey) -> bool: + if _pending_go_start or _pending_bracket_contig_delta != 0 or not _pending_mark_action.is_empty(): + return false + if key_event.shift_pressed: + return false + var digit := _digit_from_event(key_event) + if digit < 0: + return false + if digit == 0 and _count_prefix.is_empty(): + return false + if _count_prefix.length() >= 9: + return true + _count_prefix += str(digit) + _show_pending_count() + return true + + +func _digit_from_event(key_event: InputEventKey) -> int: + var typed_code := key_event.unicode + if typed_code >= 48 and typed_code <= 57: + return typed_code - 48 + if key_event.keycode >= KEY_0 and key_event.keycode <= KEY_9: + return key_event.keycode - KEY_0 + return -1 + + +static func _matches_key(key_event: InputEventKey, keycode: Key, shift_pressed: bool = false) -> bool: + if key_event.alt_pressed or key_event.ctrl_pressed or key_event.meta_pressed: + return false + var expected_unicode := _unicode_for_key(keycode, shift_pressed) + if expected_unicode > 0 and _is_ascii_letter_unicode(key_event.unicode): + return key_event.unicode == expected_unicode + var key_matches := key_event.keycode == keycode or key_event.physical_keycode == keycode or key_event.key_label == keycode + return key_matches and key_event.shift_pressed == shift_pressed + + +static func _is_ascii_letter_unicode(unicode_value: int) -> bool: + return (unicode_value >= 65 and unicode_value <= 90) or (unicode_value >= 97 and unicode_value <= 122) + + +static func _unicode_for_key(keycode: Key, shift_pressed: bool) -> int: + if keycode < KEY_A or keycode > KEY_Z: + return 0 + return (65 if shift_pressed else 97) + int(keycode - KEY_A) + + func show_search_hit_position(index: int, count: int) -> void: if count <= 0 or index < 0: return @@ -134,64 +230,81 @@ func handle_input(event: InputEvent) -> bool: var key_event := event as InputEventKey if not key_event.pressed: return false + if _is_shift_modifier_event(key_event): + return _has_pending_state() if key_event.alt_pressed or key_event.ctrl_pressed or key_event.meta_pressed: - _pending_mark_action = "" - _pending_go_start = false + _clear_pending_state() return false if _command_active or _is_text_entry_focused(): - _pending_mark_action = "" - _pending_go_start = false + _clear_pending_state() return false if _handle_mark_target(key_event): - _pending_go_start = false return true if _handle_pending_go_start(key_event): return true + if _handle_pending_bracket_contig(key_event): + return true + if _handle_count_prefix(key_event): + return true if event.is_action_pressed("seqhiker_vim_command"): _begin_command(VIM_COMMAND_PREFIX_COMMAND) return true if event.is_action_pressed("seqhiker_vim_search"): _begin_command(VIM_COMMAND_PREFIX_SEARCH) return true - if event.is_action_pressed("seqhiker_vim_mark_save"): + if _matches_key(key_event, KEY_M): _begin_mark_action(VIM_MARK_ACTION_SAVE) return true if event.is_action_pressed("seqhiker_vim_mark_load"): _begin_mark_action(VIM_MARK_ACTION_LOAD) return true - if event.is_action_pressed("seqhiker_vim_search_previous"): - _step_search_result(-1) + if _matches_key(key_event, KEY_N, true): + _clear_contig_navigation_anchor() + _step_search_result(-_consume_count()) return true - if event.is_action_pressed("seqhiker_vim_search_next"): - _step_search_result(1) + if _matches_key(key_event, KEY_N): + _clear_contig_navigation_anchor() + _step_search_result(_consume_count()) return true - if event.is_action_pressed("seqhiker_vim_go_end"): + if _matches_key(key_event, KEY_G, true): _jump_current_sequence_boundary(true) return true - if event.is_action_pressed("seqhiker_vim_go_prefix"): + if _matches_key(key_event, KEY_G): _begin_go_start_action() return true + if event.is_action_pressed("seqhiker_vim_contig_next_prefix"): + _begin_bracket_contig_action(1) + return true + if event.is_action_pressed("seqhiker_vim_contig_previous_prefix"): + _begin_bracket_contig_action(-1) + return true if key_event.shift_pressed: + _clear_pending_state() return false - if event.is_action_pressed("seqhiker_vim_scroll_left"): - host._scroll_left_by_step() + if _matches_key(key_event, KEY_H): + _clear_contig_navigation_anchor() + host._scroll_left_by_step(_consume_count()) return true - if event.is_action_pressed("seqhiker_vim_scroll_right"): - host._scroll_right_by_step() + if _matches_key(key_event, KEY_L): + _clear_contig_navigation_anchor() + host._scroll_right_by_step(_consume_count()) return true - if event.is_action_pressed("seqhiker_vim_zoom_out"): + if _matches_key(key_event, KEY_J): + _clear_pending_state() host._zoom_out_by_step() return true - if event.is_action_pressed("seqhiker_vim_zoom_in"): + if _matches_key(key_event, KEY_K): + _clear_pending_state() host._zoom_in_by_step() return true + _clear_pending_state() return false func _begin_command(prefix_text: String = VIM_COMMAND_PREFIX_COMMAND) -> void: if not _enabled or command_edit == null: return - _pending_mark_action = "" + _clear_pending_state() _reset_completion() _command_prefix_text = prefix_text _command_active = true @@ -254,7 +367,7 @@ func _step_search_result(delta: int) -> void: show_search_hit_position(int(result.get("index", -1)), int(result.get("count", 0))) -func _jump_current_sequence_boundary(at_end: bool) -> void: +func _jump_current_sequence_boundary(at_end: bool, position_override: int = -1) -> void: if host._app_mode != host.APP_MODE_BROWSER: _show_go_error("jump is only available in browser view") return @@ -274,14 +387,146 @@ func _jump_current_sequence_boundary(at_end: bool) -> void: _show_go_error("sequence length unavailable") return var display_pos := chr_len if at_end else 1 + if position_override > 0: + display_pos = mini(position_override, chr_len) host._go_on_browser_request(chr_id, display_pos, -1) + _set_contig_navigation_anchor(chr_id) var chr_name := str(chromosome.get("name", "chr")) var message := "%s:%d" % [chr_name, display_pos] + _clear_pending_state() host._set_status(message) show_message("jumped to %s" % message) +func _jump_relative_contig(delta: int, count: int = 1) -> void: + if host._app_mode != host.APP_MODE_BROWSER: + _show_go_error("contig navigation is only available in browser view") + return + var contigs := _navigation_contigs() + if contigs.is_empty(): + _show_go_error("no sequences loaded") + return + var current_index := _current_navigation_contig_index(contigs) + if current_index < 0: + _show_go_error("sequence unavailable") + return + var effective_delta := delta * maxi(1, count) + var requested_index := current_index + effective_delta + var target_index := clampi(requested_index, 0, contigs.size() - 1) + var hit_boundary := target_index != requested_index + var target: Dictionary = (contigs[target_index] as Dictionary).duplicate(true) + var chr_id := int(target.get("id", -1)) + if chr_id < 0: + _show_go_error("sequence unavailable") + return + host._go_on_browser_request(chr_id, 1, -1) + _set_contig_navigation_anchor(chr_id) + var chr_name := str(target.get("name", "chr")) + var message := "%s:1" % chr_name + _clear_pending_state() + if hit_boundary: + show_message("last contig" if effective_delta > 0 else "first contig") + host._set_status("Last contig." if effective_delta > 0 else "First contig.") + else: + host._set_status(message) + show_message("jumped to %s" % message) + + +func _navigation_contigs() -> Array[Dictionary]: + var out: Array[Dictionary] = [] + if host._seq_view_mode == host.SEQ_VIEW_CONCAT and not host._concat_segments.is_empty(): + for seg_any in host._concat_segments: + var seg: Dictionary = seg_any + out.append({ + "id": int(seg.get("id", -1)), + "name": str(seg.get("name", "chr")), + "start": int(seg.get("start", 0)), + "end": int(seg.get("end", 0)) + }) + return out + for chr_any in host._chromosomes: + var chromosome: Dictionary = chr_any + out.append({ + "id": int(chromosome.get("id", -1)), + "name": str(chromosome.get("name", "chr")), + "start": 0, + "end": int(chromosome.get("length", 0)) + }) + return out + + +func _current_navigation_contig_index(contigs: Array[Dictionary]) -> int: + var anchor_index := _anchored_navigation_contig_index(contigs) + if anchor_index >= 0: + return anchor_index + if host._seq_view_mode == host.SEQ_VIEW_CONCAT and not host._concat_segments.is_empty(): + var center_bp := int(floor(0.5 * float(host._last_start + host._last_end))) + return _contig_index_for_display_position(contigs, center_bp) + return _contig_index_for_chr_id(contigs, int(host._go_get_browser_target_chr_id())) + + +func _set_contig_navigation_anchor(chr_id: int) -> void: + _contig_navigation_anchor_chr_id = chr_id + _contig_navigation_anchor_view_start = int(host._last_start) + _contig_navigation_anchor_view_end = int(host._last_end) + + +func _clear_contig_navigation_anchor() -> void: + _contig_navigation_anchor_chr_id = -1 + _contig_navigation_anchor_view_start = -1 + _contig_navigation_anchor_view_end = -1 + + +func _anchored_navigation_contig_index(contigs: Array[Dictionary]) -> int: + if _contig_navigation_anchor_chr_id < 0: + return -1 + var anchor_index := _contig_index_for_chr_id(contigs, _contig_navigation_anchor_chr_id) + if anchor_index < 0: + _clear_contig_navigation_anchor() + return -1 + if int(host._last_start) == _contig_navigation_anchor_view_start and int(host._last_end) == _contig_navigation_anchor_view_end: + return anchor_index + if host._seq_view_mode == host.SEQ_VIEW_CONCAT and not host._concat_segments.is_empty(): + var center_bp := int(floor(0.5 * float(host._last_start + host._last_end))) + var center_index := _contig_index_for_display_position(contigs, center_bp) + if center_index == anchor_index: + return anchor_index + _clear_contig_navigation_anchor() + return center_index + return anchor_index + + +static func _contig_at_relative_index(contigs: Array[Dictionary], current_index: int, delta: int) -> Dictionary: + var target_index := current_index + delta + if target_index < 0 or target_index >= contigs.size(): + return {} + return (contigs[target_index] as Dictionary).duplicate(true) + + +static func _contig_index_for_chr_id(contigs: Array[Dictionary], chr_id: int) -> int: + for i in range(contigs.size()): + var contig: Dictionary = contigs[i] + if int(contig.get("id", -1)) == chr_id: + return i + return -1 + + +static func _contig_index_for_display_position(contigs: Array[Dictionary], display_bp: int) -> int: + if contigs.is_empty(): + return -1 + for i in range(contigs.size()): + var contig: Dictionary = contigs[i] + var start_bp := int(contig.get("start", 0)) + var end_bp := int(contig.get("end", start_bp)) + if display_bp >= start_bp and display_bp < end_bp: + return i + if display_bp < start_bp: + return maxi(0, i - 1) + return contigs.size() - 1 + + func _show_bar_error(message: String) -> void: + _clear_pending_state() host._set_status(message, true) show_message(message) @@ -349,6 +594,7 @@ func _execute_search(command: String) -> void: func _show_search_error(message: String) -> void: + _clear_pending_state() host._set_status(message, true) show_message(message) @@ -431,18 +677,34 @@ func _execute_go_command(command: String) -> void: func _show_go_error(message: String) -> void: + _clear_pending_state() host._set_status(message, true) show_message(message) func _current_go_chromosome() -> Dictionary: + if host._seq_view_mode == host.SEQ_VIEW_CONCAT and not host._concat_segments.is_empty(): + var contigs := _navigation_contigs() + var current_index := _current_navigation_contig_index(contigs) + if current_index >= 0 and current_index < contigs.size(): + var contig: Dictionary = contigs[current_index] + var anchored_chromosome := _chromosome_for_id(int(contig.get("id", -1))) + if not anchored_chromosome.is_empty(): + return anchored_chromosome var target_id: int = int(host._go_get_browser_target_chr_id()) + var target_chromosome := _chromosome_for_id(target_id) + if not target_chromosome.is_empty(): + return target_chromosome + if not host._chromosomes.is_empty(): + return host._chromosomes[0] + return {} + + +func _chromosome_for_id(chr_id: int) -> Dictionary: for chr_any in host._chromosomes: var chromosome: Dictionary = chr_any - if int(chromosome.get("id", -1)) == target_id: + if int(chromosome.get("id", -1)) == chr_id: return chromosome - if not host._chromosomes.is_empty(): - return host._chromosomes[0] return {} @@ -681,20 +943,51 @@ func _is_escape_key_event(event: InputEvent) -> bool: func _begin_mark_action(action: String) -> void: _pending_mark_action = action + _pending_go_start = false + _pending_bracket_contig_delta = 0 + _count_prefix = "" func _begin_go_start_action() -> void: _pending_go_start = true _pending_mark_action = "" + _pending_bracket_contig_delta = 0 + + +func _begin_bracket_contig_action(delta: int) -> void: + _pending_bracket_contig_delta = delta + _pending_go_start = false + _pending_mark_action = "" func _handle_pending_go_start(key_event: InputEventKey) -> bool: if not _pending_go_start: return false _pending_go_start = false - if key_event.keycode != KEY_G or key_event.shift_pressed: + if _matches_key(key_event, KEY_G): + var has_count := not _count_prefix.is_empty() + var count := _consume_count() + _jump_current_sequence_boundary(false, count if has_count else -1) + return true + if key_event.is_action_pressed("seqhiker_vim_contig_previous") or _matches_key(key_event, KEY_C, true): + _jump_relative_contig(-1, _consume_count()) + return true + if key_event.is_action_pressed("seqhiker_vim_contig_next") or _matches_key(key_event, KEY_C): + _jump_relative_contig(1, _consume_count()) + return true + _clear_count_prefix() + return false + + +func _handle_pending_bracket_contig(key_event: InputEventKey) -> bool: + if _pending_bracket_contig_delta == 0: return false - _jump_current_sequence_boundary(false) + var delta := _pending_bracket_contig_delta + _pending_bracket_contig_delta = 0 + if not _matches_key(key_event, KEY_C): + _clear_count_prefix() + return false + _jump_relative_contig(delta, _consume_count()) return true @@ -728,5 +1021,8 @@ func _mark_letter_from_event(key_event: InputEventKey) -> String: func _is_text_entry_focused() -> bool: if host == null: return false - var focus_owner := host.get_viewport().gui_get_focus_owner() + var viewport := host.get_viewport() + if viewport == null: + return false + var focus_owner := viewport.gui_get_focus_owner() return (focus_owner is LineEdit) or (focus_owner is TextEdit) diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd index 9f0ac9f..f21153b 100644 --- a/tests/godot/test_vim_command.gd +++ b/tests/godot/test_vim_command.gd @@ -8,6 +8,43 @@ var _selected_search_hits: Array[Dictionary] = [] var _selected_search_positions: Array[Dictionary] = [] +class FakeVimHost: + extends Node + + const APP_MODE_BROWSER := 0 + const SEQ_VIEW_CONCAT := 0 + + var _app_mode := APP_MODE_BROWSER + var _seq_view_mode := SEQ_VIEW_CONCAT + var _last_start := 0 + var _last_end := 50 + var _chromosomes: Array[Dictionary] = [ + {"id": 1, "name": "ctg1", "length": 1000}, + {"id": 2, "name": "ctg2", "length": 1000}, + {"id": 3, "name": "ctg3", "length": 1000} + ] + var _concat_segments: Array[Dictionary] = [ + {"id": 1, "name": "ctg1", "start": 0, "end": 1000}, + {"id": 2, "name": "ctg2", "start": 1050, "end": 2050}, + {"id": 3, "name": "ctg3", "start": 2100, "end": 3100} + ] + var go_requests: Array[Dictionary] = [] + var status_messages: Array[Dictionary] = [] + + func _go_get_browser_target_chr_id() -> int: + return 1 + + func _go_on_browser_request(chr_id: int, start_display: int, end_display: int) -> void: + go_requests.append({ + "chr_id": chr_id, + "start": start_display, + "end": end_display + }) + + func _set_status(message: String, is_error: bool = false) -> void: + status_messages.append({"message": message, "is_error": is_error}) + + func test_vim_go_range_parser_accepts_points_and_ranges() -> void: assert_eq(VimCommandParserScript.parse_go_range("123"), {"start": 123, "end": -1}) assert_eq(VimCommandParserScript.parse_go_range("1,234-5,678"), {"start": 1234, "end": 5678}) @@ -49,6 +86,150 @@ func test_vim_colon_completion_includes_quit_commands() -> void: assert_eq(controller._colon_command_completion_matches("q"), PackedStringArray(["q", "quit"])) +func test_vim_count_helpers_consume_and_clear_feedback() -> void: + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.command_edit = edit + controller._count_prefix = "12" + edit.text = "12" + + assert_eq(controller._command_count(), 12) + assert_eq(controller._consume_count(), 12) + assert_eq(controller._count_prefix, "") + assert_eq(edit.text, "") + assert_eq(controller._consume_count(), 1) + edit.free() + + +func test_vim_key_matching_distinguishes_c_and_shift_c() -> void: + var key_event := InputEventKey.new() + key_event.pressed = true + key_event.keycode = KEY_C + key_event.shift_pressed = false + assert_true(VimModeControllerScript._matches_key(key_event, KEY_C)) + assert_false(VimModeControllerScript._matches_key(key_event, KEY_C, true)) + + key_event.shift_pressed = true + assert_false(VimModeControllerScript._matches_key(key_event, KEY_C)) + assert_true(VimModeControllerScript._matches_key(key_event, KEY_C, true)) + + var unicode_upper_event := InputEventKey.new() + unicode_upper_event.pressed = true + unicode_upper_event.keycode = KEY_NONE + unicode_upper_event.shift_pressed = false + unicode_upper_event.unicode = 67 + assert_false(VimModeControllerScript._matches_key(unicode_upper_event, KEY_C)) + assert_true(VimModeControllerScript._matches_key(unicode_upper_event, KEY_C, true)) + + var keycode_upper_event := InputEventKey.new() + keycode_upper_event.pressed = true + keycode_upper_event.keycode = KEY_C + keycode_upper_event.shift_pressed = false + keycode_upper_event.unicode = 67 + assert_false(VimModeControllerScript._matches_key(keycode_upper_event, KEY_C)) + assert_true(VimModeControllerScript._matches_key(keycode_upper_event, KEY_C, true)) + + +func test_vim_counted_gg_jumps_to_counted_position() -> void: + var host := FakeVimHost.new() + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller.command_edit = edit + controller._enabled = true + + assert_true(controller.handle_input(_vim_key(KEY_5, false, 53))) + assert_true(controller.handle_input(_vim_key(KEY_0, false, 48))) + assert_true(controller.handle_input(_vim_key(KEY_0, false, 48))) + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_eq(host.go_requests[-1], {"chr_id": 1, "start": 500, "end": -1}) + assert_eq(edit.text, "jumped to ctg1:500") + edit.free() + host.free() + + +func test_vim_contig_navigation_repeats_without_viewport_update_and_supports_shift_c() -> void: + var host := FakeVimHost.new() + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller.command_edit = edit + controller._enabled = true + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_C, false, 99))) + assert_eq(host.go_requests[-1], {"chr_id": 2, "start": 1, "end": -1}) + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_C, false, 99))) + assert_eq(host.go_requests[-1], {"chr_id": 3, "start": 1, "end": -1}) + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_C, true, 67))) + assert_eq(host.go_requests[-1], {"chr_id": 2, "start": 1, "end": -1}) + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_C, false, 67))) + assert_eq(host.go_requests[-1], {"chr_id": 1, "start": 1, "end": -1}) + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_C, false, 99))) + assert_eq(host.go_requests[-1], {"chr_id": 2, "start": 1, "end": -1}) + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_SHIFT, true, 0))) + assert_true(controller.handle_input(_vim_key(KEY_C, true, 67))) + assert_eq(host.go_requests[-1], {"chr_id": 1, "start": 1, "end": -1}) + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_C, false, 99))) + assert_eq(host.go_requests[-1], {"chr_id": 2, "start": 1, "end": -1}) + + assert_true(controller.handle_input(_vim_key(KEY_5, false, 53))) + assert_true(controller.handle_input(_vim_key(KEY_0, false, 48))) + assert_true(controller.handle_input(_vim_key(KEY_0, false, 48))) + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_eq(host.go_requests[-1], {"chr_id": 2, "start": 500, "end": -1}) + assert_eq(edit.text, "jumped to ctg2:500") + + assert_true(controller.handle_input(_vim_key(KEY_9, false, 57))) + assert_true(controller.handle_input(_vim_key(KEY_9, false, 57))) + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_C, false, 99))) + assert_eq(host.go_requests[-1], {"chr_id": 3, "start": 1, "end": -1}) + assert_eq(edit.text, "last contig") + assert_eq(host.status_messages[-1], {"message": "Last contig.", "is_error": false}) + + assert_true(controller.handle_input(_vim_key(KEY_9, false, 57))) + assert_true(controller.handle_input(_vim_key(KEY_9, false, 57))) + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_C, true, 67))) + assert_eq(host.go_requests[-1], {"chr_id": 1, "start": 1, "end": -1}) + assert_eq(edit.text, "first contig") + assert_eq(host.status_messages[-1], {"message": "First contig.", "is_error": false}) + edit.free() + host.free() + + +func test_vim_contig_navigation_helpers_respect_order_and_boundaries() -> void: + var contigs: Array[Dictionary] = [ + {"id": 10, "name": "a", "start": 0, "end": 100}, + {"id": 20, "name": "b", "start": 150, "end": 250}, + {"id": 30, "name": "c", "start": 300, "end": 400} + ] + assert_eq(VimModeControllerScript._contig_index_for_chr_id(contigs, 20), 1) + assert_eq(VimModeControllerScript._contig_index_for_chr_id(contigs, 99), -1) + assert_eq(VimModeControllerScript._contig_index_for_display_position(contigs, 175), 1) + assert_eq(VimModeControllerScript._contig_index_for_display_position(contigs, 275), 1) + assert_eq(VimModeControllerScript._contig_at_relative_index(contigs, 0, 2), {"id": 30, "name": "c", "start": 300, "end": 400}) + assert_eq(VimModeControllerScript._contig_at_relative_index(contigs, 1, 1), {"id": 30, "name": "c", "start": 300, "end": 400}) + assert_eq(VimModeControllerScript._contig_at_relative_index(contigs, 1, -1), {"id": 10, "name": "a", "start": 0, "end": 100}) + assert_true(VimModeControllerScript._contig_at_relative_index(contigs, 0, -1).is_empty()) + assert_true(VimModeControllerScript._contig_at_relative_index(contigs, 2, 1).is_empty()) + + func test_vim_search_result_step_wraps_and_selects_hits() -> void: _selected_search_hits.clear() _selected_search_positions.clear() @@ -157,3 +338,12 @@ func _record_selected_search_hit(hit: Dictionary) -> void: func _record_selected_search_position(index: int, count: int) -> void: _selected_search_positions.append({"index": index, "count": count}) + + +func _vim_key(keycode: Key, shift_pressed: bool = false, unicode: int = 0) -> InputEventKey: + var event := InputEventKey.new() + event.pressed = true + event.keycode = keycode + event.shift_pressed = shift_pressed + event.unicode = unicode + return event From 27a4e3de207811b5bd512853927f7dd2a2d2fdf6 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Fri, 12 Jun 2026 21:23:51 +0100 Subject: [PATCH 04/10] Add Vim command history --- scripts/vim_mode_controller.gd | 101 ++++++++++++++++++++++++++++++++ tests/godot/test_vim_command.gd | 44 ++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd index 0f161f4..608c14c 100644 --- a/scripts/vim_mode_controller.gd +++ b/scripts/vim_mode_controller.gd @@ -10,6 +10,7 @@ const VIM_MARK_ACTION_LOAD := "load" const VIM_COMMAND_PREFIX_COMMAND := ":" const VIM_COMMAND_PREFIX_SEARCH := "/" const VIM_COLON_COMMANDS := ["go", "colorscheme", "q", "quit"] +const VIM_COMMAND_HISTORY_LIMIT := 100 var host: Node = null var mode_cb: CheckButton = null @@ -29,6 +30,11 @@ var _contig_navigation_anchor_view_start := -1 var _contig_navigation_anchor_view_end := -1 var _completion_matches := PackedStringArray() var _completion_index := -1 +var _colon_history: Array[String] = [] +var _search_history: Array[String] = [] +var _history_index := -1 +var _history_draft := "" +var _applying_history := false func setup(next_host: Node, next_mode_cb: CheckButton, next_command_bar: PanelContainer, next_command_prefix: Label, next_command_edit: LineEdit) -> void: @@ -51,6 +57,8 @@ func setup(next_host: Node, next_mode_cb: CheckButton, next_command_bar: PanelCo command_edit.focus_exited.connect(_on_command_focus_exited) if not command_edit.gui_input.is_connected(_on_command_edit_gui_input): command_edit.gui_input.connect(_on_command_edit_gui_input) + if not command_edit.text_changed.is_connected(_on_command_text_changed): + command_edit.text_changed.connect(_on_command_text_changed) if mode_cb != null and not mode_cb.toggled.is_connected(_on_mode_toggled): mode_cb.toggled.connect(_on_mode_toggled) sync_command_bar() @@ -306,6 +314,7 @@ func _begin_command(prefix_text: String = VIM_COMMAND_PREFIX_COMMAND) -> void: return _clear_pending_state() _reset_completion() + _reset_history_navigation() _command_prefix_text = prefix_text _command_active = true command_edit.text = "" @@ -315,6 +324,7 @@ func _begin_command(prefix_text: String = VIM_COMMAND_PREFIX_COMMAND) -> void: func _finish_command() -> void: _command_active = false _reset_completion() + _reset_history_navigation() if command_edit != null: command_edit.text = "" sync_command_bar() @@ -724,6 +734,86 @@ func _reset_completion() -> void: _completion_index = -1 +func _reset_history_navigation() -> void: + _history_index = -1 + _history_draft = "" + + +func _record_command_history(prefix_text: String, command: String) -> void: + var clean := command.strip_edges() + if clean.is_empty(): + return + var history := _history_for_prefix(prefix_text) + if not history.is_empty() and history[-1] == clean: + _store_history_for_prefix(prefix_text, history) + return + history.append(clean) + while history.size() > VIM_COMMAND_HISTORY_LIMIT: + history.remove_at(0) + _store_history_for_prefix(prefix_text, history) + + +func _history_for_prefix(prefix_text: String) -> Array[String]: + if prefix_text == VIM_COMMAND_PREFIX_SEARCH: + return _search_history.duplicate() + return _colon_history.duplicate() + + +func _store_history_for_prefix(prefix_text: String, history: Array[String]) -> void: + if prefix_text == VIM_COMMAND_PREFIX_SEARCH: + _search_history = history + else: + _colon_history = history + + +func _handle_command_history(event: InputEvent) -> bool: + if not _command_active or command_edit == null: + return false + if not (event is InputEventKey): + return false + var key_event := event as InputEventKey + if not key_event.pressed: + return false + if key_event.alt_pressed or key_event.ctrl_pressed or key_event.meta_pressed: + return false + if key_event.keycode == KEY_UP or key_event.physical_keycode == KEY_UP or key_event.key_label == KEY_UP: + _step_command_history(-1) + return true + if key_event.keycode == KEY_DOWN or key_event.physical_keycode == KEY_DOWN or key_event.key_label == KEY_DOWN: + _step_command_history(1) + return true + return false + + +func _step_command_history(direction: int) -> void: + var history := _history_for_prefix(_command_prefix_text) + if history.is_empty(): + return + if direction < 0: + if _history_index < 0: + _history_draft = command_edit.text + _history_index = history.size() - 1 + else: + _history_index = maxi(0, _history_index - 1) + else: + if _history_index < 0: + return + if _history_index >= history.size() - 1: + _history_index = -1 + _apply_history_text(_history_draft) + return + _history_index += 1 + _apply_history_text(history[_history_index]) + + +func _apply_history_text(text: String) -> void: + _applying_history = true + command_edit.text = text + command_edit.caret_column = text.length() + _applying_history = false + _reset_completion() + + func _handle_command_completion(event: InputEvent) -> bool: if not _command_active or command_edit == null: return false @@ -909,6 +999,7 @@ func _theme_completion_matches(prefix: String) -> PackedStringArray: func _on_command_submitted(command: String) -> void: var prefix_text := _command_prefix_text + _record_command_history(prefix_text, command) _finish_command() if prefix_text == VIM_COMMAND_PREFIX_SEARCH: _execute_search(command) @@ -920,10 +1011,20 @@ func _on_command_edit_gui_input(event: InputEvent) -> void: if handle_escape(event): command_edit.accept_event() return + if _handle_command_history(event): + command_edit.accept_event() + return if _handle_command_completion(event): command_edit.accept_event() +func _on_command_text_changed(_new_text: String) -> void: + if _applying_history: + return + _reset_history_navigation() + _reset_completion() + + func _on_command_focus_exited() -> void: if _command_active: _finish_command() diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd index f21153b..41e4c36 100644 --- a/tests/godot/test_vim_command.gd +++ b/tests/godot/test_vim_command.gd @@ -101,6 +101,50 @@ func test_vim_count_helpers_consume_and_clear_feedback() -> void: edit.free() +func test_vim_command_history_cycles_per_prompt_prefix() -> void: + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.command_edit = edit + controller._record_command_history(":", "go ctg1:100") + controller._record_command_history(":", "colorscheme Classic") + controller._record_command_history("/", "dna ACGT") + + controller._command_active = true + controller._command_prefix_text = ":" + edit.text = "draft" + assert_true(controller._handle_command_history(_vim_key(KEY_UP))) + assert_eq(edit.text, "colorscheme Classic") + assert_true(controller._handle_command_history(_vim_key(KEY_UP))) + assert_eq(edit.text, "go ctg1:100") + assert_true(controller._handle_command_history(_vim_key(KEY_UP))) + assert_eq(edit.text, "go ctg1:100") + assert_true(controller._handle_command_history(_vim_key(KEY_DOWN))) + assert_eq(edit.text, "colorscheme Classic") + assert_true(controller._handle_command_history(_vim_key(KEY_DOWN))) + assert_eq(edit.text, "draft") + + controller._command_prefix_text = "/" + edit.text = "" + assert_true(controller._handle_command_history(_vim_key(KEY_UP))) + assert_eq(edit.text, "dna ACGT") + edit.free() + + +func test_vim_command_history_ignores_empty_dedupes_and_caps() -> void: + var controller := VimModeControllerScript.new() + controller._record_command_history(":", "") + assert_eq(controller._colon_history.size(), 0) + controller._record_command_history(":", "go 1") + controller._record_command_history(":", "go 1") + assert_eq(controller._colon_history, ["go 1"]) + + for i in range(105): + controller._record_command_history(":", "go %d" % i) + assert_eq(controller._colon_history.size(), 100) + assert_eq(controller._colon_history[0], "go 5") + assert_eq(controller._colon_history[-1], "go 104") + + func test_vim_key_matching_distinguishes_c_and_shift_c() -> void: var key_event := InputEventKey.new() key_event.pressed = true From 1013286a7446874c18be5043c0eca64a51ac6b11 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Fri, 12 Jun 2026 21:35:27 +0100 Subject: [PATCH 05/10] Add view mode shortcuts --- project.godot | 5 +++ scripts/main.gd | 39 ++++++++++++++-- scripts/vim_mode_controller.gd | 79 ++++++++++++++++++++++++++++++++- tests/godot/test_vim_command.gd | 67 ++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 4 deletions(-) diff --git a/project.godot b/project.godot index 1040ce3..5190531 100644 --- a/project.godot +++ b/project.godot @@ -166,6 +166,11 @@ seqhiker_go={ , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":true,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +seqhiker_toggle_view_mode={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} seqhiker_view_slot_load_1={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":49,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/scripts/main.gd b/scripts/main.gd index f3c46a5..c9613ec 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -575,11 +575,28 @@ func _refresh_settings_sections() -> void: _comparison_controller.refresh_settings(_app_mode) func _toggle_comparison_mode() -> void: + _toggle_view_mode() + +func _toggle_view_mode() -> bool: if _theme_editor_controller != null and _theme_editor_controller.is_open(): - return + return false + var next_mode := APP_MODE_BROWSER if _app_mode == APP_MODE_COMPARISON else APP_MODE_COMPARISON + return _set_view_mode(next_mode) + +func _set_view_mode(next_mode: int) -> bool: + if _theme_editor_controller != null and _theme_editor_controller.is_open(): + return false + if next_mode != APP_MODE_BROWSER and next_mode != APP_MODE_COMPARISON: + return false + if _app_mode == next_mode: + return true _play_ui_sound(SoundControllerScript.SOUND_CHANGE_VIEW) - if _top_bar_controller != null: - _top_bar_controller.toggle_comparison_mode() + _set_app_mode(next_mode) + if next_mode == APP_MODE_COMPARISON and _comparison_controller != null: + _comparison_controller.ensure_seed_genome_loaded(_loaded_file_paths) + _comparison_controller.refresh_view(theme_option.get_item_text(theme_option.selected)) + _refresh_comparison_topbar_state() + return true func _set_app_mode(next_mode: int) -> void: if _top_bar_controller != null: @@ -3514,10 +3531,23 @@ func _handle_vim_input(event: InputEvent) -> bool: func _handle_vim_command_escape(event: InputEvent) -> bool: return _vim_controller != null and _vim_controller.handle_escape(event) +func _is_vim_command_active() -> bool: + return _vim_controller != null and _vim_controller.has_method("is_command_active") and _vim_controller.is_command_active() + +func _handle_view_mode_shortcut(event: InputEvent) -> bool: + if _is_vim_command_active(): + return false + if event.is_action_pressed("seqhiker_toggle_view_mode"): + return _toggle_view_mode() + return false + func _input(event: InputEvent) -> void: if _handle_vim_command_escape(event): get_viewport().set_input_as_handled() return + if _handle_view_mode_shortcut(event): + get_viewport().set_input_as_handled() + return if _handle_vim_input(event): get_viewport().set_input_as_handled() @@ -3525,6 +3555,9 @@ func _unhandled_input(event: InputEvent) -> void: if _handle_vim_command_escape(event): get_viewport().set_input_as_handled() return + if _handle_view_mode_shortcut(event): + get_viewport().set_input_as_handled() + return if _handle_vim_input(event): get_viewport().set_input_as_handled() return diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd index 608c14c..522749f 100644 --- a/scripts/vim_mode_controller.gd +++ b/scripts/vim_mode_controller.gd @@ -9,7 +9,8 @@ const VIM_MARK_ACTION_SAVE := "save" const VIM_MARK_ACTION_LOAD := "load" const VIM_COMMAND_PREFIX_COMMAND := ":" const VIM_COMMAND_PREFIX_SEARCH := "/" -const VIM_COLON_COMMANDS := ["go", "colorscheme", "q", "quit"] +const VIM_COLON_COMMANDS := ["go", "colorscheme", "q", "quit", "view"] +const VIM_VIEW_TARGETS := ["comparison", "single"] const VIM_COMMAND_HISTORY_LIMIT := 100 var host: Node = null @@ -68,6 +69,10 @@ func is_enabled() -> bool: return _enabled +func is_command_active() -> bool: + return _command_active + + func set_enabled(enabled: bool) -> void: _enabled = enabled if mode_cb != null and mode_cb.button_pressed != enabled: @@ -339,6 +344,8 @@ func _execute_command(command: String) -> void: _execute_go_command(clean) elif lower == VimCommandParserScript.COMMAND_COLORSCHEME or lower.begins_with("%s " % VimCommandParserScript.COMMAND_COLORSCHEME): _execute_colorscheme_command(clean) + elif lower == "view" or lower.begins_with("view "): + _execute_view_command(clean) elif lower == "q" or lower == "quit": _execute_quit_command() @@ -366,6 +373,39 @@ func _execute_quit_command() -> void: host.get_tree().quit() +func _execute_view_command(command: String) -> void: + var args := command.substr(4).strip_edges().to_lower() + if args.is_empty(): + _show_bar_error("usage: view single|comparison") + return + var next_mode := -1 + var status_message := "" + if args == "comparison": + next_mode = host.APP_MODE_COMPARISON + status_message = "Comparison view" + elif args == "single": + next_mode = host.APP_MODE_BROWSER + status_message = "Single genome view" + else: + _show_bar_error("usage: view single|comparison") + return + if not host._set_view_mode(next_mode): + _show_bar_error("view switch unavailable") + return + host._set_status(status_message) + show_message(status_message.to_lower()) + + +func _toggle_view_mode() -> void: + if not host._toggle_view_mode(): + _show_bar_error("view switch unavailable") + return + var message := "comparison view" if host._app_mode == host.APP_MODE_COMPARISON else "single genome view" + var status_message := "Comparison view" if host._app_mode == host.APP_MODE_COMPARISON else "Single genome view" + host._set_status(status_message) + show_message(message) + + func _step_search_result(delta: int) -> void: if host._search_controller == null or not host._search_controller.has_method("step_result"): _show_search_error("no search results") @@ -853,6 +893,9 @@ func _command_completion_context() -> Dictionary: var theme_context := _colorscheme_completion_context() if not theme_context.is_empty(): return theme_context + var view_context := _view_completion_context() + if not view_context.is_empty(): + return view_context return _go_completion_context() @@ -862,6 +905,8 @@ func _completion_matches_for_context(context: Dictionary, prefix: String) -> Pac return _colon_command_completion_matches(prefix) "theme": return _theme_completion_matches(prefix) + "view": + return _view_completion_matches(prefix) _: return _contig_completion_matches(prefix) @@ -927,6 +972,23 @@ func _colorscheme_completion_context() -> Dictionary: return {"kind": "theme", "head": before.substr(0, command.length() + 1), "token": token, "tail": ""} +func _view_completion_context() -> Dictionary: + var command := "view" + var text := command_edit.text + var caret := clampi(command_edit.caret_column, 0, text.length()) + var before := text.substr(0, caret) + var after := text.substr(caret) + var lower_before := before.to_lower() + if lower_before == command: + return {"kind": "view", "head": "%s " % command, "token": after, "tail": ""} + if not lower_before.begins_with("%s " % command): + return {} + var token := before.substr(command.length() + 1) + after + if token.find(" ") >= 0 or token.find("\t") >= 0: + return {} + return {"kind": "view", "head": before.substr(0, command.length() + 1), "token": token, "tail": ""} + + func _search_completion_context() -> Dictionary: if host._app_mode != host.APP_MODE_BROWSER: return {} @@ -997,6 +1059,17 @@ func _theme_completion_matches(prefix: String) -> PackedStringArray: return out +func _view_completion_matches(prefix: String) -> PackedStringArray: + var out := PackedStringArray() + var lower_prefix := prefix.to_lower() + for target_any in VIM_VIEW_TARGETS: + var target := str(target_any) + if not lower_prefix.is_empty() and not target.begins_with(lower_prefix): + continue + out.append(target) + return out + + func _on_command_submitted(command: String) -> void: var prefix_text := _command_prefix_text _record_command_history(prefix_text, command) @@ -1070,6 +1143,10 @@ func _handle_pending_go_start(key_event: InputEventKey) -> bool: var count := _consume_count() _jump_current_sequence_boundary(false, count if has_count else -1) return true + if _matches_key(key_event, KEY_V): + _clear_count_prefix() + _toggle_view_mode() + return true if key_event.is_action_pressed("seqhiker_vim_contig_previous") or _matches_key(key_event, KEY_C, true): _jump_relative_contig(-1, _consume_count()) return true diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd index 41e4c36..7f87517 100644 --- a/tests/godot/test_vim_command.gd +++ b/tests/godot/test_vim_command.gd @@ -12,6 +12,7 @@ class FakeVimHost: extends Node const APP_MODE_BROWSER := 0 + const APP_MODE_COMPARISON := 1 const SEQ_VIEW_CONCAT := 0 var _app_mode := APP_MODE_BROWSER @@ -44,6 +45,16 @@ class FakeVimHost: func _set_status(message: String, is_error: bool = false) -> void: status_messages.append({"message": message, "is_error": is_error}) + func _toggle_view_mode() -> bool: + _app_mode = APP_MODE_BROWSER if _app_mode == APP_MODE_COMPARISON else APP_MODE_COMPARISON + return true + + func _set_view_mode(next_mode: int) -> bool: + if next_mode != APP_MODE_BROWSER and next_mode != APP_MODE_COMPARISON: + return false + _app_mode = next_mode + return true + func test_vim_go_range_parser_accepts_points_and_ranges() -> void: assert_eq(VimCommandParserScript.parse_go_range("123"), {"start": 123, "end": -1}) @@ -86,6 +97,62 @@ func test_vim_colon_completion_includes_quit_commands() -> void: assert_eq(controller._colon_command_completion_matches("q"), PackedStringArray(["q", "quit"])) +func test_vim_view_completion_includes_targets() -> void: + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.command_edit = edit + controller._command_prefix_text = ":" + edit.text = "view c" + edit.caret_column = edit.text.length() + + var context := controller._command_completion_context() + assert_eq(context.get("kind", ""), "view") + assert_eq(controller._completion_matches_for_context(context, str(context.get("token", ""))), PackedStringArray(["comparison"])) + edit.free() + + +func test_vim_view_command_switches_modes() -> void: + var host := FakeVimHost.new() + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller.command_edit = edit + controller._enabled = true + + controller._execute_command("view comparison") + assert_eq(host._app_mode, host.APP_MODE_COMPARISON) + assert_eq(edit.text, "comparison view") + assert_eq(host.status_messages[-1], {"message": "Comparison view", "is_error": false}) + + controller._execute_command("view single") + assert_eq(host._app_mode, host.APP_MODE_BROWSER) + assert_eq(edit.text, "single genome view") + assert_eq(host.status_messages[-1], {"message": "Single genome view", "is_error": false}) + edit.free() + host.free() + + +func test_vim_gv_toggles_view_mode() -> void: + var host := FakeVimHost.new() + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller.command_edit = edit + controller._enabled = true + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_V, false, 118))) + assert_eq(host._app_mode, host.APP_MODE_COMPARISON) + assert_eq(edit.text, "comparison view") + + assert_true(controller.handle_input(_vim_key(KEY_G, false, 103))) + assert_true(controller.handle_input(_vim_key(KEY_V, false, 118))) + assert_eq(host._app_mode, host.APP_MODE_BROWSER) + assert_eq(edit.text, "single genome view") + edit.free() + host.free() + + func test_vim_count_helpers_consume_and_clear_feedback() -> void: var edit := LineEdit.new() var controller := VimModeControllerScript.new() From b6d75698de5ef9087ce00f42e56ef6a4eacef245 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Fri, 12 Jun 2026 21:39:18 +0100 Subject: [PATCH 06/10] Add Vim ZZ quit command --- scripts/main.gd | 3 +++ scripts/vim_mode_controller.gd | 40 +++++++++++++++++++++++++++++---- tests/godot/test_vim_command.gd | 33 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/scripts/main.gd b/scripts/main.gd index c9613ec..27bd5fe 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -598,6 +598,9 @@ func _set_view_mode(next_mode: int) -> bool: _refresh_comparison_topbar_state() return true +func _quit_app() -> void: + get_tree().quit() + func _set_app_mode(next_mode: int) -> void: if _top_bar_controller != null: _top_bar_controller.set_app_mode(next_mode) diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd index 522749f..77b15eb 100644 --- a/scripts/vim_mode_controller.gd +++ b/scripts/vim_mode_controller.gd @@ -25,6 +25,7 @@ var _command_prefix_text := VIM_COMMAND_PREFIX_COMMAND var _pending_mark_action := "" var _pending_go_start := false var _pending_bracket_contig_delta := 0 +var _pending_quit := false var _count_prefix := "" var _contig_navigation_anchor_chr_id := -1 var _contig_navigation_anchor_view_start := -1 @@ -83,6 +84,7 @@ func set_enabled(enabled: bool) -> void: _pending_mark_action = "" _pending_go_start = false _pending_bracket_contig_delta = 0 + _pending_quit = false _count_prefix = "" if command_edit != null: command_edit.text = "" @@ -129,6 +131,7 @@ func _clear_pending_state(clear_count: bool = true) -> void: _pending_mark_action = "" _pending_go_start = false _pending_bracket_contig_delta = 0 + _pending_quit = false if clear_count: _clear_count_prefix() @@ -141,7 +144,7 @@ func _clear_count_prefix() -> void: func _has_pending_state() -> bool: - return _pending_go_start or _pending_bracket_contig_delta != 0 or not _pending_mark_action.is_empty() or not _count_prefix.is_empty() + return _pending_go_start or _pending_bracket_contig_delta != 0 or _pending_quit or not _pending_mark_action.is_empty() or not _count_prefix.is_empty() static func _is_shift_modifier_event(key_event: InputEventKey) -> bool: @@ -161,7 +164,7 @@ func _consume_count(default_value: int = 1) -> int: func _handle_count_prefix(key_event: InputEventKey) -> bool: - if _pending_go_start or _pending_bracket_contig_delta != 0 or not _pending_mark_action.is_empty(): + if _pending_go_start or _pending_bracket_contig_delta != 0 or _pending_quit or not _pending_mark_action.is_empty(): return false if key_event.shift_pressed: return false @@ -257,6 +260,8 @@ func handle_input(event: InputEvent) -> bool: return true if _handle_pending_bracket_contig(key_event): return true + if _handle_pending_quit(key_event): + return true if _handle_count_prefix(key_event): return true if event.is_action_pressed("seqhiker_vim_command"): @@ -285,6 +290,9 @@ func handle_input(event: InputEvent) -> bool: if _matches_key(key_event, KEY_G): _begin_go_start_action() return true + if _matches_key(key_event, KEY_Z, true): + _begin_quit_action() + return true if event.is_action_pressed("seqhiker_vim_contig_next_prefix"): _begin_bracket_contig_action(1) return true @@ -368,9 +376,12 @@ func _execute_colorscheme_command(command: String) -> void: func _execute_quit_command() -> void: - if host == null or host.get_tree() == null: + if host == null: return - host.get_tree().quit() + if host.has_method("_quit_app"): + host._quit_app() + elif host.get_tree() != null: + host.get_tree().quit() func _execute_view_command(command: String) -> void: @@ -1119,6 +1130,7 @@ func _begin_mark_action(action: String) -> void: _pending_mark_action = action _pending_go_start = false _pending_bracket_contig_delta = 0 + _pending_quit = false _count_prefix = "" @@ -1126,12 +1138,22 @@ func _begin_go_start_action() -> void: _pending_go_start = true _pending_mark_action = "" _pending_bracket_contig_delta = 0 + _pending_quit = false func _begin_bracket_contig_action(delta: int) -> void: _pending_bracket_contig_delta = delta _pending_go_start = false _pending_mark_action = "" + _pending_quit = false + + +func _begin_quit_action() -> void: + _pending_quit = true + _pending_go_start = false + _pending_bracket_contig_delta = 0 + _pending_mark_action = "" + _clear_count_prefix() func _handle_pending_go_start(key_event: InputEventKey) -> bool: @@ -1169,6 +1191,16 @@ func _handle_pending_bracket_contig(key_event: InputEventKey) -> bool: return true +func _handle_pending_quit(key_event: InputEventKey) -> bool: + if not _pending_quit: + return false + _pending_quit = false + if not _matches_key(key_event, KEY_Z, true): + return false + _execute_quit_command() + return true + + func _handle_mark_target(key_event: InputEventKey) -> bool: if _pending_mark_action.is_empty(): return false diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd index 7f87517..ec31dff 100644 --- a/tests/godot/test_vim_command.gd +++ b/tests/godot/test_vim_command.gd @@ -31,6 +31,7 @@ class FakeVimHost: ] var go_requests: Array[Dictionary] = [] var status_messages: Array[Dictionary] = [] + var quit_requests := 0 func _go_get_browser_target_chr_id() -> int: return 1 @@ -55,6 +56,9 @@ class FakeVimHost: _app_mode = next_mode return true + func _quit_app() -> void: + quit_requests += 1 + func test_vim_go_range_parser_accepts_points_and_ranges() -> void: assert_eq(VimCommandParserScript.parse_go_range("123"), {"start": 123, "end": -1}) @@ -153,6 +157,35 @@ func test_vim_gv_toggles_view_mode() -> void: host.free() +func test_vim_zz_quits() -> void: + var host := FakeVimHost.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller._enabled = true + + assert_true(controller.handle_input(_vim_key(KEY_Z, true, 90))) + assert_true(controller.handle_input(_vim_key(KEY_SHIFT, true, 0))) + assert_true(controller.handle_input(_vim_key(KEY_Z, true, 90))) + assert_eq(host.quit_requests, 1) + host.free() + + +func test_vim_zz_requires_uppercase_sequence() -> void: + var host := FakeVimHost.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller._enabled = true + + assert_false(controller.handle_input(_vim_key(KEY_Z, false, 122))) + assert_false(controller.handle_input(_vim_key(KEY_Z, false, 122))) + assert_eq(host.quit_requests, 0) + + assert_true(controller.handle_input(_vim_key(KEY_Z, true, 90))) + assert_false(controller.handle_input(_vim_key(KEY_Z, false, 122))) + assert_eq(host.quit_requests, 0) + host.free() + + func test_vim_count_helpers_consume_and_clear_feedback() -> void: var edit := LineEdit.new() var controller := VimModeControllerScript.new() From 226f03b8f6f7581a6760f289eae1f006694a83de Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Fri, 12 Jun 2026 22:07:14 +0100 Subject: [PATCH 07/10] Add Vim annotation feature navigation --- project.godot | 10 + ...nnotation_feature_navigation_controller.gd | 290 ++++++++++++++++++ ...ation_feature_navigation_controller.gd.uid | 1 + scripts/genome_view.gd | 3 + scripts/main.gd | 49 +++ scripts/vim_mode_controller.gd | 31 ++ tests/godot/run_tests.gd | 1 + ...nnotation_feature_navigation_controller.gd | 127 ++++++++ ...ation_feature_navigation_controller.gd.uid | 1 + tests/godot/test_vim_command.gd | 37 +++ 10 files changed, 550 insertions(+) create mode 100644 scripts/annotation_feature_navigation_controller.gd create mode 100644 scripts/annotation_feature_navigation_controller.gd.uid create mode 100644 tests/godot/test_annotation_feature_navigation_controller.gd create mode 100644 tests/godot/test_annotation_feature_navigation_controller.gd.uid diff --git a/project.godot b/project.godot index 5190531..a2cbbf7 100644 --- a/project.godot +++ b/project.godot @@ -114,6 +114,16 @@ seqhiker_vim_search_previous={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":78,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +seqhiker_vim_feature_next={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":87,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +seqhiker_vim_feature_previous={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":66,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} seqhiker_vim_go_prefix={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/scripts/annotation_feature_navigation_controller.gd b/scripts/annotation_feature_navigation_controller.gd new file mode 100644 index 0000000..c2be1b5 --- /dev/null +++ b/scripts/annotation_feature_navigation_controller.gd @@ -0,0 +1,290 @@ +extends RefCounted +class_name AnnotationFeatureNavigationController + +const FeatureAnnotationUtilsScript = preload("res://scripts/feature_annotation_utils.gd") + +var host: Node = null +var _selected_annotation_feature: Dictionary = {} + + +func configure(next_host: Node) -> void: + host = next_host + + +func set_selected_feature(feature: Dictionary) -> void: + if feature.is_empty(): + clear_selected_feature() + return + _selected_annotation_feature = feature.duplicate(true) + + +func clear_selected_feature() -> void: + _selected_annotation_feature = {} + + +func step_feature(delta: int) -> Dictionary: + if host == null: + return {"ok": false, "error": "feature navigation unavailable"} + if host._app_mode != host.APP_MODE_BROWSER: + return {"ok": false, "error": "feature navigation is only available in browser view"} + if delta == 0: + return {"ok": false, "error": "no feature movement"} + if host._zem == null: + return {"ok": false, "error": "annotation navigation unavailable"} + if not _has_active_selected_feature(): + clear_selected_feature() + return {"ok": false, "error": "no annotation feature selected"} + var context := _feature_context(_selected_annotation_feature) + if context.is_empty(): + return {"ok": false, "error": "selected annotation feature unavailable"} + var feature_result := _features_for_context(context) + if not bool(feature_result.get("ok", false)): + return feature_result + var display_features: Array[Dictionary] = feature_result.get("features", []) + if display_features.is_empty(): + return {"ok": false, "error": "no annotation features"} + var current_index := _current_feature_index(display_features, _selected_annotation_feature, delta) + var requested_index := current_index + delta + if requested_index >= 0 and requested_index < display_features.size(): + var target: Dictionary = display_features[requested_index].duplicate(true) + return _jump_result(target, requested_index, display_features.size(), "") + if host._seq_view_mode == host.SEQ_VIEW_CONCAT and int(context.get("segment_index", -1)) >= 0: + var cross_result := _concat_boundary_result(context, display_features, requested_index, delta) + if not cross_result.is_empty(): + return cross_result + var target_index := clampi(requested_index, 0, display_features.size() - 1) + var clamped_target: Dictionary = display_features[target_index].duplicate(true) + return _jump_result(clamped_target, target_index, display_features.size(), "last" if delta > 0 else "first") + + +func _has_active_selected_feature() -> bool: + if _selected_annotation_feature.is_empty(): + return false + if host == null or host.genome_view == null: + return true + if host.genome_view.has_method("get_selected_feature_key"): + var selected_key := str(host.genome_view.get_selected_feature_key()) + if selected_key.is_empty(): + return false + return selected_key == FeatureAnnotationUtilsScript.feature_key(_selected_annotation_feature) + return true + + +func _feature_context(feature: Dictionary) -> Dictionary: + if feature.is_empty(): + return {} + if host._seq_view_mode == host.SEQ_VIEW_CONCAT: + var seg := _concat_segment_for_feature(feature) + if seg.is_empty(): + return {} + var seg_chr_id := int(seg.get("id", -1)) + var seg_chromosome := _chromosome_for_id(seg_chr_id) + return { + "chr_id": seg_chr_id, + "chr_len": int(seg.get("length", seg_chromosome.get("length", 0))), + "name": str(seg.get("raw_name", seg.get("name", "chr"))), + "offset": int(seg.get("start", 0)), + "segment_index": int(seg.get("_index", -1)) + } + var chr_id: int = int(host._current_chr_id) if int(host._current_chr_id) >= 0 else int(host._selected_seq_id) + var chromosome := _chromosome_for_id(chr_id) + return { + "chr_id": chr_id, + "chr_len": int(chromosome.get("length", host._current_chr_len)), + "name": str(chromosome.get("name", host._current_chr_name)), + "offset": 0, + "segment_index": -1 + } + + +func _features_for_context(context: Dictionary) -> Dictionary: + var chr_id := int(context.get("chr_id", -1)) + var chr_len := int(context.get("chr_len", 0)) + if chr_id < 0 or chr_len <= 0: + return {"ok": false, "error": "selected annotation sequence unavailable"} + var resp: Dictionary = host._zem.get_annotations(chr_id, 0, chr_len, 65535, 1) + if not bool(resp.get("ok", false)): + return {"ok": false, "error": "annotation query failed: %s" % str(resp.get("error", "error"))} + var raw_features: Array[Dictionary] = [] + for feat_any in resp.get("features", []): + if typeof(feat_any) != TYPE_DICTIONARY: + continue + var feat: Dictionary = feat_any + raw_features.append(feat.duplicate(true)) + var display_features := _display_features_for_context(raw_features, context) + display_features = host._collapse_gene_cds_features(display_features) + _sort_features(display_features) + return {"ok": true, "features": display_features} + + +func _features_for_concat_segment(segment_index: int) -> Dictionary: + if segment_index < 0 or segment_index >= host._concat_segments.size(): + return {"ok": false, "error": "selected annotation sequence unavailable"} + var seg: Dictionary = (host._concat_segments[segment_index] as Dictionary).duplicate(true) + seg["_index"] = segment_index + var chr_id := int(seg.get("id", -1)) + var chromosome := _chromosome_for_id(chr_id) + var context := { + "chr_id": chr_id, + "chr_len": int(seg.get("length", chromosome.get("length", 0))), + "name": str(seg.get("raw_name", seg.get("name", "chr"))), + "offset": int(seg.get("start", 0)), + "segment_index": segment_index + } + return _features_for_context(context) + + +func _concat_boundary_result(context: Dictionary, display_features: Array[Dictionary], requested_index: int, delta: int) -> Dictionary: + var segment_index := int(context.get("segment_index", -1)) + if delta > 0 and requested_index >= display_features.size(): + var forward_skip := requested_index - display_features.size() + return _concat_forward_result(segment_index + 1, forward_skip, display_features) + if delta < 0 and requested_index < 0: + var backward_skip := -requested_index - 1 + return _concat_backward_result(segment_index - 1, backward_skip, display_features) + return {} + + +func _concat_forward_result(start_segment_index: int, skip_count: int, fallback_features: Array[Dictionary]) -> Dictionary: + var last_non_empty_features: Array[Dictionary] = [] + for segment_index in range(start_segment_index, host._concat_segments.size()): + var feature_result := _features_for_concat_segment(segment_index) + if not bool(feature_result.get("ok", false)): + return feature_result + var features: Array[Dictionary] = feature_result.get("features", []) + if features.is_empty(): + continue + last_non_empty_features = features + if skip_count < features.size(): + var target: Dictionary = features[skip_count].duplicate(true) + return _jump_result(target, skip_count, features.size(), "") + skip_count -= features.size() + if not last_non_empty_features.is_empty(): + var last_index := last_non_empty_features.size() - 1 + var last_target: Dictionary = last_non_empty_features[last_index].duplicate(true) + return _jump_result(last_target, last_index, last_non_empty_features.size(), "last") + if fallback_features.is_empty(): + return {} + var fallback_index := fallback_features.size() - 1 + var fallback_target: Dictionary = fallback_features[fallback_index].duplicate(true) + return _jump_result(fallback_target, fallback_index, fallback_features.size(), "last") + + +func _concat_backward_result(start_segment_index: int, skip_count: int, fallback_features: Array[Dictionary]) -> Dictionary: + var first_non_empty_features: Array[Dictionary] = [] + for segment_index in range(start_segment_index, -1, -1): + var feature_result := _features_for_concat_segment(segment_index) + if not bool(feature_result.get("ok", false)): + return feature_result + var features: Array[Dictionary] = feature_result.get("features", []) + if features.is_empty(): + continue + first_non_empty_features = features + if skip_count < features.size(): + var target_index := features.size() - 1 - skip_count + var target: Dictionary = features[target_index].duplicate(true) + return _jump_result(target, target_index, features.size(), "") + skip_count -= features.size() + if not first_non_empty_features.is_empty(): + var first_target: Dictionary = first_non_empty_features[0].duplicate(true) + return _jump_result(first_target, 0, first_non_empty_features.size(), "first") + if fallback_features.is_empty(): + return {} + var fallback_target: Dictionary = fallback_features[0].duplicate(true) + return _jump_result(fallback_target, 0, fallback_features.size(), "first") + + +func _jump_result(target: Dictionary, index: int, count: int, boundary: String) -> Dictionary: + host._jump_to_annotation_feature(target) + return { + "ok": true, + "index": index, + "count": count, + "label": _feature_label(target), + "boundary": boundary + } + + +func _concat_segment_for_feature(feature: Dictionary) -> Dictionary: + var seq_name := str(feature.get("seq_name", "")).strip_edges() + if not seq_name.is_empty(): + for i in range(host._concat_segments.size()): + var seg_by_name: Dictionary = (host._concat_segments[i] as Dictionary).duplicate(true) + if seq_name == str(seg_by_name.get("raw_name", "")) or seq_name == str(seg_by_name.get("name", "")): + seg_by_name["_index"] = i + return seg_by_name + var start_bp := int(feature.get("start", 0)) + var end_bp := int(feature.get("end", start_bp + 1)) + for i in range(host._concat_segments.size()): + var seg: Dictionary = (host._concat_segments[i] as Dictionary).duplicate(true) + var seg_start := int(seg.get("start", 0)) + var seg_end := int(seg.get("end", seg_start)) + if start_bp >= seg_start and start_bp < seg_end: + seg["_index"] = i + return seg + if end_bp > seg_start and end_bp <= seg_end: + seg["_index"] = i + return seg + return {} + + +func _chromosome_for_id(chr_id: int) -> Dictionary: + for c_any in host._chromosomes: + var chromosome: Dictionary = c_any + if int(chromosome.get("id", -1)) == chr_id: + return chromosome + return {} + + +func _display_features_for_context(raw_features: Array[Dictionary], context: Dictionary) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + var offset := int(context.get("offset", 0)) + for raw_feature in raw_features: + if host._seq_view_mode == host.SEQ_VIEW_CONCAT: + out.append(host._shift_feature_coords(raw_feature, offset)) + else: + out.append(raw_feature.duplicate(true)) + return out + + +func _sort_features(features: Array[Dictionary]) -> void: + features.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_start := int(a.get("start", 0)) + var b_start := int(b.get("start", 0)) + if a_start == b_start: + return int(a.get("end", a_start)) < int(b.get("end", b_start)) + return a_start < b_start + ) + + +func _current_feature_index(features: Array[Dictionary], selected_feature: Dictionary, delta: int) -> int: + var selected_key := FeatureAnnotationUtilsScript.feature_key(selected_feature) + for i in range(features.size()): + if FeatureAnnotationUtilsScript.feature_key(features[i]) == selected_key: + return i + var selected_start := int(selected_feature.get("start", 0)) + if delta > 0: + var before_or_at := -1 + for i in range(features.size()): + if int(features[i].get("start", 0)) <= selected_start: + before_or_at = i + else: + break + return before_or_at + var after_or_at := features.size() + for i in range(features.size()): + if int(features[i].get("start", 0)) >= selected_start: + after_or_at = i + break + return after_or_at + + +func _feature_label(feature: Dictionary) -> String: + var label := str(feature.get("name", "")).strip_edges() + if label.is_empty(): + label = str(feature.get("id", "")).strip_edges() + if label.is_empty(): + label = str(feature.get("type", "")).strip_edges() + if label.is_empty(): + label = "feature" + return label diff --git a/scripts/annotation_feature_navigation_controller.gd.uid b/scripts/annotation_feature_navigation_controller.gd.uid new file mode 100644 index 0000000..69091e6 --- /dev/null +++ b/scripts/annotation_feature_navigation_controller.gd.uid @@ -0,0 +1 @@ +uid://c717lb1lf02be diff --git a/scripts/genome_view.gd b/scripts/genome_view.gd index 4180386..b36a99a 100644 --- a/scripts/genome_view.gd +++ b/scripts/genome_view.gd @@ -1923,6 +1923,9 @@ func set_selected_feature_key(key: String) -> void: _selected_feature_key = key queue_redraw() +func get_selected_feature_key() -> String: + return _selected_feature_key + func clear_selected_feature() -> void: if _selected_feature_key.is_empty(): return diff --git a/scripts/main.gd b/scripts/main.gd index 27bd5fe..7903a60 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -14,6 +14,7 @@ const TrackControlsControllerScript = preload("res://scripts/track_controls_cont const SoundControllerScript = preload("res://scripts/sound_controller.gd") const AnnotationCacheControllerScript = preload("res://scripts/annotation_cache_controller.gd") const FeaturePanelControllerScript = preload("res://scripts/feature_panel_controller.gd") +const AnnotationFeatureNavigationControllerScript = preload("res://scripts/annotation_feature_navigation_controller.gd") const VariantControllerScript = preload("res://scripts/variant_controller.gd") const SessionLoaderScript = preload("res://scripts/session_loader.gd") const ComparisonControllerScript = preload("res://scripts/comparison_controller.gd") @@ -214,6 +215,7 @@ var _top_bar_controller: RefCounted var _context_panel_controller: RefCounted var _track_settings_controller: RefCounted var _annotation_cache_controller: RefCounted +var _annotation_feature_navigation_controller: RefCounted var _feature_panel_controller: RefCounted var _variant_controller: RefCounted var _session_loader: RefCounted @@ -363,6 +365,8 @@ func _ready() -> void: _search_controller = SearchControllerScript.new() _annotation_cache_controller = AnnotationCacheControllerScript.new() _annotation_cache_controller.configure(self) + _annotation_feature_navigation_controller = AnnotationFeatureNavigationControllerScript.new() + _annotation_feature_navigation_controller.configure(self) _feature_panel_controller = FeaturePanelControllerScript.new() _feature_panel_controller.configure(self) _variant_controller = VariantControllerScript.new() @@ -2278,6 +2282,7 @@ func _jump_to_search_hit(hit_any: Dictionary) -> void: _navigate_to_centered_range(display_start_bp, display_end_bp, current_bp_per_px) if hit_kind == "dna": _pending_annotation_highlight = {} + _clear_selected_annotation_feature() genome_view.clear_selected_feature() genome_view.set_region_selection(display_start_bp, maxi(display_start_bp, display_end_bp - 1)) else: @@ -2288,6 +2293,38 @@ func _jump_to_search_hit(hit_any: Dictionary) -> void: genome_view.clear_region_selection() +func _set_selected_annotation_feature(feature: Dictionary) -> void: + if _annotation_feature_navigation_controller != null: + _annotation_feature_navigation_controller.set_selected_feature(feature) + + +func _clear_selected_annotation_feature() -> void: + if _annotation_feature_navigation_controller != null: + _annotation_feature_navigation_controller.clear_selected_feature() + + +func _step_annotation_feature(delta: int) -> Dictionary: + if _annotation_feature_navigation_controller == null: + return {"ok": false, "error": "feature navigation unavailable"} + return _annotation_feature_navigation_controller.step_feature(delta) + + +func _jump_to_annotation_feature(feature: Dictionary) -> void: + var start_bp := int(feature.get("start", 0)) + var end_bp := maxi(start_bp + 1, int(feature.get("end", start_bp + 1))) + var current_bp_per_px := clampf(_last_bp_per_px, genome_view.min_bp_per_px, genome_view.max_bp_per_px) + _play_ui_sound(SoundControllerScript.SOUND_JUMP) + _navigate_to_centered_range(start_bp, end_bp, current_bp_per_px) + _pending_annotation_highlight = {} + genome_view.clear_region_selection() + genome_view.clear_selected_read() + genome_view.clear_selected_variant() + genome_view.set_selected_feature(feature, false) + _set_selected_annotation_feature(feature) + if _feature_panel_controller != null: + _feature_panel_controller.on_feature_selected(feature) + + func _search_hit_display_range(chr_id: int, start_bp: int, end_bp: int) -> Dictionary: if chr_id < 0: return {} @@ -2741,6 +2778,9 @@ func _refresh_sequence_options() -> void: _session_loader.refresh_sequence_options() func _apply_sequence_view(reset_viewport: bool) -> void: + _clear_selected_annotation_feature() + if genome_view != null: + genome_view.clear_selected_feature() _session_loader.apply_sequence_view(reset_viewport) func _ensure_browser_contig_context_menu() -> void: @@ -2828,6 +2868,7 @@ func _refresh_browser_after_contig_layout_change() -> void: if _annotation_cache_controller != null: _annotation_cache_controller.cancel_all_requests() genome_view.clear_selected_variant() + _clear_selected_annotation_feature() genome_view.clear_selected_feature() genome_view.set_reference_slice(_last_start, "") genome_view.set_features(empty_dicts) @@ -2881,6 +2922,7 @@ func _apply_pending_annotation_highlight(features: Array[Dictionary]) -> void: if hit_label != fname and hit_label != ftype: continue genome_view.set_selected_feature(f, false) + _set_selected_annotation_feature(f) return func _collapse_gene_cds_features(features_in: Array[Dictionary]) -> Array[Dictionary]: @@ -3086,6 +3128,7 @@ func _reset_loaded_state() -> void: _auto_play_enabled = false _feature_panel_open = false _slide_feature_panel(false, false) + _clear_selected_annotation_feature() genome_view.clear_all_data() _sync_bam_read_tracks() _sync_variant_track() @@ -3394,24 +3437,30 @@ func _save_config() -> void: cfg.save(CONFIG_PATH) func _on_feature_clicked(feature: Dictionary) -> void: + _set_selected_annotation_feature(feature) _feature_panel_controller.on_feature_clicked(feature) func _on_feature_selected(feature: Dictionary) -> void: + _set_selected_annotation_feature(feature) _play_ui_sound(SoundControllerScript.SOUND_BLIP) _feature_panel_controller.on_feature_selected(feature) func _on_read_clicked(read: Dictionary) -> void: + _clear_selected_annotation_feature() _feature_panel_controller.on_read_clicked(read) func _on_read_selected(read: Dictionary) -> void: + _clear_selected_annotation_feature() _play_ui_sound(SoundControllerScript.SOUND_BLIP) _feature_panel_controller.on_read_selected(read) func _on_variant_clicked(variant: Dictionary) -> void: + _clear_selected_annotation_feature() if _variant_controller != null: _variant_controller.on_variant_clicked(variant) func _on_variant_selected(variant: Dictionary) -> void: + _clear_selected_annotation_feature() if _variant_controller != null: _variant_controller.on_variant_selected(variant) diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd index 77b15eb..c48b22e 100644 --- a/scripts/vim_mode_controller.gd +++ b/scripts/vim_mode_controller.gd @@ -284,6 +284,14 @@ func handle_input(event: InputEvent) -> bool: _clear_contig_navigation_anchor() _step_search_result(_consume_count()) return true + if event.is_action_pressed("seqhiker_vim_feature_previous") or _matches_key(key_event, KEY_B): + _clear_contig_navigation_anchor() + _step_annotation_feature(-_consume_count()) + return true + if event.is_action_pressed("seqhiker_vim_feature_next") or _matches_key(key_event, KEY_W): + _clear_contig_navigation_anchor() + _step_annotation_feature(_consume_count()) + return true if _matches_key(key_event, KEY_G, true): _jump_current_sequence_boundary(true) return true @@ -428,6 +436,29 @@ func _step_search_result(delta: int) -> void: show_search_hit_position(int(result.get("index", -1)), int(result.get("count", 0))) +func _step_annotation_feature(delta: int) -> void: + if host == null or not host.has_method("_step_annotation_feature"): + _show_bar_error("feature navigation unavailable") + return + var result: Dictionary = host._step_annotation_feature(delta) + if not bool(result.get("ok", false)): + _show_bar_error(str(result.get("error", "no annotation feature selected"))) + return + var index := int(result.get("index", -1)) + var count := int(result.get("count", 0)) + var label := str(result.get("label", "")).strip_edges() + var boundary := str(result.get("boundary", "")).strip_edges() + var message := "" + if not boundary.is_empty(): + message = "%s feature" % boundary + elif count > 0 and index >= 0: + message = "%s %d/%d" % [label if not label.is_empty() else "feature", index + 1, count] + else: + message = label if not label.is_empty() else "feature" + host._set_status(message) + show_message(message) + + func _jump_current_sequence_boundary(at_end: bool, position_override: int = -1) -> void: if host._app_mode != host.APP_MODE_BROWSER: _show_go_error("jump is only available in browser view") diff --git a/tests/godot/run_tests.gd b/tests/godot/run_tests.gd index 78b4b0d..0cb430d 100644 --- a/tests/godot/run_tests.gd +++ b/tests/godot/run_tests.gd @@ -2,6 +2,7 @@ extends SceneTree const TEST_SCRIPTS := [ "res://tests/godot/test_annotation_cache_controller.gd", + "res://tests/godot/test_annotation_feature_navigation_controller.gd", "res://tests/godot/test_genome_display_mapper.gd", "res://tests/godot/test_local_zem_manager.gd", "res://tests/godot/test_track_controls_controller.gd", diff --git a/tests/godot/test_annotation_feature_navigation_controller.gd b/tests/godot/test_annotation_feature_navigation_controller.gd new file mode 100644 index 0000000..8a02afa --- /dev/null +++ b/tests/godot/test_annotation_feature_navigation_controller.gd @@ -0,0 +1,127 @@ +extends "res://tests/godot/test_case.gd" + +const AnnotationFeatureNavigationControllerScript = preload("res://scripts/annotation_feature_navigation_controller.gd") +const FeatureAnnotationUtilsScript = preload("res://scripts/feature_annotation_utils.gd") + + +class FakeGenomeView: + extends Node + + var selected_key := "" + + func get_selected_feature_key() -> String: + return selected_key + + +class FakeZem: + extends RefCounted + + var features_by_chr := {} + var requests: Array[Dictionary] = [] + + func get_annotations(chr_id: int, start_bp: int, end_bp: int, max_records: int = 2000, min_feature_len_bp: int = 1) -> Dictionary: + requests.append({ + "chr_id": chr_id, + "start": start_bp, + "end": end_bp, + "max_records": max_records, + "min_feature_len_bp": min_feature_len_bp + }) + return {"ok": true, "features": features_by_chr.get(chr_id, [])} + + +class FakeHost: + extends Node + + const APP_MODE_BROWSER := 0 + const APP_MODE_COMPARISON := 1 + const SEQ_VIEW_CONCAT := 0 + const SEQ_VIEW_SINGLE := 1 + + var _app_mode := APP_MODE_BROWSER + var _seq_view_mode := SEQ_VIEW_CONCAT + var _zem := FakeZem.new() + var _current_chr_id := -2 + var _current_chr_name := "concat" + var _current_chr_len := 2200 + var _selected_seq_id := 1 + var _chromosomes: Array[Dictionary] = [ + {"id": 1, "name": "ctg1", "length": 100}, + {"id": 2, "name": "ctg2", "length": 100}, + {"id": 3, "name": "ctg3", "length": 100}, + {"id": 4, "name": "ctg4", "length": 100} + ] + var _concat_segments: Array[Dictionary] = [ + {"id": 1, "name": "ctg1", "raw_name": "ctg1", "length": 100, "start": 0, "end": 100}, + {"id": 2, "name": "ctg2", "raw_name": "ctg2", "length": 100, "start": 1000, "end": 1100}, + {"id": 3, "name": "ctg3", "raw_name": "ctg3", "length": 100, "start": 2000, "end": 2100}, + {"id": 4, "name": "ctg4", "raw_name": "ctg4", "length": 100, "start": 3000, "end": 3100} + ] + var genome_view := FakeGenomeView.new() + var controller: RefCounted = null + var jumped_features: Array[Dictionary] = [] + + func _collapse_gene_cds_features(features_in: Array[Dictionary]) -> Array[Dictionary]: + return features_in + + func _shift_feature_coords(feature: Dictionary, offset: int) -> Dictionary: + var shifted := feature.duplicate(true) + shifted["start"] = int(shifted.get("start", 0)) + offset + shifted["end"] = int(shifted.get("end", 0)) + offset + return shifted + + func _jump_to_annotation_feature(feature: Dictionary) -> void: + jumped_features.append(feature.duplicate(true)) + genome_view.selected_key = FeatureAnnotationUtilsScript.feature_key(feature) + if controller != null: + controller.set_selected_feature(feature) + + +func test_annotation_feature_navigation_steps_counts_and_clamps_in_concat() -> void: + var host := FakeHost.new() + var controller := AnnotationFeatureNavigationControllerScript.new() + controller.configure(host) + host.controller = controller + host._zem.features_by_chr[2] = [ + {"seq_name": "ctg2", "start": 10, "end": 20, "name": "geneA", "type": "gene"}, + {"seq_name": "ctg2", "start": 30, "end": 40, "name": "geneB", "type": "gene"}, + {"seq_name": "ctg2", "start": 50, "end": 60, "name": "geneC", "type": "gene"} + ] + host._zem.features_by_chr[3] = [ + {"seq_name": "ctg3", "start": 5, "end": 15, "name": "geneD", "type": "gene"}, + {"seq_name": "ctg3", "start": 25, "end": 35, "name": "geneE", "type": "gene"} + ] + var selected_feature := { + "seq_name": "ctg2", + "start": 1010, + "end": 1020, + "name": "geneA", + "type": "gene" + } + host.genome_view.selected_key = FeatureAnnotationUtilsScript.feature_key(selected_feature) + controller.set_selected_feature(selected_feature) + + var result := controller.step_feature(2) + assert_eq(result, {"ok": true, "index": 2, "count": 3, "label": "geneC", "boundary": ""}) + assert_eq(host._zem.requests[-1], {"chr_id": 2, "start": 0, "end": 100, "max_records": 65535, "min_feature_len_bp": 1}) + assert_eq(host.jumped_features[-1]["start"], 1050) + assert_eq(host.jumped_features[-1]["end"], 1060) + + result = controller.step_feature(1) + assert_eq(result, {"ok": true, "index": 0, "count": 2, "label": "geneD", "boundary": ""}) + assert_eq(host._zem.requests[-1], {"chr_id": 3, "start": 0, "end": 100, "max_records": 65535, "min_feature_len_bp": 1}) + assert_eq(host.jumped_features[-1]["start"], 2005) + assert_eq(host.jumped_features[-1]["end"], 2015) + + result = controller.step_feature(-1) + assert_eq(result, {"ok": true, "index": 2, "count": 3, "label": "geneC", "boundary": ""}) + assert_eq(host.jumped_features[-1]["start"], 1050) + + result = controller.step_feature(9) + assert_eq(result, {"ok": true, "index": 1, "count": 2, "label": "geneE", "boundary": "last"}) + assert_eq(host.jumped_features[-1]["start"], 2025) + + result = controller.step_feature(-9) + assert_eq(result, {"ok": true, "index": 0, "count": 3, "label": "geneA", "boundary": "first"}) + assert_eq(host.jumped_features[-1]["start"], 1010) + host.free() diff --git a/tests/godot/test_annotation_feature_navigation_controller.gd.uid b/tests/godot/test_annotation_feature_navigation_controller.gd.uid new file mode 100644 index 0000000..b90bc70 --- /dev/null +++ b/tests/godot/test_annotation_feature_navigation_controller.gd.uid @@ -0,0 +1 @@ +uid://bba5s3bcu3d16 diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd index ec31dff..c22b200 100644 --- a/tests/godot/test_vim_command.gd +++ b/tests/godot/test_vim_command.gd @@ -31,6 +31,8 @@ class FakeVimHost: ] var go_requests: Array[Dictionary] = [] var status_messages: Array[Dictionary] = [] + var feature_steps: Array[int] = [] + var feature_step_results: Array[Dictionary] = [] var quit_requests := 0 func _go_get_browser_target_chr_id() -> int: @@ -56,6 +58,13 @@ class FakeVimHost: _app_mode = next_mode return true + func _step_annotation_feature(delta: int) -> Dictionary: + feature_steps.append(delta) + if feature_step_results.is_empty(): + return {"ok": false, "error": "no annotation feature selected"} + var result: Dictionary = feature_step_results.pop_front() + return result + func _quit_app() -> void: quit_requests += 1 @@ -274,6 +283,34 @@ func test_vim_key_matching_distinguishes_c_and_shift_c() -> void: assert_true(VimModeControllerScript._matches_key(keycode_upper_event, KEY_C, true)) +func test_vim_w_b_step_annotation_features_with_counts() -> void: + var host := FakeVimHost.new() + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller.command_edit = edit + controller._enabled = true + + host.feature_step_results.append({"ok": true, "index": 1, "count": 3, "label": "geneB"}) + assert_true(controller.handle_input(_vim_key(KEY_W, false, 119))) + assert_eq(host.feature_steps[-1], 1) + assert_eq(edit.text, "geneB 2/3") + assert_eq(host.status_messages[-1], {"message": "geneB 2/3", "is_error": false}) + + host.feature_step_results.append({"ok": true, "index": 0, "count": 3, "label": "geneA", "boundary": "first"}) + assert_true(controller.handle_input(_vim_key(KEY_5, false, 53))) + assert_true(controller.handle_input(_vim_key(KEY_B, false, 98))) + assert_eq(host.feature_steps[-1], -5) + assert_eq(edit.text, "first feature") + assert_eq(host.status_messages[-1], {"message": "first feature", "is_error": false}) + + assert_true(controller.handle_input(_vim_key(KEY_B, false, 98))) + assert_eq(edit.text, "no annotation feature selected") + assert_eq(host.status_messages[-1], {"message": "no annotation feature selected", "is_error": true}) + edit.free() + host.free() + + func test_vim_counted_gg_jumps_to_counted_position() -> void: var host := FakeVimHost.new() var edit := LineEdit.new() From 333df0360d999d6c1523e234b1207b7348ae0eb5 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Sun, 14 Jun 2026 16:13:46 +0100 Subject: [PATCH 08/10] Document Vim mode controls --- docs/index.md | 1 + docs/vim-mode.md | 202 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 docs/vim-mode.md diff --git a/docs/index.md b/docs/index.md index fd697bb..1fb6096 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ For more detail, see: install supported-files navigation +vim-mode contig-actions theme-editor comparison-view diff --git a/docs/vim-mode.md b/docs/vim-mode.md new file mode 100644 index 0000000..77708af --- /dev/null +++ b/docs/vim-mode.md @@ -0,0 +1,202 @@ +# Vim Mode + +Vim mode adds keyboard-driven navigation and commands. Enable it from the +settings panel with the `Vim Mode` toggle. When Vim mode is off, these Vim keys +are ignored and the normal app shortcuts keep their usual behavior. + +When Vim mode is on, a single-line command bar is shown at the bottom of the +window. It is used for commands, searches, counts, and short status messages. + +## Normal-mode keys + +These keys work when Vim mode is enabled and the command bar is not actively +editing a `:` command or `/` search. + +| Key | Action | +| --- | --- | +| `h` | Pan left by one pan step. | +| `l` | Pan right by one pan step. | +| `j` | Zoom out one step. | +| `k` | Zoom in one step. | +| `gg` | Jump to the start of the current sequence. | +| `gg` | Jump to that 1-based position in the current sequence. For example, `500gg` jumps to position 500. | +| `G` | Jump to the end of the current sequence. | +| `gc`, `]c` | Jump to the start of the next contig. | +| `gC`, `[c` | Jump to the start of the previous contig. | +| `n` | Move to the next search hit. | +| `N` | Move to the previous search hit. | +| `w` | Move to the next annotation feature when a feature is selected. | +| `b` | Move to the previous annotation feature when a feature is selected. | +| `m` | Save the current view as a mark. For example, `ma` sets mark `a`. | +| `'` | Load a mark. For example, `'a` jumps to mark `a`. | +| `gv` | Toggle between single genome view and comparison view. | +| `ZZ` | Quit the app. | +| `:` | Open the Vim command prompt. | +| `/` | Open the Vim search prompt. | + +The pan and zoom keys apply to the active view. Browser-only navigation, such as +`gg`, `G`, contig jumps, feature jumps, `go`, and Vim `/` search, requires the +single genome browser view. + +## Counts + +Counts can be typed before several normal-mode commands. The count is shown in +the command bar while it is being entered. + +Supported counted commands: + +| Command | Meaning | +| --- | --- | +| `h` | Pan left by that many pan steps. | +| `l` | Pan right by that many pan steps. | +| `n` | Move forward by that many search hits. | +| `N` | Move backward by that many search hits. | +| `gg` | Jump to that position in the current sequence. | +| `gc`, `]c` | Jump forward by that many contigs. | +| `gC`, `[c` | Jump backward by that many contigs. | +| `w` | Move forward by that many annotation features. | +| `b` | Move backward by that many annotation features. | + +Contig navigation clamps at the first or last contig. Feature navigation clamps +at the first or last feature. In concatenate view, feature navigation continues +across contig boundaries and skips contigs that have no annotation features. + +## Marks + +Marks save and restore view positions. + +- `m` saves the current view to a mark. +- `'` loads that mark. + +For example: + +- `ma` saves mark `a` +- `'a` jumps to mark `a` + +When a mark is saved, the command bar shows `mark set`. When a mark is +loaded, it shows `jumped to mark `. + +Marks use the same view-state saving code as the numbered temporary view slots. +Browser view and comparison view keep separate saved-view banks. + +## Annotation feature navigation + +Feature navigation works after selecting an annotation feature. + +- `w` selects the next annotation feature. +- `b` selects the previous annotation feature. +- Counts work, such as `5w` or `3b`. + +In concatenate view, moving past the final feature of one contig continues to the +first feature of the next contig with annotations. Moving backward from the first +feature of a contig continues to the previous contig with annotations. + +The command bar shows the selected feature label and its position within the +current contig's feature list, such as `geneA 12/80`. At the ends, it shows +`first feature` or `last feature`. + +## Search result navigation + +After using the search panel or Vim `/` search, use: + +- `n` for the next hit +- `N` for the previous hit + +Counts are supported, such as `5n` or `2N`. + +The command bar shows the selected search result as `search hit x/y`. Clicking a +search result in the search panel also updates this Vim message. + +## Command prompt + +Press `:` to type a Vim command. Press `Enter` to run it. Press `Escape` to exit +the prompt without running it. + +If a `:` command is active, `Escape` only exits the command prompt. It does not +also close panels. If the command prompt is not active, `Escape` keeps the app's +normal behavior. + +Implemented commands: + +| Command | Action | +| --- | --- | +| `:go ` | Jump to a 1-based position in the current sequence. | +| `:go -` | Jump to a range in the current sequence. | +| `:go :` | Jump to a position in a named sequence. | +| `:go :-` | Jump to a range in a named sequence. | +| `:go ` | Space-separated form for a named sequence. | +| `:go -` | Space-separated range form for a named sequence. | +| `:colorscheme ` | Switch to a theme. This uses standard Vim-style `colorscheme` syntax. | +| `:view single` | Switch to single genome view. | +| `:view comparison` | Switch to comparison view. | +| `:q` | Quit the app. | +| `:quit` | Quit the app. | + +Position and range examples: + +```text +:go 123 +:go 100-250 +:go contig1:123 +:go contig1:100-250 +:go sample:contig1:500 +:go sample:contig1 500 +``` + +The colon form is the preferred sequence syntax. The space-separated sequence +form is also available when it is clearer for sequence names that contain +colons. + +## Search prompt + +Press `/` to type a search. Press `Enter` to run it. Press `Escape` to exit the +prompt without running it. + +Searches open the search panel with the results. + +The first word chooses the search type: + +- `annotation`, or any unambiguous prefix such as `a`, `an`, or `annot` +- `dna`, or any unambiguous prefix such as `d` or `dn` + +Annotation search examples: + +```text +/a gene1 +/an contig1 gene1 +/annot contig2 foo bar +``` + +DNA search examples: + +```text +/d ACGT +/dna contig24 gtcgtacgtcag +``` + +If the search prompt contains a single word made only of `A`, `C`, `G`, and `T` +characters, it is treated as a whole-genome DNA search: + +```text +/ACGT +``` + +For annotation and DNA searches, if the first word after the search type matches +a sequence name, the search is restricted to that sequence. Otherwise the search +runs across all sequences and the rest of the text is treated as the query. + +## Completion and history + +Press `Tab` in the command bar to cycle through available completions. + +Completion is implemented for: + +- `:` command names, such as `go`, `colorscheme`, `view`, `q`, and `quit` +- sequence names after `:go` +- theme names after `:colorscheme` +- `single` and `comparison` after `:view` +- sequence names after the search target in `/annotation` or `/dna` searches + +Press `Up` and `Down` in the command bar to move through prompt history. +History is session-only, keeps the last 100 commands, and is stored separately +for `:` commands and `/` searches. From 6f4ec97a58e5b41ca1cb66025d374555ae2cba9f Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Sun, 14 Jun 2026 16:23:01 +0100 Subject: [PATCH 09/10] Add Vim open command --- docs/vim-mode.md | 12 +++++++- scripts/main.gd | 17 +++++++++++ scripts/vim_mode_controller.gd | 30 +++++++++++++++++++- tests/godot/test_vim_command.gd | 50 +++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/docs/vim-mode.md b/docs/vim-mode.md index 77708af..0f4ab67 100644 --- a/docs/vim-mode.md +++ b/docs/vim-mode.md @@ -126,6 +126,8 @@ Implemented commands: | `:go :-` | Jump to a range in a named sequence. | | `:go ` | Space-separated form for a named sequence. | | `:go -` | Space-separated range form for a named sequence. | +| `:open` | Open the native file picker, like pressing the open file button. | +| `:open ` | Open a file from a filesystem path. | | `:colorscheme ` | Switch to a theme. This uses standard Vim-style `colorscheme` syntax. | | `:view single` | Switch to single genome view. | | `:view comparison` | Switch to comparison view. | @@ -147,6 +149,14 @@ The colon form is the preferred sequence syntax. The space-separated sequence form is also available when it is clearer for sequence names that contain colons. +Open examples: + +```text +:open +:open /path/to/foo.fasta +:open "/path/to/foo bar.fasta" +``` + ## Search prompt Press `/` to type a search. Press `Enter` to run it. Press `Escape` to exit the @@ -191,7 +201,7 @@ Press `Tab` in the command bar to cycle through available completions. Completion is implemented for: -- `:` command names, such as `go`, `colorscheme`, `view`, `q`, and `quit` +- `:` command names, such as `go`, `open`, `colorscheme`, `view`, `q`, and `quit` - sequence names after `:go` - theme names after `:colorscheme` - `single` and `comparison` after `:view` diff --git a/scripts/main.gd b/scripts/main.gd index 7903a60..625cb6f 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -660,6 +660,23 @@ func _on_open_files_selected(paths: PackedStringArray) -> void: _open_file_last_dir = first_path.get_base_dir() _on_files_dropped(paths) +func _vim_open_file(path: String = "") -> Dictionary: + var clean := _expand_open_path(path.strip_edges()) + if clean.is_empty(): + _on_open_file_pressed() + return {"ok": true, "message": "open files"} + if not FileAccess.file_exists(clean): + return {"ok": false, "error": "file not found: %s" % clean} + _on_open_files_selected(PackedStringArray([clean])) + return {"ok": true, "message": "opening %s" % clean.get_file(), "set_status": false} + +func _expand_open_path(path: String) -> String: + if path == "~": + return _user_home_dir() + if path.begins_with("~/") or path.begins_with("~\\"): + return _user_home_dir().path_join(path.substr(2)) + return path + func _remember_open_file_dialog_dir() -> void: if _open_file_dialog == null: return diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd index c48b22e..a941bee 100644 --- a/scripts/vim_mode_controller.gd +++ b/scripts/vim_mode_controller.gd @@ -9,7 +9,7 @@ const VIM_MARK_ACTION_SAVE := "save" const VIM_MARK_ACTION_LOAD := "load" const VIM_COMMAND_PREFIX_COMMAND := ":" const VIM_COMMAND_PREFIX_SEARCH := "/" -const VIM_COLON_COMMANDS := ["go", "colorscheme", "q", "quit", "view"] +const VIM_COLON_COMMANDS := ["go", "colorscheme", "open", "q", "quit", "view"] const VIM_VIEW_TARGETS := ["comparison", "single"] const VIM_COMMAND_HISTORY_LIMIT := 100 @@ -360,6 +360,8 @@ func _execute_command(command: String) -> void: _execute_go_command(clean) elif lower == VimCommandParserScript.COMMAND_COLORSCHEME or lower.begins_with("%s " % VimCommandParserScript.COMMAND_COLORSCHEME): _execute_colorscheme_command(clean) + elif lower == "open" or lower.begins_with("open "): + _execute_open_command(clean) elif lower == "view" or lower.begins_with("view "): _execute_view_command(clean) elif lower == "q" or lower == "quit": @@ -392,6 +394,32 @@ func _execute_quit_command() -> void: host.get_tree().quit() +func _execute_open_command(command: String) -> void: + if host == null or not host.has_method("_vim_open_file"): + _show_bar_error("open unavailable") + return + var path := _strip_matching_quotes(command.substr(4).strip_edges()) + var result: Dictionary = host._vim_open_file(path) + if not bool(result.get("ok", false)): + _show_bar_error(str(result.get("error", "open failed"))) + return + var message := str(result.get("message", "open files")) + if not message.is_empty(): + if bool(result.get("set_status", true)): + host._set_status(message) + show_message(message) + + +static func _strip_matching_quotes(text: String) -> String: + if text.length() < 2: + return text + var first := text.unicode_at(0) + var last := text.unicode_at(text.length() - 1) + if (first == 34 and last == 34) or (first == 39 and last == 39): + return text.substr(1, text.length() - 2) + return text + + func _execute_view_command(command: String) -> void: var args := command.substr(4).strip_edges().to_lower() if args.is_empty(): diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd index c22b200..21ce606 100644 --- a/tests/godot/test_vim_command.gd +++ b/tests/godot/test_vim_command.gd @@ -33,6 +33,8 @@ class FakeVimHost: var status_messages: Array[Dictionary] = [] var feature_steps: Array[int] = [] var feature_step_results: Array[Dictionary] = [] + var open_requests: Array[String] = [] + var open_results: Array[Dictionary] = [] var quit_requests := 0 func _go_get_browser_target_chr_id() -> int: @@ -65,6 +67,16 @@ class FakeVimHost: var result: Dictionary = feature_step_results.pop_front() return result + func _vim_open_file(path: String = "") -> Dictionary: + open_requests.append(path) + if not open_results.is_empty(): + var result: Dictionary = open_results.pop_front() + return result + return { + "ok": true, + "message": "open files" if path.is_empty() else "opening %s" % path.get_file() + } + func _quit_app() -> void: quit_requests += 1 @@ -110,6 +122,44 @@ func test_vim_colon_completion_includes_quit_commands() -> void: assert_eq(controller._colon_command_completion_matches("q"), PackedStringArray(["q", "quit"])) +func test_vim_colon_completion_includes_open_command() -> void: + var controller := VimModeControllerScript.new() + assert_eq(controller._colon_command_completion_matches("o"), PackedStringArray(["open"])) + + +func test_vim_open_command_opens_dialog_or_path() -> void: + var host := FakeVimHost.new() + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller.command_edit = edit + controller._enabled = true + + controller._execute_command("open") + assert_eq(host.open_requests[-1], "") + assert_eq(edit.text, "open files") + assert_eq(host.status_messages[-1], {"message": "open files", "is_error": false}) + + controller._execute_command("open \"/tmp/foo bar.fasta\"") + assert_eq(host.open_requests[-1], "/tmp/foo bar.fasta") + assert_eq(edit.text, "opening foo bar.fasta") + + host.open_results.append({"ok": true, "message": "opening silent.fasta", "set_status": false}) + var previous_status: Dictionary = host.status_messages[-1] + controller._execute_command("open /tmp/silent.fasta") + assert_eq(host.open_requests[-1], "/tmp/silent.fasta") + assert_eq(edit.text, "opening silent.fasta") + assert_eq(host.status_messages[-1], previous_status) + + host.open_results.append({"ok": false, "error": "file not found: /tmp/missing.fasta"}) + controller._execute_command("open /tmp/missing.fasta") + assert_eq(host.open_requests[-1], "/tmp/missing.fasta") + assert_eq(edit.text, "file not found: /tmp/missing.fasta") + assert_eq(host.status_messages[-1], {"message": "file not found: /tmp/missing.fasta", "is_error": true}) + edit.free() + host.free() + + func test_vim_view_completion_includes_targets() -> void: var edit := LineEdit.new() var controller := VimModeControllerScript.new() From 94ab103024be14b6b7e754614d23d8bd961b85c6 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Sun, 14 Jun 2026 16:30:37 +0100 Subject: [PATCH 10/10] Add Vim download command --- docs/vim-mode.md | 9 +++++++- scripts/main.gd | 27 ++++++++++++++++++++++ scripts/vim_mode_controller.gd | 23 +++++++++++++++++- tests/godot/test_vim_command.gd | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/docs/vim-mode.md b/docs/vim-mode.md index 0f4ab67..5492639 100644 --- a/docs/vim-mode.md +++ b/docs/vim-mode.md @@ -128,6 +128,7 @@ Implemented commands: | `:go -` | Space-separated range form for a named sequence. | | `:open` | Open the native file picker, like pressing the open file button. | | `:open ` | Open a file from a filesystem path. | +| `:download ` | Open the download panel, fill the accession box, and start download-and-load. | | `:colorscheme ` | Switch to a theme. This uses standard Vim-style `colorscheme` syntax. | | `:view single` | Switch to single genome view. | | `:view comparison` | Switch to comparison view. | @@ -157,6 +158,12 @@ Open examples: :open "/path/to/foo bar.fasta" ``` +Download example: + +```text +:download GCF_000005845.2 +``` + ## Search prompt Press `/` to type a search. Press `Enter` to run it. Press `Escape` to exit the @@ -201,7 +208,7 @@ Press `Tab` in the command bar to cycle through available completions. Completion is implemented for: -- `:` command names, such as `go`, `open`, `colorscheme`, `view`, `q`, and `quit` +- `:` command names, such as `go`, `open`, `download`, `colorscheme`, `view`, `q`, and `quit` - sequence names after `:go` - theme names after `:colorscheme` - `single` and `comparison` after `:view` diff --git a/scripts/main.gd b/scripts/main.gd index 625cb6f..d46104b 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -677,6 +677,18 @@ func _expand_open_path(path: String) -> String: return _user_home_dir().path_join(path.substr(2)) return path +func _vim_download_accession(accession: String) -> Dictionary: + var clean := accession.strip_edges() + if clean.is_empty(): + return {"ok": false, "error": "usage: download "} + if not _open_download_panel(): + return {"ok": false, "error": "download unavailable"} + if _download_accession_edit == null: + return {"ok": false, "error": "download unavailable"} + _download_accession_edit.text = clean + _start_download_genome() + return {"ok": true, "message": "downloading %s" % clean} + func _remember_open_file_dialog_dir() -> void: if _open_file_dialog == null: return @@ -2268,6 +2280,21 @@ func _toggle_download_panel() -> void: if _context_panel_controller != null: _context_panel_controller.toggle_download_panel() +func _open_download_panel() -> bool: + if _theme_editor_controller != null and _theme_editor_controller.is_open(): + return false + if _context_panel_controller == null or _download_panel == null: + return false + _context_panel_controller.prepare_context_panel(CONTEXT_PANEL_DOWNLOAD, "Download Genome", false) + _download_panel.visible = true + if not _download_in_progress: + _set_download_status("") + _feature_panel_open = true + _slide_feature_panel(true, true) + if _download_accession_edit != null: + _download_accession_edit.grab_focus() + return true + func _prepare_context_panel(mode: int, title: String, show_detail_labels: bool) -> void: if _context_panel_controller != null: _context_panel_controller.prepare_context_panel(mode, title, show_detail_labels) diff --git a/scripts/vim_mode_controller.gd b/scripts/vim_mode_controller.gd index a941bee..257d930 100644 --- a/scripts/vim_mode_controller.gd +++ b/scripts/vim_mode_controller.gd @@ -9,7 +9,7 @@ const VIM_MARK_ACTION_SAVE := "save" const VIM_MARK_ACTION_LOAD := "load" const VIM_COMMAND_PREFIX_COMMAND := ":" const VIM_COMMAND_PREFIX_SEARCH := "/" -const VIM_COLON_COMMANDS := ["go", "colorscheme", "open", "q", "quit", "view"] +const VIM_COLON_COMMANDS := ["go", "colorscheme", "download", "open", "q", "quit", "view"] const VIM_VIEW_TARGETS := ["comparison", "single"] const VIM_COMMAND_HISTORY_LIMIT := 100 @@ -360,6 +360,8 @@ func _execute_command(command: String) -> void: _execute_go_command(clean) elif lower == VimCommandParserScript.COMMAND_COLORSCHEME or lower.begins_with("%s " % VimCommandParserScript.COMMAND_COLORSCHEME): _execute_colorscheme_command(clean) + elif lower == "download" or lower.begins_with("download "): + _execute_download_command(clean) elif lower == "open" or lower.begins_with("open "): _execute_open_command(clean) elif lower == "view" or lower.begins_with("view "): @@ -410,6 +412,25 @@ func _execute_open_command(command: String) -> void: show_message(message) +func _execute_download_command(command: String) -> void: + if host == null or not host.has_method("_vim_download_accession"): + _show_bar_error("download unavailable") + return + var accession := _strip_matching_quotes(command.substr(8).strip_edges()) + if accession.is_empty(): + _show_bar_error("usage: download ") + return + var result: Dictionary = host._vim_download_accession(accession) + if not bool(result.get("ok", false)): + _show_bar_error(str(result.get("error", "download failed"))) + return + var message := str(result.get("message", "downloading %s" % accession)) + if not message.is_empty(): + if bool(result.get("set_status", false)): + host._set_status(message) + show_message(message) + + static func _strip_matching_quotes(text: String) -> String: if text.length() < 2: return text diff --git a/tests/godot/test_vim_command.gd b/tests/godot/test_vim_command.gd index 21ce606..162fd55 100644 --- a/tests/godot/test_vim_command.gd +++ b/tests/godot/test_vim_command.gd @@ -35,6 +35,8 @@ class FakeVimHost: var feature_step_results: Array[Dictionary] = [] var open_requests: Array[String] = [] var open_results: Array[Dictionary] = [] + var download_requests: Array[String] = [] + var download_results: Array[Dictionary] = [] var quit_requests := 0 func _go_get_browser_target_chr_id() -> int: @@ -77,6 +79,13 @@ class FakeVimHost: "message": "open files" if path.is_empty() else "opening %s" % path.get_file() } + func _vim_download_accession(accession: String) -> Dictionary: + download_requests.append(accession) + if not download_results.is_empty(): + var result: Dictionary = download_results.pop_front() + return result + return {"ok": true, "message": "downloading %s" % accession} + func _quit_app() -> void: quit_requests += 1 @@ -127,6 +136,11 @@ func test_vim_colon_completion_includes_open_command() -> void: assert_eq(controller._colon_command_completion_matches("o"), PackedStringArray(["open"])) +func test_vim_colon_completion_includes_download_command() -> void: + var controller := VimModeControllerScript.new() + assert_eq(controller._colon_command_completion_matches("d"), PackedStringArray(["download"])) + + func test_vim_open_command_opens_dialog_or_path() -> void: var host := FakeVimHost.new() var edit := LineEdit.new() @@ -160,6 +174,33 @@ func test_vim_open_command_opens_dialog_or_path() -> void: host.free() +func test_vim_download_command_starts_download() -> void: + var host := FakeVimHost.new() + var edit := LineEdit.new() + var controller := VimModeControllerScript.new() + controller.host = host + controller.command_edit = edit + controller._enabled = true + + controller._execute_command("download GCF_000005845.2") + assert_eq(host.download_requests[-1], "GCF_000005845.2") + assert_eq(edit.text, "downloading GCF_000005845.2") + assert_eq(host.status_messages.size(), 0) + + controller._execute_command("download") + assert_eq(host.download_requests.size(), 1) + assert_eq(edit.text, "usage: download ") + assert_eq(host.status_messages[-1], {"message": "usage: download ", "is_error": true}) + + host.download_results.append({"ok": false, "error": "download unavailable"}) + controller._execute_command("download ERR024604") + assert_eq(host.download_requests[-1], "ERR024604") + assert_eq(edit.text, "download unavailable") + assert_eq(host.status_messages[-1], {"message": "download unavailable", "is_error": true}) + edit.free() + host.free() + + func test_vim_view_completion_includes_targets() -> void: var edit := LineEdit.new() var controller := VimModeControllerScript.new()