@tool class_name SimulationTimeline extends VBoxContainer signal pause_requested signal resume_requested signal step_requested( seconds: float ) signal reset_requested signal sequence_selected( index: int ) signal run_sequence_requested signal stop_sequence_requested signal replay_sequence_requested var sequence_select: OptionButton var run_button: Button var stop_button: Button var replay_button: Button var tree: Tree var clear_button: Button var count_label: Label var _first_trace_time: float = -1.0 var _entry_count: int = 0 var pause_button: Button var step_button: Button var reset_button: Button var _paused: bool = false func _ready() -> void: if tree != null: return _build_ui() func _build_ui() -> void: var toolbar := HBoxContainer.new() add_child( toolbar ) var title := Label.new() title.text = "Simulation Timeline" title.size_flags_horizontal = ( Control.SIZE_EXPAND_FILL ) toolbar.add_child( title ) count_label = Label.new() count_label.text = "0 events" toolbar.add_child( count_label ) sequence_select = OptionButton.new() sequence_select.custom_minimum_size.x = 180.0 sequence_select.item_selected.connect( func(index: int): sequence_selected.emit( index ) ) toolbar.add_child( sequence_select ) run_button = Button.new() run_button.text = "Run" run_button.pressed.connect( func(): run_sequence_requested.emit() ) toolbar.add_child( run_button ) stop_button = Button.new() stop_button.text = "Stop" stop_button.disabled = true stop_button.pressed.connect( func(): stop_sequence_requested.emit() ) toolbar.add_child( stop_button ) replay_button = Button.new() replay_button.text = "Replay" replay_button.pressed.connect( func(): replay_sequence_requested.emit() ) toolbar.add_child( replay_button ) pause_button = Button.new() pause_button.text = "Pause" pause_button.pressed.connect( _on_pause_button_pressed ) toolbar.add_child( pause_button ) step_button = Button.new() step_button.text = "Step 0.1s" step_button.pressed.connect( func(): step_requested.emit( 0.1 ) ) toolbar.add_child( step_button ) reset_button = Button.new() reset_button.text = "Reset" reset_button.pressed.connect( func(): reset_requested.emit() ) toolbar.add_child( reset_button ) clear_button = Button.new() clear_button.text = "Clear" clear_button.pressed.connect( clear ) toolbar.add_child( clear_button ) tree = Tree.new() tree.size_flags_horizontal = ( Control.SIZE_EXPAND_FILL ) tree.size_flags_vertical = ( Control.SIZE_EXPAND_FILL ) tree.custom_minimum_size.y = 180.0 tree.columns = 4 tree.set_column_title( 0, "Time" ) tree.set_column_title( 1, "Type" ) tree.set_column_title( 2, "Source / Target" ) tree.set_column_title( 3, "Detail" ) tree.column_titles_visible = true tree.set_column_expand( 0, false ) tree.set_column_custom_minimum_width( 0, 80 ) tree.set_column_expand( 1, false ) tree.set_column_custom_minimum_width( 1, 90 ) tree.set_column_expand( 2, true ) tree.set_column_expand( 3, true ) add_child( tree ) tree.create_item() set_paused( false ) func clear() -> void: _first_trace_time = -1.0 _entry_count = 0 if count_label != null: count_label.text = "0 events" if tree == null: return tree.clear() tree.create_item() func add_trace_entry( entry: Dictionary ) -> void: if tree == null: _build_ui() var absolute_time := float( entry.get( "time", 0.0 ) ) if _first_trace_time < 0.0: _first_trace_time = absolute_time var relative_time := ( absolute_time - _first_trace_time ) var root := tree.get_root() if root == null: root = tree.create_item() var item := tree.create_item( root ) item.set_text( 0, "%.3f" % relative_time ) var entry_type := StringName( entry.get( "type", &"unknown" ) ) item.set_text( 1, String(entry_type).to_upper() ) item.set_text( 2, _get_subject_text( entry ) ) item.set_text( 3, _get_detail_text( entry ) ) _entry_count += 1 if count_label != null: count_label.text = ( "%d events" % _entry_count ) tree.scroll_to_item( item ) func _get_subject_text( entry: Dictionary ) -> String: var entry_type := StringName( entry.get( "type", &"" ) ) match entry_type: &"event": return String( entry.get( "source_id", &"" ) ) &"rule": return String( entry.get( "rule_id", &"" ) ) &"action": return String( entry.get( "target_id", &"" ) ) &"gpio": return String( entry.get( "cabinet_id", &"" ) ) &"device": return String( entry.get( "device_id", &"" ) ) &"input": return String( entry.get( "source_id", &"" ) ) &"sequence": return String( entry.get( "display_name", entry.get( "sequence_id", &"" ) ) ) _: return "" func _get_detail_text( entry: Dictionary ) -> String: var entry_type := StringName( entry.get( "type", &"" ) ) match entry_type: &"event": return String( entry.get( "event_id", &"" ) ) &"rule": var target_id := String( entry.get( "target_id", &"" ) ) var action_id := String( entry.get( "action_id", &"" ) ) var delay := float( entry.get( "delay_seconds", 0.0 ) ) if delay > 0.0: return ( "→ %s.%s after %.3fs" % [ target_id, action_id, delay ] ) return ( "→ %s.%s" % [ target_id, action_id ] ) &"action": return String( entry.get( "action_id", &"" ) ) &"gpio": var pin := int( entry.get( "gpio_pin", -1 ) ) var electrical_state := bool( entry.get( "electrical_state", false ) ) var action_id := String( entry.get( "action_id", &"" ) ) var level := "LOW" if electrical_state: level = "HIGH" return ( "GPIO %d %s (%s)" % [ pin, level, action_id ] ) &"device": var active := bool( entry.get( "active", false ) ) if active: return "ACTIVE" return "INACTIVE" &"input": var event_id := String( entry.get( "event_id", &"" ) ) var note := String( entry.get( "note", "" ) ) if not note.is_empty(): return ( "%s — %s" % [ event_id, note ] ) return event_id &"sequence": var state := String( entry.get( "state", &"" ) ) return state.to_upper() _: return str( entry ) func _on_pause_button_pressed() -> void: if _paused: resume_requested.emit() else: pause_requested.emit() func set_paused( value: bool ) -> void: _paused = value if pause_button != null: if _paused: pause_button.text = "Resume" else: pause_button.text = "Pause" if step_button != null: step_button.disabled = not _paused func set_sequences( sequences: Array[TestSequence] ) -> void: if sequence_select == null: return sequence_select.clear() for sequence in sequences: if sequence == null: continue var label := ( sequence.display_name ) if label.is_empty(): label = String( sequence.sequence_id ) sequence_select.add_item( label ) if sequence_select.item_count > 0: sequence_select.select( 0 ) func set_sequence_running( running: bool ) -> void: if run_button != null: run_button.disabled = running if stop_button != null: stop_button.disabled = not running if replay_button != null: replay_button.disabled = running if sequence_select != null: sequence_select.disabled = running