feat: complete mobile UI implementation

This commit is contained in:
shenlei
2026-09-04 19:54:33 +09:00
parent 9a4a044da5
commit b19d5b5dd3
132 changed files with 6643 additions and 180 deletions
+163
View File
@@ -0,0 +1,163 @@
# 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()