164 lines
6.1 KiB
GDScript
164 lines
6.1 KiB
GDScript
# 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()
|