# (c) 2023-present Eroax # (c) 2023-present Yagich # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) extends Control class_name DeckHolderRenderer ## Renderer class for [DeckHolder] ## ## Entry point for the [GraphEdit] based Renderer ## Reference to the base scene for [DeckRendererGraphEdit] @export var DECK_SCENE: PackedScene @export var DEBUG_DECKS_LIST: PackedScene @export var DEBUG_NODES_LIST: PackedScene const PERSISTENCE_NAMESPACE := "default" ## Reference to the main windows [TabContainerCustom] @onready var tab_container := %TabContainerCustom as TabContainerCustom ## Reference to the [FileDialog] used for File operations through the program. @onready var file_dialog: FileDialog = $FileDialog ## Enum for storing the Options in the "File" PopupMenu. enum FileMenuId { NEW, OPEN, SAVE = 3, SAVE_AS, CLOSE = 6, RECENTS, } @onready var file_popup_menu: PopupMenu = %File as PopupMenu var max_recents := 4 var recent_files := [] var recent_path: String @onready var unsaved_changes_dialog_single_deck := $UnsavedChangesDialogSingleDeck as UnsavedChangesDialogSingleDeck @onready var unsaved_changes_dialog: ConfirmationDialog = $UnsavedChangesDialog enum ConnectionsMenuId { OBS, TWITCH, RPC, } enum DebugMenuId { DECKS, NODES, EMBED_SUBWINDOWS, } @onready var debug_popup_menu: PopupMenu = %Debug enum HelpMenuId { DOCS, ABOUT, } @onready var about_dialog: AcceptDialog = %AboutDialog enum EditMenuId { COPY, PASTE, DUPLICATE, SETTINGS, } @onready var edit_popup_menu: PopupMenu = %Edit @onready var settings_dialog: SettingsDialog = %SettingsDialog ## Weak Reference to the Deck that is currently going to be saved. var _deck_to_save: WeakRef @onready var no_obsws := %NoOBSWS as NoOBSWS @onready var obs_setup_dialog := $OBSWebsocketSetupDialog as OBSWebsocketSetupDialog @onready var twitch_setup_dialog := $Twitch_Setup_Dialog as TwitchSetupDialog @onready var rpc_setup_dialog := $RPCSetupDialog as RPCSetupDialog @onready var bottom_dock: BottomDock = %BottomDock @onready var sidebar_split: HSplitContainer = %SidebarSplit @onready var sidebar: Sidebar = %Sidebar as Sidebar signal quit_completed() signal rpc_start_requested(port: int) signal rpc_stop_requested() func _ready() -> void: tab_container.add_button_pressed.connect(add_empty_deck) tab_container.tab_changed.connect(_on_tab_container_tab_changed) tab_container.tab_about_to_change.connect(_on_tab_container_tab_about_to_change) RendererPersistence.init_namespace(PERSISTENCE_NAMESPACE) var embed_subwindows: bool = RendererPersistence.get_or_create(PERSISTENCE_NAMESPACE, "config", "embed_subwindows", true) debug_popup_menu.set_item_checked( DebugMenuId.EMBED_SUBWINDOWS, embed_subwindows ) get_tree().get_root().gui_embed_subwindows = embed_subwindows file_dialog.use_native_dialog = not embed_subwindows recent_files = RendererPersistence.get_or_create( PERSISTENCE_NAMESPACE, "config", "recent_files", [] ) recent_path = RendererPersistence.get_or_create( PERSISTENCE_NAMESPACE, "config", "recent_path", OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS) ) RendererShortcuts.load_overrides() reset_popup_menu_shortcuts() add_recents_to_menu() var rpc_port: int = RendererPersistence.get_or_create( PERSISTENCE_NAMESPACE, "config", "rpc_server_port", 6907 ) rpc_setup_dialog.set_port(rpc_port) rpc_setup_dialog.start_requested.connect( func(port: int): rpc_start_requested.emit(port) RendererPersistence.set_value( PERSISTENCE_NAMESPACE, "config", "rpc_server_port", port ) ) rpc_setup_dialog.stop_requested.connect( func(): rpc_stop_requested.emit() ) rpc_setup_dialog.confirmed.connect( func(): RendererPersistence.set_value( PERSISTENCE_NAMESPACE, "config", "rpc_server_port", rpc_setup_dialog.get_port() ) ) tab_container.tab_close_requested.connect( func(tab: int): if tab_container.get_tab_metadata(tab, "dirty") and not tab_container.get_tab_metadata(tab, "group"): unsaved_changes_dialog_single_deck.set_meta("tab", tab) unsaved_changes_dialog_single_deck.show() return await close_tab(tab) ) file_dialog.canceled.connect(disconnect_file_dialog_signals) Connections.obs_websocket = no_obsws Connections.twitch = %Twitch_Connection no_obsws.event_received.connect( func(m: NoOBSWS.Message): Connections._obs_event_received(m.get_data()) ) Connections.twitch.chat_received_rich.connect(Connections._twitch_chat_received) bottom_dock.variable_viewer.top_field_edited.connect( func(old_name: String, new_name: String, new_value: Variant) -> void: get_active_deck_renderer().dirty = true get_active_deck().update_variable(old_name, new_name, new_value) ) bottom_dock.variable_viewer.top_field_removed.connect( func(field_name: String) -> void: get_active_deck_renderer().dirty = true get_active_deck().remove_variable(field_name) ) sidebar_split.dragger_visibility = SplitContainer.DRAGGER_HIDDEN_COLLAPSED sidebar.go_to_node_requested.connect( func(node_id: String) -> void: # we can reasonably assume its the current deck var deck := get_active_deck() var node := deck.get_node(node_id) var deck_renderer := get_active_deck_renderer() deck_renderer.focus_node(deck_renderer.get_node_renderer(node)) ) func reset_popup_menu_shortcuts() -> void: file_popup_menu.set_item_shortcut(FileMenuId.NEW, RendererShortcuts.get_shortcut("new_deck")) file_popup_menu.set_item_shortcut(FileMenuId.OPEN, RendererShortcuts.get_shortcut("open_deck")) file_popup_menu.set_item_shortcut(FileMenuId.SAVE, RendererShortcuts.get_shortcut("save_deck")) file_popup_menu.set_item_shortcut(FileMenuId.SAVE_AS, RendererShortcuts.get_shortcut("save_deck_as")) file_popup_menu.set_item_shortcut(FileMenuId.CLOSE, RendererShortcuts.get_shortcut("close_deck")) edit_popup_menu.set_item_shortcut(EditMenuId.COPY, RendererShortcuts.get_shortcut("copy_nodes")) edit_popup_menu.set_item_shortcut(EditMenuId.PASTE, RendererShortcuts.get_shortcut("paste_nodes")) edit_popup_menu.set_item_shortcut(EditMenuId.DUPLICATE, RendererShortcuts.get_shortcut("duplicate_nodes")) edit_popup_menu.set_item_shortcut(EditMenuId.SETTINGS, RendererShortcuts.get_shortcut("settings")) func reset_recents_shortcuts() -> void: for i in recent_files.size(): var s := RendererShortcuts.get_shortcut("open_recent_deck_%s" % [i + 1]) file_popup_menu.set_item_shortcut(file_popup_menu.get_item_count() - recent_files.size() + i, s) func _notification(what: int) -> void: if what == RendererShortcuts.NOTIFICATION_SHORTCUTS_UPDATED: reset_popup_menu_shortcuts() func _on_tab_container_tab_about_to_change(previous_tab: int) -> void: var deck := get_deck_at_tab(previous_tab) Util.safe_disconnect(deck.variables_updated, bottom_dock.rebuild_variable_tree) var deck_renderer := get_deck_renderer_at_tab(previous_tab) Util.safe_disconnect(deck_renderer.node_selected, set_sidebar_node) Util.safe_disconnect(deck_renderer.node_deselected, set_sidebar_node) Util.pop_batch(&"sidebar_signals") func _on_tab_container_tab_changed(tab: int) -> void: var is_group = tab_container.get_tab_metadata(tab, "group", false) file_popup_menu.set_item_disabled(FileMenuId.SAVE, is_group) file_popup_menu.set_item_disabled(FileMenuId.SAVE_AS, is_group) bottom_dock.rebuild_variable_tree(get_active_deck().variable_stack) var deck := get_active_deck() deck.variables_updated.connect(bottom_dock.rebuild_variable_tree.bind(deck.variable_stack)) sidebar.set_edited_deck(deck.id) get_active_deck_renderer().node_selected.connect(set_sidebar_node) get_active_deck_renderer().node_deselected.connect(set_sidebar_node) sidebar.refresh_node_list() var batch := Util.batch_begin() batch.add(deck.node_added, refresh_sidebar_node_list) batch.add(deck.node_removed, refresh_sidebar_node_list) Util.push_batch(batch, &"sidebar_signals") func set_sidebar_node(_node: Node) -> void: var count = get_active_deck_renderer().get_selected_nodes().size() if count != 1: sidebar.set_edited_node() return var dnode := (get_active_deck_renderer().get_selected_nodes()[0] as DeckNodeRendererGraphNode).node sidebar.set_edited_node(dnode._id) func refresh_sidebar_node_list(_unused1 = null, _unused2 = null, _unused3 = null) -> void: sidebar.refresh_node_list() ## Called when the File button in the [MenuBar] is pressed with the [param id] ## of the button within it that was pressed. func _on_file_id_pressed(id: int) -> void: match id: FileMenuId.NEW: add_empty_deck() FileMenuId.OPEN: open_open_dialog(recent_path) FileMenuId.SAVE when tab_container.get_tab_count() > 0: save_active_deck() FileMenuId.SAVE_AS when tab_container.get_tab_count() > 0: open_save_dialog(tab_container.get_tab_metadata(tab_container.get_current_tab(), "path")) FileMenuId.CLOSE: close_current_tab() _ when id in range(FileMenuId.RECENTS, FileMenuId.RECENTS + max_recents + 1): open_deck_at_path(recent_files[id - FileMenuId.RECENTS - 1]) func _on_edit_id_pressed(id: int) -> void: match id: EditMenuId.SETTINGS: settings_dialog.popup_centered() EditMenuId.COPY: var r := get_active_deck_renderer() r._on_copy_nodes_request() EditMenuId.PASTE: var r := get_active_deck_renderer() r._on_paste_nodes_request() EditMenuId.DUPLICATE: var r := get_active_deck_renderer() r._on_duplicate_nodes_request() func _on_edit_about_to_popup() -> void: # enable/disable the copy/paste buttons depending on if there's a deck renderer and if it has nodes selected if tab_container.get_tab_count() == 0: edit_popup_menu.set_item_disabled(EditMenuId.COPY, true) edit_popup_menu.set_item_disabled(EditMenuId.PASTE, true) edit_popup_menu.set_item_disabled(EditMenuId.DUPLICATE, true) return edit_popup_menu.set_item_disabled(EditMenuId.PASTE, false) var r := get_active_deck_renderer() edit_popup_menu.set_item_disabled(EditMenuId.COPY, r.get_selected_nodes().size() == 0) edit_popup_menu.set_item_disabled(EditMenuId.DUPLICATE, r.get_selected_nodes().size() == 0) ## Adds an empty [DeckRendererGraphEdit] with a corresponding [Deck] for it's data. func add_empty_deck() -> void: var deck := DeckHolder.add_empty_deck() var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() inst.deck = deck var tab := tab_container.add_content(inst, "") tab_container.set_tab_metadata(tab, "id", deck.id) tab_container.set_tab_metadata(tab, "group", false) tab_container.set_tab_metadata(tab, "path", recent_path.path_join("")) inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) inst.dirty_state_changed.connect(_on_deck_renderer_dirty_state_changed.bind(inst)) tab_container.set_current_tab(tab) bottom_dock.variable_viewer.enable_new_button() ## Closes the current tab in [member tab_container] func close_current_tab() -> void: tab_container.close_tab(tab_container.get_current_tab()) func close_tab(tab: int) -> void: if not tab_container.get_tab_metadata(tab, "group"): var groups := DeckHolder.close_deck(tab_container.get_tab_metadata(tab, "id")) # close tabs associated with this deck's groups for group in groups: for c_tab in range(tab_container.get_tab_count() - 1, -1, -1): if tab_container.get_tab_metadata(c_tab, "id") == group: tab_container.close_tab(c_tab) await get_tree().process_frame tab_container.close_tab(tab) if tab_container.get_tab_count() == 0: bottom_dock.variable_viewer.disable_new_button() ## Opens [member file_dialog] with the mode [member FileDialog.FILE_MODE_SAVE_FILE] ## as well as getting a weakref to the active [Deck] func open_save_dialog(path: String) -> void: file_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE file_dialog.title = "Save a Deck" file_dialog.current_path = path _deck_to_save = weakref(get_active_deck()) file_dialog.popup_centered() file_dialog.file_selected.connect(_on_file_dialog_save_file, CONNECT_ONE_SHOT) ## Opens [member file_dialog] with the mode [FileDialog.FILE_MODE_OPEN_FILES] ## with the supplied [param path] func open_open_dialog(path: String) -> void: file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILES file_dialog.title = "Open Deck(s)" file_dialog.current_path = path + "/" file_dialog.popup_centered() file_dialog.files_selected.connect(_on_file_dialog_open_files, CONNECT_ONE_SHOT) ## Connected to [signal FileDialog.save_file] on [member file_dialog]. ## Saves the selected [Deck] if it still exists. func _on_file_dialog_save_file(path: String) -> void: var deck: Deck = _deck_to_save.get_ref() as Deck if not deck: return deck.save_path = path tab_container.set_tab_title(tab_container.get_current_tab(), path.get_file()) var renderer: DeckRendererGraphEdit = tab_container.get_content(tab_container.get_current_tab()) as DeckRendererGraphEdit renderer.dirty = false # TODO: put this into DeckHolder instead var json := JSON.stringify(deck.to_dict(), "\t") var f := FileAccess.open(path, FileAccess.WRITE) f.store_string(json) add_recent_file(get_active_deck().save_path) recent_path = path.get_base_dir() RendererPersistence.set_value(PERSISTENCE_NAMESPACE, "config", "recent_path", recent_path) ## Connected to [signal FileDialog.open_files] on [member file_dialog]. Opens ## the selected paths, instantiating [DeckRenderGraphEdit]s and [Deck]s for each. func _on_file_dialog_open_files(paths: PackedStringArray) -> void: for path in paths: open_deck_at_path(path) func open_deck_at_path(path: String) -> void: for tab in tab_container.get_tab_count(): if tab_container.get_tab_metadata(tab, "path") == path: tab_container.set_current_tab(tab) return var deck := DeckHolder.open_deck_from_file(path) var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() inst.deck = deck var tab := tab_container.add_content(inst, path.get_file()) tab_container.set_tab_metadata(tab, "id", deck.id) tab_container.set_tab_metadata(tab, "group", false) tab_container.set_tab_metadata(tab, "path", path) inst.initialize_from_deck() inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) inst.dirty_state_changed.connect(_on_deck_renderer_dirty_state_changed.bind(inst)) add_recent_file(path) recent_path = path.get_base_dir() RendererPersistence.set_value(PERSISTENCE_NAMESPACE, "config", "recent_path", recent_path) tab_container.set_current_tab(tab) bottom_dock.variable_viewer.enable_new_button() ## Returns the current deck in the [member tab_container]. func get_active_deck() -> Deck: return get_deck_at_tab(tab_container.get_current_tab()) ## Returns the deck at [param tab] in the [member tab_container]. func get_deck_at_tab(tab: int) -> Deck: return get_deck_renderer_at_tab(tab).deck ## Returns the current deck renderer in the [member tab_container]. func get_active_deck_renderer() -> DeckRendererGraphEdit: return get_deck_renderer_at_tab(tab_container.get_current_tab()) ## Returns the deck renderer at [param tab] in the [member tab_container]. func get_deck_renderer_at_tab(tab: int) -> DeckRendererGraphEdit: if tab_container.is_empty(): return null return tab_container.get_content(tab) as DeckRendererGraphEdit ## Saves the active [Deck] in [member tab_container] func save_active_deck() -> void: save_tab(tab_container.get_current_tab()) func save_tab(tab: int) -> void: if tab_container.get_tab_metadata(tab, "group"): return var renderer := tab_container.get_content(tab) as DeckRendererGraphEdit var deck := renderer.deck if deck.save_path.is_empty(): open_save_dialog("res://") else: var json := JSON.stringify(deck.to_dict(), "\t") var f := FileAccess.open(deck.save_path, FileAccess.WRITE) f.store_string(json) add_recent_file(deck.save_path) renderer.dirty = false ## Disconnects the [FileDialog] signals if they are already connected. func disconnect_file_dialog_signals() -> void: if file_dialog.file_selected.is_connected(_on_file_dialog_save_file): file_dialog.file_selected.disconnect(_on_file_dialog_save_file) if file_dialog.files_selected.is_connected(_on_file_dialog_open_files): file_dialog.files_selected.disconnect(_on_file_dialog_open_files) ## Connected to [signal DeckRenderGraphEdit.group_entered_request] to allow entering ## groups based off the given [param group_id] and [param deck]. As well as adding ## a corresponding tab to [member tab_container] func _on_deck_renderer_group_enter_requested(group_id: String) -> void: #var group_deck := deck.get_group(group_id) for tab in tab_container.get_tab_count(): if tab_container.get_tab_metadata(tab, "id") == group_id: tab_container.set_current_tab(tab) return var group_deck := DeckHolder.get_deck(group_id) var deck_renderer: DeckRendererGraphEdit = DECK_SCENE.instantiate() deck_renderer.deck = group_deck deck_renderer.initialize_from_deck() var tab := tab_container.add_content(deck_renderer, "(g) %s" % group_id.left(8)) tab_container.set_tab_metadata(tab, "id", group_id) tab_container.set_tab_metadata(tab, "group", true) deck_renderer.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) tab_container.set_current_tab(tab) func _on_connections_id_pressed(id: int) -> void: match id: ConnectionsMenuId.OBS: obs_setup_dialog.popup_centered() ConnectionsMenuId.TWITCH: twitch_setup_dialog.popup_centered() ConnectionsMenuId.RPC: rpc_setup_dialog.popup_centered() func _on_obs_websocket_setup_dialog_connect_button_pressed(state: OBSWebsocketSetupDialog.ConnectionState) -> void: match state: OBSWebsocketSetupDialog.ConnectionState.DISCONNECTED: obs_setup_dialog.set_button_state(OBSWebsocketSetupDialog.ConnectionState.CONNECTING) no_obsws.subscriptions = obs_setup_dialog.get_subscriptions() no_obsws.connect_to_obsws(obs_setup_dialog.get_port(), obs_setup_dialog.get_password()) await no_obsws.connection_ready obs_setup_dialog.set_button_state(OBSWebsocketSetupDialog.ConnectionState.CONNECTED) OBSWebsocketSetupDialog.ConnectionState.CONNECTED: no_obsws.disconnect_from_obsws() obs_setup_dialog.set_button_state(OBSWebsocketSetupDialog.ConnectionState.DISCONNECTED) func _process(delta: float) -> void: DeckHolder.send_event(&"process", {"delta": delta}) func _on_debug_id_pressed(id: int) -> void: match id: DebugMenuId.DECKS: var d := AcceptDialog.new() var debug_decks: DebugDecksList = DEBUG_DECKS_LIST.instantiate() d.add_child(debug_decks) d.canceled.connect(d.queue_free) d.confirmed.connect(d.queue_free) debug_decks.item_pressed.connect(_on_debug_decks_viewer_item_pressed) add_child(d) d.popup_centered() DebugMenuId.NODES: var d := AcceptDialog.new() var debug_nodes: DebugNodesList = DEBUG_NODES_LIST.instantiate() d.add_child(debug_nodes) d.canceled.connect(d.queue_free) d.confirmed.connect(d.queue_free) debug_nodes.build(get_active_deck()) add_child(d) d.popup_centered() DebugMenuId.EMBED_SUBWINDOWS: var c := debug_popup_menu.is_item_checked(id) debug_popup_menu.set_item_checked(id, not c) get_tree().get_root().gui_embed_subwindows = not c RendererPersistence.set_value(PERSISTENCE_NAMESPACE, "config", "embed_subwindows", not c) file_dialog.use_native_dialog = c func _on_debug_decks_viewer_item_pressed(deck_id: String, instance_id: String) -> void: if instance_id == "": var deck := DeckHolder.get_deck(deck_id) var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() inst.deck = deck var tab := tab_container.add_content(inst, "" % [deck_id.left(8)]) tab_container.set_tab_metadata(tab, "id", deck.id) inst.initialize_from_deck() inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) tab_container.set_current_tab(tab) else: var deck := DeckHolder.get_group_instance(deck_id, instance_id) var inst: DeckRendererGraphEdit = DECK_SCENE.instantiate() inst.deck = deck var tab := tab_container.add_content(inst, "" % [deck_id.left(8), instance_id.left(8)]) tab_container.set_tab_metadata(tab, "id", deck.id) inst.initialize_from_deck() inst.group_enter_requested.connect(_on_deck_renderer_group_enter_requested) tab_container.set_current_tab(tab) func _on_deck_renderer_dirty_state_changed(renderer: DeckRendererGraphEdit) -> void: var idx: int = range(tab_container.get_tab_count()).filter( func(x: int): return tab_container.get_content(x) == renderer )[0] var title := tab_container.get_tab_title(idx).trim_suffix("(*)") if renderer.dirty: tab_container.set_tab_title(idx, "%s(*)" % title) else: tab_container.set_tab_title(idx, title.trim_suffix("(*)")) tab_container.set_tab_metadata(idx, "dirty", renderer.dirty) func add_recent_file(path: String) -> void: var item := recent_files.find(path) if item == -1: recent_files.push_front(path) else: recent_files.push_front(recent_files.pop_at(item)) recent_files = recent_files.slice(0, max_recents) add_recents_to_menu() func add_recents_to_menu() -> void: if recent_files.is_empty(): return if file_popup_menu.get_item_count() > FileMenuId.RECENTS + 1: var end := file_popup_menu.get_item_count() - FileMenuId.RECENTS - 1 for i in end: file_popup_menu.remove_item(file_popup_menu.get_item_count() - 1) var reduce_length := recent_files.any(func(x: String): return x.length() > 35) for i in recent_files.size(): var file = recent_files[i] as String if reduce_length: # shorten the basepath to be the first letter of all folders var base: String = Array( file.get_base_dir().split("/", false) ).reduce( func(a: String, s: String): return a + s[0] + "/", "/") var filename := file.get_file() file = base.path_join(filename) file_popup_menu.add_item(file) reset_recents_shortcuts() RendererPersistence.set_value(PERSISTENCE_NAMESPACE, "config", "recent_files", recent_files) func request_quit() -> void: RendererPersistence.commit(PERSISTENCE_NAMESPACE) if range(tab_container.get_tab_count()).any(func(x: int): return tab_container.get_content(x).dirty): unsaved_changes_dialog.show() else: for i in range(tab_container.get_tab_count() - 1, -1, -1): await close_tab(i) #get_tree().quit() quit_completed.emit() func _on_unsaved_changes_dialog_single_deck_confirmed() -> void: save_tab(unsaved_changes_dialog_single_deck.get_meta("tab")) await close_tab(unsaved_changes_dialog_single_deck.get_meta("tab")) func _on_unsaved_changes_dialog_single_deck_custom_action(action: StringName) -> void: if action == &"force_close": await close_tab(unsaved_changes_dialog_single_deck.get_meta("tab")) unsaved_changes_dialog_single_deck.hide() func _on_unsaved_changes_dialog_confirmed() -> void: for i in range(tab_container.get_tab_count() - 1, -1, -1): await close_tab(i) #get_tree().quit() quit_completed.emit() func _unhandled_key_input(event: InputEvent) -> void: if RendererShortcuts.check_shortcut("toggle_bottom_dock", event): bottom_dock.visible = !bottom_dock.visible accept_event() if RendererShortcuts.check_shortcut("toggle_sidebar", event): sidebar.visible = !sidebar.visible if sidebar.visible: sidebar_split.dragger_visibility = SplitContainer.DRAGGER_VISIBLE else: sidebar_split.dragger_visibility = SplitContainer.DRAGGER_HIDDEN_COLLAPSED func _on_help_id_pressed(id: int) -> void: match id: HelpMenuId.ABOUT: about_dialog.show() HelpMenuId.DOCS: OS.shell_open("https://codeberg.org/Eroax/StreamGraph/wiki")