feat: complete mobile UI implementation
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user