From 126c9a091be0c9843f15dd2ce18d536a1302fd16 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Mon, 8 Jun 2026 17:58:13 +0100 Subject: [PATCH 1/2] Extract track settings controller --- scripts/main.gd | 335 +--------------- scripts/track_settings_controller.gd | 360 ++++++++++++++++++ scripts/track_settings_controller.gd.uid | 1 + tests/godot/run_tests.gd | 3 +- tests/godot/test_track_settings_controller.gd | 226 +++++++++++ .../test_track_settings_controller.gd.uid | 1 + 6 files changed, 598 insertions(+), 328 deletions(-) create mode 100644 scripts/track_settings_controller.gd create mode 100644 scripts/track_settings_controller.gd.uid create mode 100644 tests/godot/test_track_settings_controller.gd create mode 100644 tests/godot/test_track_settings_controller.gd.uid diff --git a/scripts/main.gd b/scripts/main.gd index 5d5dad0..1181521 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -8,6 +8,7 @@ const SearchControllerScript = preload("res://scripts/search_controller.gd") const GoControllerScript = preload("res://scripts/go_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") 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") @@ -193,6 +194,7 @@ var _search_controller: RefCounted var _go_controller: RefCounted var _top_bar_controller: RefCounted var _context_panel_controller: RefCounted +var _track_settings_controller: RefCounted var _annotation_cache_controller: RefCounted var _feature_panel_controller: RefCounted var _variant_controller: RefCounted @@ -356,6 +358,8 @@ func _ready() -> void: _top_bar_controller.configure(self) _context_panel_controller = ContextPanelControllerScript.new() _context_panel_controller.configure(self) + _track_settings_controller = TrackSettingsControllerScript.new() + _track_settings_controller.configure(self) _sound_controller = SoundControllerScript.new() _sound_controller.configure(self) _top_bar_controller.setup() @@ -1700,45 +1704,8 @@ func _on_annotation_max_on_screen_changed(value: float) -> void: _schedule_fetch() func _setup_track_settings_panel() -> void: - _track_settings_box = VBoxContainer.new() - _track_settings_box.visible = false - feature_content.add_child(_track_settings_box) - _read_track_settings_panel = ReadTrackSettingsPanelScene.instantiate() as VBoxContainer - if _read_track_settings_panel != null: - _read_track_settings_panel.visible = false - _track_settings_box.add_child(_read_track_settings_panel) - var read_view_option := _read_track_settings_panel.get_node("ReadViewOption") as OptionButton - if read_view_option != null and read_view_option.item_count == 0: - read_view_option.add_item("Stack", 0) - read_view_option.add_item("Strand Stack", 1) - read_view_option.add_item("Paired", 2) - read_view_option.add_item("Fragment Size", 3) - if read_view_option != null and not read_view_option.item_selected.is_connected(_on_active_read_track_view_selected): - read_view_option.item_selected.connect(_on_active_read_track_view_selected) - var frag_cb := _read_track_settings_panel.get_node("FragmentLogScale") as CheckButton - if frag_cb != null and not frag_cb.toggled.is_connected(_on_active_read_track_fragment_log_toggled): - frag_cb.toggled.connect(_on_active_read_track_fragment_log_toggled) - var thickness_spin := _read_track_settings_panel.get_node("ReadThicknessSpin") as SpinBox - if thickness_spin != null and not thickness_spin.value_changed.is_connected(_on_active_read_track_thickness_changed): - thickness_spin.value_changed.connect(_on_active_read_track_thickness_changed) - var auto_expand_snp_cb := _read_track_settings_panel.get_node("AutoExpandSNPText") as CheckButton - if auto_expand_snp_cb != null and not auto_expand_snp_cb.toggled.is_connected(_on_active_read_track_auto_expand_snp_toggled): - auto_expand_snp_cb.toggled.connect(_on_active_read_track_auto_expand_snp_toggled) - var show_soft_clips_cb := _read_track_settings_panel.get_node("ShowSoftClips") as CheckButton - if show_soft_clips_cb != null and not show_soft_clips_cb.toggled.is_connected(_on_active_read_track_show_soft_clips_toggled): - show_soft_clips_cb.toggled.connect(_on_active_read_track_show_soft_clips_toggled) - var show_pileup_logo_cb := _read_track_settings_panel.get_node("ShowPileupLogo") as CheckButton - if show_pileup_logo_cb != null and not show_pileup_logo_cb.toggled.is_connected(_on_active_read_track_show_pileup_logo_toggled): - show_pileup_logo_cb.toggled.connect(_on_active_read_track_show_pileup_logo_toggled) - var mate_contig_color_cb := _read_track_settings_panel.get_node("MateContigColor") as CheckButton - if mate_contig_color_cb != null and not mate_contig_color_cb.toggled.is_connected(_on_active_read_track_mate_contig_color_toggled): - mate_contig_color_cb.toggled.connect(_on_active_read_track_mate_contig_color_toggled) - var max_rows_spin := _read_track_settings_panel.get_node("MaxRowsSpin") as SpinBox - if max_rows_spin != null and not max_rows_spin.value_changed.is_connected(_on_active_read_track_max_rows_changed): - max_rows_spin.value_changed.connect(_on_active_read_track_max_rows_changed) - var mapq_spin := _read_track_settings_panel.get_node("MapQSpin") as SpinBox - if mapq_spin != null and not mapq_spin.value_changed.is_connected(_on_active_read_track_min_mapq_changed): - mapq_spin.value_changed.connect(_on_active_read_track_min_mapq_changed) + if _track_settings_controller != null: + _track_settings_controller.setup(feature_content, ReadTrackSettingsPanelScene) _search_controller.setup(feature_content, { "get_zem": Callable(self, "_search_get_zem"), "get_app_mode": Callable(self, "_search_get_app_mode"), @@ -2233,294 +2200,8 @@ func _sync_bam_read_tracks() -> void: genome_view.set_track_order(out) func _on_track_settings_requested(track_id: String) -> void: - if _track_settings_box == null: - return - if _track_settings_open and _active_track_settings_id == track_id and _feature_panel_open: - _play_ui_sound(SoundControllerScript.SOUND_BLIP) - _close_feature_panel() - return - if _track_settings_open and _active_track_settings_id == TRACK_GENOME and track_id != TRACK_GENOME: - _save_config() - _play_ui_sound(SoundControllerScript.SOUND_BLIP) - _prepare_context_panel(CONTEXT_PANEL_TRACK_SETTINGS, "%s track settings" % _track_label_for_id(track_id), false) - feature_name_label.visible = true - feature_name_label.text = "" - for child in _track_settings_box.get_children(): - if child == _read_track_settings_panel: - child.visible = false - var dynamic_options := _read_track_settings_panel.get_node("DynamicOptions") as VBoxContainer - if dynamic_options != null: - for dynamic_child in dynamic_options.get_children(): - dynamic_child.queue_free() - continue - child.queue_free() - _track_settings_box.visible = true - _track_settings_open = true - _active_track_settings_id = track_id - if track_id.begins_with("reads:"): - var track_meta := _bam_track_for_id(track_id) - var bam_name := str(track_meta.get("label", track_meta.get("path", "BAM"))) - var bam_label := _read_track_settings_panel.get_node("BAMLabel") as Label - var view_option := _read_track_settings_panel.get_node("ReadViewOption") as OptionButton - var frag_cb := _read_track_settings_panel.get_node("FragmentLogScale") as CheckButton - var thickness_spin := _read_track_settings_panel.get_node("ReadThicknessSpin") as SpinBox - var auto_expand_snp_cb := _read_track_settings_panel.get_node("AutoExpandSNPText") as CheckButton - var show_soft_clips_cb := _read_track_settings_panel.get_node("ShowSoftClips") as CheckButton - var show_pileup_logo_cb := _read_track_settings_panel.get_node("ShowPileupLogo") as CheckButton - var mate_contig_color_cb := _read_track_settings_panel.get_node("MateContigColor") as CheckButton - var max_rows_spin := _read_track_settings_panel.get_node("MaxRowsSpin") as SpinBox - var mapq_spin := _read_track_settings_panel.get_node("MapQSpin") as SpinBox - var dynamic_options := _read_track_settings_panel.get_node("DynamicOptions") as VBoxContainer - if _read_track_settings_panel != null: - _read_track_settings_panel.visible = true - if bam_label != null: - bam_label.text = "BAM: %s" % bam_name - if view_option != null: - view_option.select(int(track_meta.get("view_mode", 0))) - if frag_cb != null: - frag_cb.button_pressed = bool(track_meta.get("fragment_log", true)) - frag_cb.visible = view_option != null and view_option.selected == 3 - if thickness_spin != null: - thickness_spin.value = float(track_meta.get("thickness", DEFAULT_READ_THICKNESS)) - if auto_expand_snp_cb != null: - auto_expand_snp_cb.button_pressed = bool(track_meta.get("auto_expand_snp_text", true)) - if show_soft_clips_cb != null: - show_soft_clips_cb.button_pressed = bool(track_meta.get("show_soft_clips", false)) - if show_pileup_logo_cb != null: - show_pileup_logo_cb.button_pressed = bool(track_meta.get("show_pileup_logo", false)) - if mate_contig_color_cb != null: - mate_contig_color_cb.button_pressed = bool(track_meta.get("color_by_mate_contig", false)) - if max_rows_spin != null: - max_rows_spin.value = float(int(track_meta.get("max_rows", DEFAULT_READ_MAX_ROWS))) - if mapq_spin != null: - mapq_spin.value = float(int(track_meta.get("min_mapq", DEFAULT_READ_MIN_MAPQ))) - if dynamic_options != null: - for child in dynamic_options.get_children(): - child.queue_free() - var hidden_flags := int(track_meta.get("hidden_flags", DEFAULT_READ_HIDDEN_FLAGS)) - for entry_any in READ_FILTER_FLAG_LABELS: - var entry: Dictionary = entry_any - var flag_bit := int(entry.get("bit", 0)) - var flag_cb := CheckBox.new() - flag_cb.text = "Hide %s" % str(entry.get("label", "")) - flag_cb.button_pressed = (hidden_flags & flag_bit) != 0 - flag_cb.toggled.connect(func(enabled: bool) -> void: - _play_toggle_sound(enabled) - for i in range(_bam_tracks.size()): - var t: Dictionary = _bam_tracks[i] - if str(t.get("track_id", "")) != track_id: - continue - var next_hidden := int(t.get("hidden_flags", 0)) - if enabled: - next_hidden |= flag_bit - else: - next_hidden &= ~flag_bit - t["hidden_flags"] = next_hidden - _bam_tracks[i] = t - break - _schedule_fetch() - ) - dynamic_options.add_child(flag_cb) - if flag_bit == 2: - var improper_pair_cb := CheckBox.new() - improper_pair_cb.text = "Hide improper pair" - improper_pair_cb.button_pressed = bool(track_meta.get("hide_improper_pair", false)) - improper_pair_cb.toggled.connect(func(enabled: bool) -> void: - _play_toggle_sound(enabled) - for i in range(_bam_tracks.size()): - var t: Dictionary = _bam_tracks[i] - if str(t.get("track_id", "")) != track_id: - continue - t["hide_improper_pair"] = enabled - _bam_tracks[i] = t - break - _schedule_fetch() - ) - dynamic_options.add_child(improper_pair_cb) - elif flag_bit == 8: - var mate_forward_cb := CheckBox.new() - mate_forward_cb.text = "Hide mate forward strand" - mate_forward_cb.button_pressed = bool(track_meta.get("hide_mate_forward_strand", false)) - mate_forward_cb.toggled.connect(func(enabled: bool) -> void: - _play_toggle_sound(enabled) - for i in range(_bam_tracks.size()): - var t: Dictionary = _bam_tracks[i] - if str(t.get("track_id", "")) != track_id: - continue - t["hide_mate_forward_strand"] = enabled - _bam_tracks[i] = t - break - _schedule_fetch() - ) - dynamic_options.add_child(mate_forward_cb) - elif flag_bit == 16: - var forward_cb := CheckBox.new() - forward_cb.text = "Hide forward strand" - forward_cb.button_pressed = bool(track_meta.get("hide_forward_strand", false)) - forward_cb.toggled.connect(func(enabled: bool) -> void: - _play_toggle_sound(enabled) - for i in range(_bam_tracks.size()): - var t: Dictionary = _bam_tracks[i] - if str(t.get("track_id", "")) != track_id: - continue - t["hide_forward_strand"] = enabled - _bam_tracks[i] = t - break - _schedule_fetch() - ) - dynamic_options.add_child(forward_cb) - elif track_id == "aa": - var region_cb := CheckButton.new() - region_cb.text = "Show full-length region annotations" - region_cb.button_pressed = _show_full_length_regions - region_cb.toggled.connect(_on_show_full_region_toggled) - var stop_cb := CheckButton.new() - stop_cb.text = "Show stop codons" - stop_cb.button_pressed = _show_stop_codons - stop_cb.toggled.connect(_on_show_stop_codons_toggled) - var max_ann_label := Label.new() - max_ann_label.text = "Max annotations on screen" - var max_ann_spin := SpinBox.new() - max_ann_spin.min_value = ANNOT_MAX_ON_SCREEN_MIN - max_ann_spin.max_value = ANNOT_MAX_ON_SCREEN_MAX - max_ann_spin.step = 100 - max_ann_spin.value = _annotation_max_on_screen - max_ann_spin.value_changed.connect(_on_annotation_max_on_screen_changed) - _track_settings_box.add_child(region_cb) - _track_settings_box.add_child(stop_cb) - _track_settings_box.add_child(max_ann_label) - _track_settings_box.add_child(max_ann_spin) - elif track_id == TRACK_VCF: - if _variant_controller != null: - _variant_controller.populate_track_settings(track_id, _track_settings_box) - elif track_id == "genome": - var seq_view_label := Label.new() - seq_view_label.text = "Sequence View" - var seq_view_option := OptionButton.new() - seq_view_option.add_item("Concatenate", SEQ_VIEW_CONCAT) - seq_view_option.add_item("Single Sequence", SEQ_VIEW_SINGLE) - seq_view_option.select(_seq_view_mode) - _track_settings_box.add_child(seq_view_label) - _track_settings_box.add_child(seq_view_option) - var seq_label := Label.new() - seq_label.text = "Sequence" - var seq_option := OptionButton.new() - for i in range(_seq_option.item_count): - seq_option.add_item(_seq_option.get_item_text(i), _seq_option.get_item_id(i)) - if _selected_seq_id >= 0: - for i in range(seq_option.item_count): - if seq_option.get_item_id(i) == _selected_seq_id: - seq_option.select(i) - break - seq_option.visible = _seq_view_mode == SEQ_VIEW_SINGLE - seq_label.visible = seq_option.visible - _track_settings_box.add_child(seq_label) - _track_settings_box.add_child(seq_option) - var gap_label := Label.new() - gap_label.text = "Concat Gap (bp)" - var gap_spin := SpinBox.new() - gap_spin.min_value = 0 - gap_spin.max_value = 10000 - gap_spin.step = 10 - gap_spin.value = _concat_gap_bp - _track_settings_box.add_child(gap_label) - _track_settings_box.add_child(gap_spin) - var coord_commas_cb := CheckButton.new() - coord_commas_cb.text = "Use commas in axis coordinates" - coord_commas_cb.button_pressed = _axis_coords_with_commas - coord_commas_cb.toggled.connect(_on_axis_coords_commas_toggled) - _track_settings_box.add_child(coord_commas_cb) - seq_view_option.item_selected.connect(func(index: int) -> void: - _on_seq_view_selected(index) - var single := index == SEQ_VIEW_SINGLE - seq_option.visible = single - seq_label.visible = single - ) - seq_option.item_selected.connect(func(index: int) -> void: - if index < 0 or index >= seq_option.item_count: - return - var target_id := int(seq_option.get_item_id(index)) - for j in range(_seq_option.item_count): - if _seq_option.get_item_id(j) == target_id: - _seq_option.select(j) - break - _on_seq_selected(_seq_option.selected) - ) - gap_spin.value_changed.connect(_on_concat_gap_changed) - elif track_id == "gc_plot": - var win_label := Label.new() - win_label.text = "GC Window (bp)" - var win_spin := SpinBox.new() - win_spin.min_value = 1 - win_spin.max_value = 1000000 - win_spin.step = 1 - win_spin.value = _gc_window_bp - win_spin.value_changed.connect(func(value: float) -> void: - _gc_window_bp = clampi(int(value), 1, 1000000) - _invalidate_cache() - _schedule_fetch() - ) - var height_label := Label.new() - height_label.text = "Track Height (px)" - var height_spin := SpinBox.new() - height_spin.min_value = MIN_PLOT_HEIGHT - height_spin.max_value = MAX_PLOT_HEIGHT - height_spin.step = 1 - height_spin.value = _gc_plot_height - height_spin.value_changed.connect(func(value: float) -> void: - _gc_plot_height = value - _apply_gc_plot_height() - ) - _track_settings_box.add_child(win_label) - _track_settings_box.add_child(win_spin) - _track_settings_box.add_child(height_label) - _track_settings_box.add_child(height_spin) - elif track_id == "depth_plot": - if not _has_bam_loaded: - var no_bam := Label.new() - no_bam.text = "Load BAM to enable depth plot." - _track_settings_box.add_child(no_bam) - var height_label2 := Label.new() - height_label2.text = "Track Height (px)" - var height_spin2 := SpinBox.new() - height_spin2.min_value = MIN_PLOT_HEIGHT - height_spin2.max_value = MAX_PLOT_HEIGHT - height_spin2.step = 1 - height_spin2.value = _depth_plot_height - height_spin2.value_changed.connect(func(value: float) -> void: - _depth_plot_height = value - _apply_depth_plot_height() - ) - _track_settings_box.add_child(height_label2) - _track_settings_box.add_child(height_spin2) - var legend_title := Label.new() - legend_title.text = "Depth Lines" - _track_settings_box.add_child(legend_title) - if _bam_tracks.is_empty(): - var legend_empty := Label.new() - legend_empty.text = "None" - _track_settings_box.add_child(legend_empty) - else: - for i in range(_bam_tracks.size()): - var t: Dictionary = _bam_tracks[i] - var tid := str(t.get("track_id", "")) - var row := HBoxContainer.new() - row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var swatch := ColorRect.new() - swatch.custom_minimum_size = Vector2(14, 14) - swatch.color = _depth_plot_color_for_track(tid) - var name_label := Label.new() - name_label.text = "BAM %d: %s" % [i + 1, str(t.get("label", tid))] - name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.add_child(swatch) - row.add_child(name_label) - _track_settings_box.add_child(row) - else: - var info := Label.new() - info.text = "No track-specific settings yet." - _track_settings_box.add_child(info) - _feature_panel_open = true - _slide_feature_panel(true, true) + if _track_settings_controller != null: + _track_settings_controller.show_track_settings(track_id) func _toggle_search_panel() -> void: if _theme_editor_controller != null and _theme_editor_controller.is_open(): diff --git a/scripts/track_settings_controller.gd b/scripts/track_settings_controller.gd new file mode 100644 index 0000000..53ebff8 --- /dev/null +++ b/scripts/track_settings_controller.gd @@ -0,0 +1,360 @@ +extends RefCounted +class_name TrackSettingsController + +const SoundControllerScript = preload("res://scripts/sound_controller.gd") + +var host: Node = null + + +func configure(next_host: Node) -> void: + host = next_host + + +func setup(feature_content: VBoxContainer, read_panel_scene: PackedScene) -> void: + if host == null: + return + host._track_settings_box = VBoxContainer.new() + host._track_settings_box.visible = false + feature_content.add_child(host._track_settings_box) + host._read_track_settings_panel = read_panel_scene.instantiate() as VBoxContainer + if host._read_track_settings_panel == null: + return + host._read_track_settings_panel.visible = false + host._track_settings_box.add_child(host._read_track_settings_panel) + _setup_read_track_settings_panel() + + +func show_track_settings(track_id: String) -> void: + if host == null or host._track_settings_box == null: + return + if host._track_settings_open and host._active_track_settings_id == track_id and host._feature_panel_open: + host._play_ui_sound(SoundControllerScript.SOUND_BLIP) + host._close_feature_panel() + return + if host._track_settings_open and host._active_track_settings_id == host.TRACK_GENOME and track_id != host.TRACK_GENOME: + host._save_config() + host._play_ui_sound(SoundControllerScript.SOUND_BLIP) + host._prepare_context_panel(host.CONTEXT_PANEL_TRACK_SETTINGS, "%s track settings" % host._track_label_for_id(track_id), false) + host.feature_name_label.visible = true + host.feature_name_label.text = "" + _clear_track_settings_box() + host._track_settings_box.visible = true + host._track_settings_open = true + host._active_track_settings_id = track_id + if track_id.begins_with("reads:"): + _populate_read_track_settings(track_id) + elif track_id == host.TRACK_AA: + _populate_annotation_track_settings() + elif track_id == host.TRACK_VCF: + if host._variant_controller != null: + host._variant_controller.populate_track_settings(track_id, host._track_settings_box) + elif track_id == host.TRACK_GENOME: + _populate_genome_track_settings() + elif track_id == host.TRACK_GC_PLOT: + _populate_gc_plot_track_settings() + elif track_id == host.TRACK_DEPTH_PLOT: + _populate_depth_plot_track_settings() + else: + var info := Label.new() + info.text = "No track-specific settings yet." + host._track_settings_box.add_child(info) + host._feature_panel_open = true + host._slide_feature_panel(true, true) + + +func _setup_read_track_settings_panel() -> void: + var read_view_option := host._read_track_settings_panel.get_node("ReadViewOption") as OptionButton + if read_view_option != null and read_view_option.item_count == 0: + read_view_option.add_item("Stack", 0) + read_view_option.add_item("Strand Stack", 1) + read_view_option.add_item("Paired", 2) + read_view_option.add_item("Fragment Size", 3) + _connect_read_setting_signal(read_view_option, "item_selected", Callable(host, "_on_active_read_track_view_selected")) + _connect_read_setting_signal(host._read_track_settings_panel.get_node("FragmentLogScale") as CheckButton, "toggled", Callable(host, "_on_active_read_track_fragment_log_toggled")) + _connect_read_setting_signal(host._read_track_settings_panel.get_node("ReadThicknessSpin") as SpinBox, "value_changed", Callable(host, "_on_active_read_track_thickness_changed")) + _connect_read_setting_signal(host._read_track_settings_panel.get_node("AutoExpandSNPText") as CheckButton, "toggled", Callable(host, "_on_active_read_track_auto_expand_snp_toggled")) + _connect_read_setting_signal(host._read_track_settings_panel.get_node("ShowSoftClips") as CheckButton, "toggled", Callable(host, "_on_active_read_track_show_soft_clips_toggled")) + _connect_read_setting_signal(host._read_track_settings_panel.get_node("ShowPileupLogo") as CheckButton, "toggled", Callable(host, "_on_active_read_track_show_pileup_logo_toggled")) + _connect_read_setting_signal(host._read_track_settings_panel.get_node("MateContigColor") as CheckButton, "toggled", Callable(host, "_on_active_read_track_mate_contig_color_toggled")) + _connect_read_setting_signal(host._read_track_settings_panel.get_node("MaxRowsSpin") as SpinBox, "value_changed", Callable(host, "_on_active_read_track_max_rows_changed")) + _connect_read_setting_signal(host._read_track_settings_panel.get_node("MapQSpin") as SpinBox, "value_changed", Callable(host, "_on_active_read_track_min_mapq_changed")) + + +func _connect_read_setting_signal(control: Object, signal_name: String, target: Callable) -> void: + if control == null: + return + var sig: Signal = control.get(signal_name) + if not sig.is_connected(target): + sig.connect(target) + + +func _clear_track_settings_box() -> void: + for child in host._track_settings_box.get_children(): + if child == host._read_track_settings_panel: + child.visible = false + _clear_read_dynamic_options() + continue + child.queue_free() + + +func _clear_read_dynamic_options() -> void: + if host._read_track_settings_panel == null: + return + var dynamic_options := host._read_track_settings_panel.get_node("DynamicOptions") as VBoxContainer + if dynamic_options == null: + return + for dynamic_child in dynamic_options.get_children(): + dynamic_child.queue_free() + + +func _populate_read_track_settings(track_id: String) -> void: + var track_meta: Dictionary = host._bam_track_for_id(track_id) + var bam_name := str(track_meta.get("label", track_meta.get("path", "BAM"))) + var bam_label := host._read_track_settings_panel.get_node("BAMLabel") as Label + var view_option := host._read_track_settings_panel.get_node("ReadViewOption") as OptionButton + var frag_cb := host._read_track_settings_panel.get_node("FragmentLogScale") as CheckButton + var thickness_spin := host._read_track_settings_panel.get_node("ReadThicknessSpin") as SpinBox + var auto_expand_snp_cb := host._read_track_settings_panel.get_node("AutoExpandSNPText") as CheckButton + var show_soft_clips_cb := host._read_track_settings_panel.get_node("ShowSoftClips") as CheckButton + var show_pileup_logo_cb := host._read_track_settings_panel.get_node("ShowPileupLogo") as CheckButton + var mate_contig_color_cb := host._read_track_settings_panel.get_node("MateContigColor") as CheckButton + var max_rows_spin := host._read_track_settings_panel.get_node("MaxRowsSpin") as SpinBox + var mapq_spin := host._read_track_settings_panel.get_node("MapQSpin") as SpinBox + var dynamic_options := host._read_track_settings_panel.get_node("DynamicOptions") as VBoxContainer + host._read_track_settings_panel.visible = true + if bam_label != null: + bam_label.text = "BAM: %s" % bam_name + if view_option != null: + view_option.select(int(track_meta.get("view_mode", 0))) + if frag_cb != null: + frag_cb.button_pressed = bool(track_meta.get("fragment_log", true)) + frag_cb.visible = view_option != null and view_option.selected == 3 + if thickness_spin != null: + thickness_spin.value = float(track_meta.get("thickness", host.DEFAULT_READ_THICKNESS)) + if auto_expand_snp_cb != null: + auto_expand_snp_cb.button_pressed = bool(track_meta.get("auto_expand_snp_text", true)) + if show_soft_clips_cb != null: + show_soft_clips_cb.button_pressed = bool(track_meta.get("show_soft_clips", false)) + if show_pileup_logo_cb != null: + show_pileup_logo_cb.button_pressed = bool(track_meta.get("show_pileup_logo", false)) + if mate_contig_color_cb != null: + mate_contig_color_cb.button_pressed = bool(track_meta.get("color_by_mate_contig", false)) + if max_rows_spin != null: + max_rows_spin.value = float(int(track_meta.get("max_rows", host.DEFAULT_READ_MAX_ROWS))) + if mapq_spin != null: + mapq_spin.value = float(int(track_meta.get("min_mapq", host.DEFAULT_READ_MIN_MAPQ))) + if dynamic_options == null: + return + for child in dynamic_options.get_children(): + child.queue_free() + _add_read_filter_options(track_id, track_meta, dynamic_options) + + +func _add_read_filter_options(track_id: String, track_meta: Dictionary, dynamic_options: VBoxContainer) -> void: + var hidden_flags := int(track_meta.get("hidden_flags", host.DEFAULT_READ_HIDDEN_FLAGS)) + for entry_any in host.READ_FILTER_FLAG_LABELS: + var entry: Dictionary = entry_any + var flag_bit := int(entry.get("bit", 0)) + var flag_cb := CheckBox.new() + flag_cb.text = "Hide %s" % str(entry.get("label", "")) + flag_cb.button_pressed = (hidden_flags & flag_bit) != 0 + flag_cb.toggled.connect(func(enabled: bool) -> void: + host._play_toggle_sound(enabled) + _set_read_track_hidden_flag(track_id, flag_bit, enabled) + host._schedule_fetch() + ) + dynamic_options.add_child(flag_cb) + if flag_bit == 2: + _add_read_filter_bool_option(dynamic_options, track_id, "hide_improper_pair", "Hide improper pair", bool(track_meta.get("hide_improper_pair", false))) + elif flag_bit == 8: + _add_read_filter_bool_option(dynamic_options, track_id, "hide_mate_forward_strand", "Hide mate forward strand", bool(track_meta.get("hide_mate_forward_strand", false))) + elif flag_bit == 16: + _add_read_filter_bool_option(dynamic_options, track_id, "hide_forward_strand", "Hide forward strand", bool(track_meta.get("hide_forward_strand", false))) + + +func _add_read_filter_bool_option(dynamic_options: VBoxContainer, track_id: String, key: String, label: String, current_value: bool) -> void: + var cb := CheckBox.new() + cb.text = label + cb.button_pressed = current_value + cb.toggled.connect(func(enabled: bool) -> void: + host._play_toggle_sound(enabled) + _set_read_track_value(track_id, key, enabled) + host._schedule_fetch() + ) + dynamic_options.add_child(cb) + + +func _set_read_track_hidden_flag(track_id: String, flag_bit: int, enabled: bool) -> void: + for i in range(host._bam_tracks.size()): + var track: Dictionary = host._bam_tracks[i] + if str(track.get("track_id", "")) != track_id: + continue + var next_hidden := int(track.get("hidden_flags", 0)) + if enabled: + next_hidden |= flag_bit + else: + next_hidden &= ~flag_bit + track["hidden_flags"] = next_hidden + host._bam_tracks[i] = track + return + + +func _set_read_track_value(track_id: String, key: String, value: Variant) -> void: + for i in range(host._bam_tracks.size()): + var track: Dictionary = host._bam_tracks[i] + if str(track.get("track_id", "")) != track_id: + continue + track[key] = value + host._bam_tracks[i] = track + return + + +func _populate_annotation_track_settings() -> void: + var region_cb := CheckButton.new() + region_cb.text = "Show full-length region annotations" + region_cb.button_pressed = host._show_full_length_regions + region_cb.toggled.connect(host._on_show_full_region_toggled) + var stop_cb := CheckButton.new() + stop_cb.text = "Show stop codons" + stop_cb.button_pressed = host._show_stop_codons + stop_cb.toggled.connect(host._on_show_stop_codons_toggled) + var max_ann_label := Label.new() + max_ann_label.text = "Max annotations on screen" + var max_ann_spin := SpinBox.new() + max_ann_spin.min_value = host.ANNOT_MAX_ON_SCREEN_MIN + max_ann_spin.max_value = host.ANNOT_MAX_ON_SCREEN_MAX + max_ann_spin.step = 100 + max_ann_spin.value = host._annotation_max_on_screen + max_ann_spin.value_changed.connect(host._on_annotation_max_on_screen_changed) + host._track_settings_box.add_child(region_cb) + host._track_settings_box.add_child(stop_cb) + host._track_settings_box.add_child(max_ann_label) + host._track_settings_box.add_child(max_ann_spin) + + +func _populate_genome_track_settings() -> void: + var seq_view_label := Label.new() + seq_view_label.text = "Sequence View" + var seq_view_option := OptionButton.new() + seq_view_option.add_item("Concatenate", host.SEQ_VIEW_CONCAT) + seq_view_option.add_item("Single Sequence", host.SEQ_VIEW_SINGLE) + seq_view_option.select(host._seq_view_mode) + host._track_settings_box.add_child(seq_view_label) + host._track_settings_box.add_child(seq_view_option) + var seq_label := Label.new() + seq_label.text = "Sequence" + var seq_option := OptionButton.new() + for i in range(host._seq_option.item_count): + seq_option.add_item(host._seq_option.get_item_text(i), host._seq_option.get_item_id(i)) + if host._selected_seq_id >= 0: + for i in range(seq_option.item_count): + if seq_option.get_item_id(i) == host._selected_seq_id: + seq_option.select(i) + break + seq_option.visible = host._seq_view_mode == host.SEQ_VIEW_SINGLE + seq_label.visible = seq_option.visible + host._track_settings_box.add_child(seq_label) + host._track_settings_box.add_child(seq_option) + var gap_label := Label.new() + gap_label.text = "Concat Gap (bp)" + var gap_spin := SpinBox.new() + gap_spin.min_value = 0 + gap_spin.max_value = 10000 + gap_spin.step = 10 + gap_spin.value = host._concat_gap_bp + host._track_settings_box.add_child(gap_label) + host._track_settings_box.add_child(gap_spin) + var coord_commas_cb := CheckButton.new() + coord_commas_cb.text = "Use commas in axis coordinates" + coord_commas_cb.button_pressed = host._axis_coords_with_commas + coord_commas_cb.toggled.connect(host._on_axis_coords_commas_toggled) + host._track_settings_box.add_child(coord_commas_cb) + seq_view_option.item_selected.connect(func(index: int) -> void: + host._on_seq_view_selected(index) + var single: bool = index == host.SEQ_VIEW_SINGLE + seq_option.visible = single + seq_label.visible = single + ) + seq_option.item_selected.connect(func(index: int) -> void: + if index < 0 or index >= seq_option.item_count: + return + var target_id := int(seq_option.get_item_id(index)) + for j in range(host._seq_option.item_count): + if host._seq_option.get_item_id(j) == target_id: + host._seq_option.select(j) + break + host._on_seq_selected(host._seq_option.selected) + ) + gap_spin.value_changed.connect(host._on_concat_gap_changed) + + +func _populate_gc_plot_track_settings() -> void: + var win_label := Label.new() + win_label.text = "GC Window (bp)" + var win_spin := SpinBox.new() + win_spin.min_value = 1 + win_spin.max_value = 1000000 + win_spin.step = 1 + win_spin.value = host._gc_window_bp + win_spin.value_changed.connect(func(value: float) -> void: + host._gc_window_bp = clampi(int(value), 1, 1000000) + host._invalidate_cache() + host._schedule_fetch() + ) + var height_label := Label.new() + height_label.text = "Track Height (px)" + var height_spin := SpinBox.new() + height_spin.min_value = host.MIN_PLOT_HEIGHT + height_spin.max_value = host.MAX_PLOT_HEIGHT + height_spin.step = 1 + height_spin.value = host._gc_plot_height + height_spin.value_changed.connect(func(value: float) -> void: + host._gc_plot_height = value + host._apply_gc_plot_height() + ) + host._track_settings_box.add_child(win_label) + host._track_settings_box.add_child(win_spin) + host._track_settings_box.add_child(height_label) + host._track_settings_box.add_child(height_spin) + + +func _populate_depth_plot_track_settings() -> void: + if not host._has_bam_loaded: + var no_bam := Label.new() + no_bam.text = "Load BAM to enable depth plot." + host._track_settings_box.add_child(no_bam) + var height_label := Label.new() + height_label.text = "Track Height (px)" + var height_spin := SpinBox.new() + height_spin.min_value = host.MIN_PLOT_HEIGHT + height_spin.max_value = host.MAX_PLOT_HEIGHT + height_spin.step = 1 + height_spin.value = host._depth_plot_height + height_spin.value_changed.connect(func(value: float) -> void: + host._depth_plot_height = value + host._apply_depth_plot_height() + ) + host._track_settings_box.add_child(height_label) + host._track_settings_box.add_child(height_spin) + var legend_title := Label.new() + legend_title.text = "Depth Lines" + host._track_settings_box.add_child(legend_title) + if host._bam_tracks.is_empty(): + var legend_empty := Label.new() + legend_empty.text = "None" + host._track_settings_box.add_child(legend_empty) + return + for i in range(host._bam_tracks.size()): + var track: Dictionary = host._bam_tracks[i] + var track_id := str(track.get("track_id", "")) + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var swatch := ColorRect.new() + swatch.custom_minimum_size = Vector2(14, 14) + swatch.color = host._depth_plot_color_for_track(track_id) + var name_label := Label.new() + name_label.text = "BAM %d: %s" % [i + 1, str(track.get("label", track_id))] + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(swatch) + row.add_child(name_label) + host._track_settings_box.add_child(row) diff --git a/scripts/track_settings_controller.gd.uid b/scripts/track_settings_controller.gd.uid new file mode 100644 index 0000000..6ec53a9 --- /dev/null +++ b/scripts/track_settings_controller.gd.uid @@ -0,0 +1 @@ +uid://do3pkxqjoboc3 diff --git a/tests/godot/run_tests.gd b/tests/godot/run_tests.gd index 4c04b60..0de5d4c 100644 --- a/tests/godot/run_tests.gd +++ b/tests/godot/run_tests.gd @@ -2,7 +2,8 @@ extends SceneTree const TEST_SCRIPTS := [ "res://tests/godot/test_annotation_cache_controller.gd", - "res://tests/godot/test_local_zem_manager.gd" + "res://tests/godot/test_local_zem_manager.gd", + "res://tests/godot/test_track_settings_controller.gd" ] diff --git a/tests/godot/test_track_settings_controller.gd b/tests/godot/test_track_settings_controller.gd new file mode 100644 index 0000000..909d795 --- /dev/null +++ b/tests/godot/test_track_settings_controller.gd @@ -0,0 +1,226 @@ +extends "res://tests/godot/test_case.gd" + +const ReadTrackSettingsPanelScene = preload("res://scenes/ReadTrackSettingsPanel.tscn") +const TrackSettingsControllerScript = preload("res://scripts/track_settings_controller.gd") + +var host: FakeHost +var feature_content: VBoxContainer +var controller: RefCounted + + +class FakeHost: + extends Node + + const CONTEXT_PANEL_TRACK_SETTINGS := 2 + const TRACK_AA := "aa" + const TRACK_GC_PLOT := "gc_plot" + const TRACK_DEPTH_PLOT := "depth_plot" + const TRACK_VCF := "vcf" + const TRACK_GENOME := "genome" + const SEQ_VIEW_CONCAT := 0 + const SEQ_VIEW_SINGLE := 1 + const DEFAULT_READ_THICKNESS := 8.0 + const DEFAULT_READ_MAX_ROWS := 500 + const DEFAULT_READ_MIN_MAPQ := 0 + const DEFAULT_READ_HIDDEN_FLAGS := 256 | 512 | 1024 | 2048 + const ANNOT_MAX_ON_SCREEN_MIN := 200 + const ANNOT_MAX_ON_SCREEN_MAX := 50000 + const MIN_PLOT_HEIGHT := 50.0 + const MAX_PLOT_HEIGHT := 360.0 + const READ_FILTER_FLAG_LABELS := [ + {"bit": 1, "label": "paired"}, + {"bit": 2, "label": "proper pair"}, + {"bit": 8, "label": "mate unmapped"}, + {"bit": 16, "label": "reverse strand"} + ] + + var _track_settings_box: VBoxContainer + var _read_track_settings_panel: VBoxContainer + var _track_settings_open := false + var _active_track_settings_id := "" + var _feature_panel_open := false + var feature_name_label := Label.new() + var _bam_tracks: Array[Dictionary] = [ + { + "track_id": "reads:1", + "label": "sample.bam", + "view_mode": 0, + "fragment_log": true, + "thickness": 8.0, + "auto_expand_snp_text": true, + "show_soft_clips": false, + "show_pileup_logo": false, + "color_by_mate_contig": false, + "max_rows": 500, + "min_mapq": 0, + "hidden_flags": 0 + } + ] + var _show_full_length_regions := false + var _show_stop_codons := false + var _annotation_max_on_screen := 4400 + var _seq_view_mode := SEQ_VIEW_CONCAT + var _selected_seq_id := -1 + var _seq_option := OptionButton.new() + var _concat_gap_bp := 50 + var _axis_coords_with_commas := false + var _gc_window_bp := 200 + var _gc_plot_height := 100.0 + var _depth_plot_height := 100.0 + var _has_bam_loaded := true + var _variant_controller: RefCounted = null + var prepared_title := "" + var close_count := 0 + var save_count := 0 + var sound_count := 0 + var slide_count := 0 + var fetch_count := 0 + + func _track_label_for_id(track_id: String) -> String: + return "Reads" if track_id.begins_with("reads:") else track_id + + func _prepare_context_panel(_mode: int, title: String, _show_detail_labels: bool) -> void: + prepared_title = title + _track_settings_open = false + _active_track_settings_id = "" + + func _play_ui_sound(_sound_id: String) -> void: + sound_count += 1 + + func _close_feature_panel() -> void: + close_count += 1 + _feature_panel_open = false + + func _save_config() -> void: + save_count += 1 + + func _bam_track_for_id(track_id: String) -> Dictionary: + for track in _bam_tracks: + if str(track.get("track_id", "")) == track_id: + return track + return {} + + func _play_toggle_sound(_enabled: bool) -> void: + sound_count += 1 + + func _schedule_fetch() -> void: + fetch_count += 1 + + func _slide_feature_panel(_open: bool, _animated: bool) -> void: + slide_count += 1 + + func _on_active_read_track_view_selected(_index: int) -> void: + pass + + func _on_active_read_track_fragment_log_toggled(_enabled: bool) -> void: + pass + + func _on_active_read_track_thickness_changed(_value: float) -> void: + pass + + func _on_active_read_track_auto_expand_snp_toggled(_enabled: bool) -> void: + pass + + func _on_active_read_track_show_soft_clips_toggled(_enabled: bool) -> void: + pass + + func _on_active_read_track_show_pileup_logo_toggled(_enabled: bool) -> void: + pass + + func _on_active_read_track_mate_contig_color_toggled(_enabled: bool) -> void: + pass + + func _on_active_read_track_max_rows_changed(_value: float) -> void: + pass + + func _on_active_read_track_min_mapq_changed(_value: float) -> void: + pass + + func _on_show_full_region_toggled(_enabled: bool) -> void: + pass + + func _on_show_stop_codons_toggled(_enabled: bool) -> void: + pass + + func _on_annotation_max_on_screen_changed(_value: float) -> void: + pass + + func _on_axis_coords_commas_toggled(_enabled: bool) -> void: + pass + + func _on_seq_view_selected(_index: int) -> void: + pass + + func _on_seq_selected(_index: int) -> void: + pass + + func _on_concat_gap_changed(_value: float) -> void: + pass + + func _invalidate_cache() -> void: + pass + + func _apply_gc_plot_height() -> void: + pass + + func _apply_depth_plot_height() -> void: + pass + + func _depth_plot_color_for_track(_track_id: String) -> Color: + return Color.WHITE + + +func setup() -> void: + host = FakeHost.new() + host.add_child(host.feature_name_label) + host.add_child(host._seq_option) + feature_content = VBoxContainer.new() + host.add_child(feature_content) + controller = TrackSettingsControllerScript.new() + controller.configure(host) + controller.setup(feature_content, ReadTrackSettingsPanelScene) + + +func teardown() -> void: + host.free() + host = null + feature_content = null + controller = null + + +func test_setup_creates_read_track_panel() -> void: + assert_ne(host._track_settings_box, null) + assert_ne(host._read_track_settings_panel, null) + var view_option := host._read_track_settings_panel.get_node("ReadViewOption") as OptionButton + assert_eq(view_option.item_count, 4) + + +func test_show_read_track_settings_populates_controls() -> void: + controller.show_track_settings("reads:1") + + assert_true(host._track_settings_open) + assert_eq(host._active_track_settings_id, "reads:1") + assert_eq(host.prepared_title, "Reads track settings") + assert_true(host._read_track_settings_panel.visible) + var bam_label := host._read_track_settings_panel.get_node("BAMLabel") as Label + assert_eq(bam_label.text, "BAM: sample.bam") + var dynamic_options := host._read_track_settings_panel.get_node("DynamicOptions") as VBoxContainer + assert_true(dynamic_options.get_child_count() > 0) + + +func test_read_filter_toggle_updates_track_and_fetches() -> void: + controller.show_track_settings("reads:1") + var dynamic_options := host._read_track_settings_panel.get_node("DynamicOptions") as VBoxContainer + var paired_cb := dynamic_options.get_child(0) as CheckBox + + paired_cb.button_pressed = true + + assert_true((int(host._bam_tracks[0].get("hidden_flags", 0)) & 1) != 0) + assert_eq(host.fetch_count, 1) + + +func test_reopening_same_track_closes_panel() -> void: + controller.show_track_settings("reads:1") + controller.show_track_settings("reads:1") + + assert_eq(host.close_count, 1) diff --git a/tests/godot/test_track_settings_controller.gd.uid b/tests/godot/test_track_settings_controller.gd.uid new file mode 100644 index 0000000..eb00211 --- /dev/null +++ b/tests/godot/test_track_settings_controller.gd.uid @@ -0,0 +1 @@ +uid://dqavbhui15a02 From 22e03fd3b9d432068a6bb1c46aeece5a52c66714 Mon Sep 17 00:00:00 2001 From: Martin Hunt Date: Mon, 8 Jun 2026 18:08:11 +0100 Subject: [PATCH 2/2] Refresh reads after filter changes --- CHANGELOG.md | 1 + scripts/annotation_cache_controller.gd | 14 ++++++-- scripts/main.gd | 34 +++++++++++++++---- scripts/track_settings_controller.gd | 4 +-- .../godot/test_annotation_cache_controller.gd | 21 ++++++++++++ tests/godot/test_track_settings_controller.gd | 6 +++- 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a56bf12..5fb81c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Various changes to the whole genome alignment used in comparison mode. Improves speed, lower memory, and better handling of repeats and other edge cases ### Fixed +- Changing read filters now updates detailed reads immediately, without needing to pan or zoom first. - Old background requests are less likely to show stale errors after moving to a new region. - Detailed read view now fills in missing regions more reliably when navigating around. - Starting and closing the bundled seqhiker server is more reliable, especially while background work is still running. diff --git a/scripts/annotation_cache_controller.gd b/scripts/annotation_cache_controller.gd index e30f185..bb8ac27 100644 --- a/scripts/annotation_cache_controller.gd +++ b/scripts/annotation_cache_controller.gd @@ -165,7 +165,7 @@ func prefetch_visible_target(start_bp: int, end_bp: int, bp_per_px: float) -> vo func update_detailed_read_strips(start_bp: int, end_bp: int, bp_per_px: float) -> void: if not detailed_read_strips_enabled(bp_per_px): - _reset_read_strips() + invalidate_read_strips() return _ensure_read_strip_scope(bp_per_px) if _strip_range_fully_covered(start_bp, end_bp): @@ -187,6 +187,16 @@ func cancel_visible_requests() -> void: _latest_visible_serial = -1 +func invalidate_read_strips() -> void: + _strip_segments.clear() + _strip_zoom = -1 + _strip_scope_key = "" + _strip_generation = -1 + _strip_left_pending = false + _strip_right_pending = false + _strip_pending_requests.clear() + + func cancel_all_requests() -> void: cancel_visible_requests() _strip_pending_requests.clear() @@ -220,7 +230,7 @@ func _ensure_read_strip_scope(bp_per_px: float) -> void: var zoom: int = int(host._compute_tile_zoom(bp_per_px)) var scope_key: String = str(host._scope_cache_key()) if zoom != _strip_zoom or scope_key != _strip_scope_key or host._tile_cache_generation != _strip_generation: - _reset_read_strips() + invalidate_read_strips() _strip_zoom = zoom _strip_scope_key = scope_key _strip_generation = host._tile_cache_generation diff --git a/scripts/main.gd b/scripts/main.gd index 1181521..3f5ce59 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -1849,19 +1849,31 @@ func _update_window_min_height() -> void: if w != null: w.min_size.y = maxi(200, ceili(min_h)) -func _update_active_bam_track(mutator: Callable) -> void: +func _mutate_active_bam_track(mutator: Callable) -> bool: if not _active_track_settings_id.begins_with("reads:"): - return + return false for i in range(_bam_tracks.size()): var t: Dictionary = _bam_tracks[i] if str(t.get("track_id", "")) != _active_track_settings_id: continue mutator.call(t) _bam_tracks[i] = t - if _annotation_cache_controller.detailed_read_strips_enabled(_last_bp_per_px): - _annotation_cache_controller.apply_detailed_read_span(_last_start, _last_end, _last_bp_per_px) - _schedule_fetch() + return true + return false + + +func _update_active_bam_track(mutator: Callable) -> void: + if not _mutate_active_bam_track(mutator): + return + if _annotation_cache_controller.detailed_read_strips_enabled(_last_bp_per_px): + _annotation_cache_controller.apply_detailed_read_span(_last_start, _last_end, _last_bp_per_px) + _schedule_fetch() + + +func _update_active_bam_filter(mutator: Callable) -> void: + if not _mutate_active_bam_track(mutator): return + _on_read_track_filter_changed() func _on_active_read_track_view_selected(index: int) -> void: if _read_track_settings_panel == null: @@ -1914,7 +1926,7 @@ func _on_active_read_track_max_rows_changed(value: float) -> void: ) func _on_active_read_track_min_mapq_changed(value: float) -> void: - _update_active_bam_track(func(t: Dictionary) -> void: + _update_active_bam_filter(func(t: Dictionary) -> void: t["min_mapq"] = clampi(int(round(value)), 0, 255) ) @@ -2968,6 +2980,16 @@ func _annotation_min_feature_len_bp() -> int: func _schedule_fetch() -> void: _annotation_cache_controller.schedule_fetch() + +func _on_read_track_filter_changed() -> void: + _invalidate_viewport_cache() + if _annotation_cache_controller != null: + _annotation_cache_controller.invalidate_read_strips() + if _annotation_cache_controller.detailed_read_strips_enabled(_last_bp_per_px): + _annotation_cache_controller.update_detailed_read_strips(_last_start, _last_end, _last_bp_per_px) + _schedule_fetch() + + func _on_fetch_timer_timeout() -> void: _annotation_cache_controller.on_fetch_timer_timeout() diff --git a/scripts/track_settings_controller.gd b/scripts/track_settings_controller.gd index 53ebff8..718114e 100644 --- a/scripts/track_settings_controller.gd +++ b/scripts/track_settings_controller.gd @@ -161,7 +161,7 @@ func _add_read_filter_options(track_id: String, track_meta: Dictionary, dynamic_ flag_cb.toggled.connect(func(enabled: bool) -> void: host._play_toggle_sound(enabled) _set_read_track_hidden_flag(track_id, flag_bit, enabled) - host._schedule_fetch() + host._on_read_track_filter_changed() ) dynamic_options.add_child(flag_cb) if flag_bit == 2: @@ -179,7 +179,7 @@ func _add_read_filter_bool_option(dynamic_options: VBoxContainer, track_id: Stri cb.toggled.connect(func(enabled: bool) -> void: host._play_toggle_sound(enabled) _set_read_track_value(track_id, key, enabled) - host._schedule_fetch() + host._on_read_track_filter_changed() ) dynamic_options.add_child(cb) diff --git a/tests/godot/test_annotation_cache_controller.gd b/tests/godot/test_annotation_cache_controller.gd index 18bec3d..e9ddfaa 100644 --- a/tests/godot/test_annotation_cache_controller.gd +++ b/tests/godot/test_annotation_cache_controller.gd @@ -229,6 +229,27 @@ func test_missing_read_strip_request_fills_internal_gap() -> void: assert_eq(str(request.get("request_kind", "")), "read_strip") +func test_invalidate_read_strips_clears_cached_reads_without_visible_requests() -> void: + controller._strip_zoom = 1 + controller._strip_scope_key = "scope" + controller._strip_generation = 0 + controller._strip_segments = _strip_segments([ + {"start_bp": 0, "end_bp": 300} + ]) + controller._strip_pending_requests[4] = {"side": "right"} + controller._strip_right_pending = true + controller._visible_pending_requests[2] = {"serial": 2} + controller._latest_visible_serial = 2 + + controller.invalidate_read_strips() + + assert_false(controller.detailed_read_target_ready(100, 200, 1.0)) + assert_eq(controller._strip_pending_requests.size(), 0) + assert_false(controller._strip_right_pending) + assert_true(controller._visible_pending_requests.has(2)) + assert_eq(controller._latest_visible_serial, 2) + + func test_stale_visible_error_is_ignored() -> void: controller._visible_pending_requests[2] = {"serial": 2, "query_start": 0, "query_end": 100} controller._latest_visible_serial = 3 diff --git a/tests/godot/test_track_settings_controller.gd b/tests/godot/test_track_settings_controller.gd index 909d795..87854a9 100644 --- a/tests/godot/test_track_settings_controller.gd +++ b/tests/godot/test_track_settings_controller.gd @@ -75,6 +75,7 @@ class FakeHost: var sound_count := 0 var slide_count := 0 var fetch_count := 0 + var filter_change_count := 0 func _track_label_for_id(track_id: String) -> String: return "Reads" if track_id.begins_with("reads:") else track_id @@ -106,6 +107,9 @@ class FakeHost: func _schedule_fetch() -> void: fetch_count += 1 + func _on_read_track_filter_changed() -> void: + filter_change_count += 1 + func _slide_feature_panel(_open: bool, _animated: bool) -> void: slide_count += 1 @@ -216,7 +220,7 @@ func test_read_filter_toggle_updates_track_and_fetches() -> void: paired_cb.button_pressed = true assert_true((int(host._bam_tracks[0].get("hidden_flags", 0)) & 1) != 0) - assert_eq(host.fetch_count, 1) + assert_eq(host.filter_change_count, 1) func test_reopening_same_track_closes_panel() -> void: