# MobileWindowHost —— touch-safe presentation for existing feature windows. # # Feature controllers keep ownership of data, packets and permissions. This # helper only gives their Control roots a landscape-safe frame, a predictable # back/close affordance and a screen-touch bridge for legacy Button nodes. extends RefCounted const NAV_HEIGHT := 40.0 const EDGE_MARGIN := 12.0 const MIN_SCALE := 0.42 static func safe_rect(viewport: Viewport) -> Rect2: if viewport == null: return Rect2(Vector2.ZERO, Vector2(844, 390)) var viewport_size := viewport.get_visible_rect().size return safe_rect_for(viewport_size, DisplayServer.get_display_safe_area()) ## Resolve a platform-reported safe area against the current logical viewport. ## Keeping this pure makes the edge cases testable without an Android/iOS ## device and avoids trusting desktop backends that return physical-screen ## coordinates unrelated to the current viewport. static func safe_rect_for(viewport_size: Vector2, reported: Rect2) -> Rect2: if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: return Rect2(Vector2.ZERO, viewport_size) if reported.size.x <= 0.0 or reported.size.y <= 0.0: return Rect2(Vector2.ZERO, viewport_size) var result := reported # Some desktop backends report a screen-space safe area larger than the # current viewport. Treat that as unavailable rather than moving windows # outside the viewport. if result.size.x < 1.0 or result.size.y < 1.0: return Rect2(Vector2.ZERO, viewport_size) if result.position.x < 0.0 or result.position.y < 0.0 \ or result.end.x > viewport_size.x + 1.0 \ or result.end.y > viewport_size.y + 1.0: return Rect2(Vector2.ZERO, viewport_size) return result static func content_size(root: Control) -> Vector2: if root == null: return Vector2(320, 240) var result := root.size if result.x <= 1.0 or result.y <= 1.0: result = root.get_combined_minimum_size() if result.x <= 1.0 or result.y <= 1.0: result = Vector2(320, 240) return result static func install(root: Control, area: Rect2, title: String, close_cb: Callable, back_cb: Callable = Callable()) -> Dictionary: if root == null: return {} var base_size := content_size(root) var available := area.size - Vector2(EDGE_MARGIN * 2.0, EDGE_MARGIN * 2.0) var scale_value := minf(available.x / base_size.x, available.y / base_size.y) scale_value = maxf(MIN_SCALE, minf(1.0, scale_value)) root.set_anchors_preset(Control.PRESET_TOP_LEFT) root.size = base_size root.pivot_offset = Vector2.ZERO root.scale = Vector2.ONE * scale_value root.position = area.position + (area.size - base_size * scale_value) * 0.5 root.set_meta("mobile_hosted", true) root.set_meta("mobile_base_size", base_size) root.set_meta("mobile_scale", scale_value) var nav := root.get_node_or_null("__mobile_nav") as Control if nav == null: nav = Panel.new() nav.name = "__mobile_nav" nav.mouse_filter = Control.MOUSE_FILTER_STOP nav.z_index = 100 var nav_style := StyleBoxFlat.new() nav_style.bg_color = Color(0.025, 0.045, 0.08, 0.96) nav_style.border_color = Color(0.36, 0.6, 0.92, 0.82) nav_style.border_width_bottom = 1 nav.add_theme_stylebox_override("panel", nav_style) root.add_child(nav) nav.position = Vector2.ZERO nav.size = Vector2(base_size.x, NAV_HEIGHT) nav.visible = true var title_label := nav.get_node_or_null("Title") as Label if title_label == null: title_label = Label.new() title_label.name = "Title" title_label.position = Vector2(88, 0) title_label.size = Vector2(maxf(80.0, base_size.x - 176.0), NAV_HEIGHT) title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER title_label.add_theme_font_size_override("font_size", 15) title_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58)) title_label.mouse_filter = Control.MOUSE_FILTER_IGNORE nav.add_child(title_label) title_label.text = title if title != "" else String(root.name) var back := nav.get_node_or_null("Back") as Button if back == null: back = Button.new() back.name = "Back" back.text = "返回" back.position = Vector2(8, 4) back.size = Vector2(70, 32) back.add_theme_font_size_override("font_size", 12) nav.add_child(back) if not back.has_meta("mobile_host_wired"): back.set_meta("mobile_host_wired", true) back.pressed.connect(func(): if back_cb.is_valid(): back_cb.call() elif close_cb.is_valid(): close_cb.call()) var close := nav.get_node_or_null("Close") as Button if close == null: close = Button.new() close.name = "Close" close.text = "×" close.add_theme_font_size_override("font_size", 18) nav.add_child(close) if not close.has_meta("mobile_host_wired"): close.set_meta("mobile_host_wired", true) close.pressed.connect(func(): if close_cb.is_valid(): close_cb.call()) close.position = Vector2(maxf(78.0, base_size.x - 78.0), 4) close.size = Vector2(70, 32) _wire_buttons(root) return {"size": base_size, "scale": scale_value, "area": area} static func uninstall(root: Control) -> void: if root == null: return var nav := root.get_node_or_null("__mobile_nav") as Control if nav: nav.visible = false root.set_meta("mobile_hosted", false) static func wire_buttons(root: Node) -> void: if root: _wire_buttons(root) static func _wire_buttons(root: Node) -> void: for node in root.find_children("*", "BaseButton", true, false): var button := node as BaseButton if button == null or button.has_meta("mobile_touch_wired"): continue button.set_meta("mobile_touch_wired", true) button.gui_input.connect(func(event: InputEvent): _handle_button_touch(button, event)) static func _handle_button_touch(button: BaseButton, event: InputEvent) -> void: if button == null or not is_instance_valid(button): return if event is InputEventScreenTouch: var touch := event as InputEventScreenTouch if touch.pressed: if int(button.get_meta("mobile_touch_index", -1)) < 0: button.set_meta("mobile_touch_index", touch.index) else: if int(button.get_meta("mobile_touch_index", -1)) == touch.index: button.set_meta("mobile_touch_index", -1) if not button.disabled: button.pressed.emit() button.accept_event()