- Steam-linked login no longer writes literal "<null>" into the email field (and no longer locks it read-only) when the verified identity has no email - Login screen releases focus on app focus-out so the username caret stops blinking while the window is inactive - Calculator display text stays legible in light mode; the LCD-style panel background is always dark, so its text should not follow the app theme - Question player: gate the "save as flashcard" toolbar button behind Brand.is_uat_environment(), matching the nav item, so it's hidden in prod - Hide the discipline/system label while a question is unanswered so it can't hint at the answer; reveal it once the explanation is shown - Top-align question content instead of vertically centering it, with a 20px top margin, so it sits closer to the progress indicator
449 lines
12 KiB
GDScript
449 lines
12 KiB
GDScript
extends PanelContainer
|
||
|
||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||
const MAX_ENTRY_LENGTH := 16
|
||
|
||
var display_label: Label
|
||
var operation_label: Label
|
||
var keypad_buttons: Array[Button] = []
|
||
var entry := "0"
|
||
var accumulator := 0.0
|
||
var pending_operator := ""
|
||
var waiting_for_operand := true
|
||
var last_operator := ""
|
||
var last_operand := 0.0
|
||
var has_last_operation := false
|
||
var error_state := false
|
||
var drag_active := false
|
||
var drag_offset := Vector2.ZERO
|
||
var has_been_positioned := false
|
||
|
||
|
||
func _ready() -> void:
|
||
name = "CalculatorPanel"
|
||
visible = false
|
||
top_level = true
|
||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||
custom_minimum_size = Vector2(310, 390)
|
||
size = custom_minimum_size
|
||
_build_ui()
|
||
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
|
||
get_viewport().size_changed.connect(_on_viewport_size_changed)
|
||
_on_viewport_size_changed()
|
||
refresh_theme_controls()
|
||
_refresh_display()
|
||
|
||
|
||
func open_panel() -> void:
|
||
visible = true
|
||
move_to_front()
|
||
if not has_been_positioned:
|
||
var viewport_size := get_viewport_rect().size
|
||
position = Vector2(maxf(12.0, viewport_size.x - size.x - 24.0), 72.0)
|
||
has_been_positioned = true
|
||
_clamp_to_viewport()
|
||
|
||
|
||
func close_panel() -> void:
|
||
visible = false
|
||
drag_active = false
|
||
|
||
|
||
func toggle_panel() -> void:
|
||
if visible:
|
||
close_panel()
|
||
else:
|
||
open_panel()
|
||
|
||
|
||
func reset_calculation() -> void:
|
||
entry = "0"
|
||
accumulator = 0.0
|
||
pending_operator = ""
|
||
waiting_for_operand = true
|
||
last_operator = ""
|
||
last_operand = 0.0
|
||
has_last_operation = false
|
||
error_state = false
|
||
_refresh_display()
|
||
|
||
|
||
func press_token(token: String) -> void:
|
||
if token.length() == 1 and token.is_valid_int():
|
||
_enter_digit(token)
|
||
return
|
||
match token:
|
||
".":
|
||
_enter_decimal()
|
||
"+", "−", "×", "÷":
|
||
_set_operator(token)
|
||
"=":
|
||
_equals()
|
||
"%":
|
||
_apply_unary("percent")
|
||
"√":
|
||
_apply_unary("sqrt")
|
||
"±":
|
||
_apply_unary("sign")
|
||
"⌫":
|
||
_backspace()
|
||
"AC":
|
||
reset_calculation()
|
||
|
||
|
||
func get_display_text() -> String:
|
||
return entry
|
||
|
||
|
||
func refresh_theme_controls() -> void:
|
||
if display_label == null:
|
||
return
|
||
var dark := UIThemeService.is_dark_mode()
|
||
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||
display_label.add_theme_color_override("font_color", Color.WHITE)
|
||
operation_label.add_theme_color_override("font_color", Brand.muted_color(true))
|
||
for button in keypad_buttons:
|
||
var accent := Brand.ROYAL_BLUE if str(button.get_meta("calculator_token", "")) in ["+", "−", "×", "÷", "="] else Brand.SLATE
|
||
button.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
|
||
button.add_theme_stylebox_override("hover", Brand.badge_box(dark, Brand.TEAL))
|
||
button.add_theme_stylebox_override("pressed", Brand.badge_box(dark, Brand.PURPLE))
|
||
button.add_theme_color_override("font_color", Brand.text_color(dark))
|
||
|
||
|
||
func _build_ui() -> void:
|
||
var margin := MarginContainer.new()
|
||
margin.add_theme_constant_override("margin_left", 10)
|
||
margin.add_theme_constant_override("margin_right", 10)
|
||
margin.add_theme_constant_override("margin_top", 12)
|
||
margin.add_theme_constant_override("margin_bottom", 14)
|
||
add_child(margin)
|
||
|
||
var content := VBoxContainer.new()
|
||
content.add_theme_constant_override("separation", 10)
|
||
margin.add_child(content)
|
||
|
||
var title_bar := HBoxContainer.new()
|
||
title_bar.name = "CalculatorTitleBar"
|
||
title_bar.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
title_bar.custom_minimum_size.y = 34
|
||
title_bar.gui_input.connect(_on_title_bar_gui_input)
|
||
content.add_child(title_bar)
|
||
|
||
var title := Label.new()
|
||
title.text = "Calculator"
|
||
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
Brand.apply_font(title)
|
||
title.add_theme_font_size_override("font_size", 18)
|
||
title_bar.add_child(title)
|
||
|
||
var close := Button.new()
|
||
close.name = "CalculatorClose"
|
||
close.text = "×"
|
||
close.tooltip_text = "Close calculator"
|
||
close.custom_minimum_size = Vector2(36, 32)
|
||
close.focus_mode = Control.FOCUS_NONE
|
||
close.pressed.connect(close_panel)
|
||
title_bar.add_child(close)
|
||
|
||
var display_panel := PanelContainer.new()
|
||
display_panel.custom_minimum_size.y = 78
|
||
display_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("101b31"), Style.CYAN))
|
||
content.add_child(display_panel)
|
||
|
||
var display_margin := MarginContainer.new()
|
||
display_margin.add_theme_constant_override("margin_left", 12)
|
||
display_margin.add_theme_constant_override("margin_right", 12)
|
||
display_margin.add_theme_constant_override("margin_top", 8)
|
||
display_margin.add_theme_constant_override("margin_bottom", 8)
|
||
display_panel.add_child(display_margin)
|
||
|
||
var display_stack := VBoxContainer.new()
|
||
display_margin.add_child(display_stack)
|
||
operation_label = Label.new()
|
||
operation_label.name = "CalculatorOperation"
|
||
operation_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
operation_label.add_theme_font_size_override("font_size", 13)
|
||
Brand.apply_font(operation_label)
|
||
display_stack.add_child(operation_label)
|
||
display_label = Label.new()
|
||
display_label.name = "CalculatorDisplay"
|
||
display_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
display_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
display_label.add_theme_font_size_override("font_size", 28)
|
||
Brand.apply_font(display_label)
|
||
display_stack.add_child(display_label)
|
||
|
||
var keypad := GridContainer.new()
|
||
keypad.name = "CalculatorKeypad"
|
||
keypad.columns = 4
|
||
keypad.add_theme_constant_override("h_separation", 6)
|
||
keypad.add_theme_constant_override("v_separation", 8)
|
||
content.add_child(keypad)
|
||
|
||
var tokens := [
|
||
"AC", "⌫", "±", "√",
|
||
"7", "8", "9", "÷",
|
||
"4", "5", "6", "×",
|
||
"1", "2", "3", "−",
|
||
"%", "0", ".", "+",
|
||
"", "", "", "="
|
||
]
|
||
for token in tokens:
|
||
var button := Button.new()
|
||
button.custom_minimum_size = Vector2(44, 45)
|
||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
button.focus_mode = Control.FOCUS_NONE
|
||
if token == "":
|
||
button.visible = false
|
||
keypad.add_child(button)
|
||
continue
|
||
button.text = token
|
||
button.set_meta("calculator_token", token)
|
||
button.tooltip_text = _token_tooltip(token)
|
||
button.add_theme_font_size_override("font_size", 18)
|
||
Brand.apply_font(button)
|
||
UISoundService.set_button_sound(button, "toggle")
|
||
button.pressed.connect(press_token.bind(token))
|
||
keypad.add_child(button)
|
||
keypad_buttons.append(button)
|
||
|
||
|
||
func _input(event: InputEvent) -> void:
|
||
if not visible or not (event is InputEventKey):
|
||
return
|
||
var key_event := event as InputEventKey
|
||
if not key_event.pressed or key_event.echo:
|
||
return
|
||
var token := ""
|
||
if key_event.keycode == KEY_ESCAPE:
|
||
close_panel()
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
if key_event.keycode in [KEY_ENTER, KEY_KP_ENTER]:
|
||
token = "="
|
||
elif key_event.keycode == KEY_BACKSPACE:
|
||
token = "⌫"
|
||
elif key_event.keycode == KEY_DELETE:
|
||
token = "AC"
|
||
elif key_event.unicode > 0:
|
||
var character := String.chr(key_event.unicode)
|
||
match character:
|
||
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "%", "=":
|
||
token = character
|
||
"+": token = "+"
|
||
"-": token = "−"
|
||
"*", "x", "X": token = "×"
|
||
"/": token = "÷"
|
||
"c", "C": token = "AC"
|
||
if token == "":
|
||
return
|
||
press_token(token)
|
||
get_viewport().set_input_as_handled()
|
||
|
||
|
||
func _enter_digit(digit: String) -> void:
|
||
if error_state:
|
||
reset_calculation()
|
||
if waiting_for_operand:
|
||
entry = digit
|
||
waiting_for_operand = false
|
||
elif entry == "0":
|
||
entry = digit
|
||
elif entry.length() < MAX_ENTRY_LENGTH:
|
||
entry += digit
|
||
_refresh_display()
|
||
|
||
|
||
func _enter_decimal() -> void:
|
||
if error_state:
|
||
reset_calculation()
|
||
if waiting_for_operand:
|
||
entry = "0."
|
||
waiting_for_operand = false
|
||
elif not "." in entry and entry.length() < MAX_ENTRY_LENGTH:
|
||
entry += "."
|
||
_refresh_display()
|
||
|
||
|
||
func _set_operator(operator: String) -> void:
|
||
if error_state:
|
||
return
|
||
var current := _entry_value()
|
||
if pending_operator != "" and not waiting_for_operand:
|
||
if not _perform_operation(pending_operator, current):
|
||
return
|
||
else:
|
||
accumulator = current
|
||
pending_operator = operator
|
||
waiting_for_operand = true
|
||
has_last_operation = false
|
||
_refresh_display()
|
||
|
||
|
||
func _equals() -> void:
|
||
if error_state:
|
||
return
|
||
if pending_operator != "":
|
||
var operand := accumulator if waiting_for_operand else _entry_value()
|
||
var operator := pending_operator
|
||
if not _perform_operation(operator, operand):
|
||
return
|
||
last_operator = operator
|
||
last_operand = operand
|
||
has_last_operation = true
|
||
pending_operator = ""
|
||
elif has_last_operation:
|
||
if not _perform_operation(last_operator, last_operand):
|
||
return
|
||
else:
|
||
return
|
||
waiting_for_operand = true
|
||
_refresh_display()
|
||
|
||
|
||
func _apply_unary(operation: String) -> void:
|
||
if error_state:
|
||
return
|
||
var value := _entry_value()
|
||
match operation:
|
||
"percent":
|
||
value /= 100.0
|
||
"sqrt":
|
||
if value < 0.0:
|
||
_set_error()
|
||
return
|
||
value = sqrt(value)
|
||
"sign":
|
||
value = -value
|
||
entry = _format_number(value)
|
||
if pending_operator == "":
|
||
accumulator = value
|
||
waiting_for_operand = false
|
||
_refresh_display()
|
||
|
||
|
||
func _backspace() -> void:
|
||
if error_state:
|
||
reset_calculation()
|
||
return
|
||
if waiting_for_operand:
|
||
return
|
||
entry = entry.left(entry.length() - 1)
|
||
if entry in ["", "-"]:
|
||
entry = "0"
|
||
waiting_for_operand = true
|
||
_refresh_display()
|
||
|
||
|
||
func _perform_operation(operator: String, operand: float) -> bool:
|
||
var result := accumulator
|
||
match operator:
|
||
"+": result += operand
|
||
"−": result -= operand
|
||
"×": result *= operand
|
||
"÷":
|
||
if is_zero_approx(operand):
|
||
_set_error()
|
||
return false
|
||
result /= operand
|
||
if is_nan(result) or is_inf(result):
|
||
_set_error()
|
||
return false
|
||
accumulator = result
|
||
entry = _format_number(result)
|
||
return true
|
||
|
||
|
||
func _entry_value() -> float:
|
||
return entry.to_float()
|
||
|
||
|
||
func _format_number(value: float) -> String:
|
||
if is_zero_approx(value):
|
||
return "0"
|
||
var formatted := String.num(value, 12)
|
||
if "." in formatted:
|
||
while formatted.ends_with("0"):
|
||
formatted = formatted.left(formatted.length() - 1)
|
||
if formatted.ends_with("."):
|
||
formatted = formatted.left(formatted.length() - 1)
|
||
if formatted.length() > MAX_ENTRY_LENGTH:
|
||
formatted = String.num_scientific(value).replace("e+", "e")
|
||
return formatted
|
||
|
||
|
||
func _set_error() -> void:
|
||
entry = "Error"
|
||
error_state = true
|
||
pending_operator = ""
|
||
waiting_for_operand = true
|
||
has_last_operation = false
|
||
_refresh_display()
|
||
|
||
|
||
func _refresh_display() -> void:
|
||
if display_label == null:
|
||
return
|
||
display_label.text = entry
|
||
operation_label.text = ("%s %s" % [_format_number(accumulator), pending_operator]) if pending_operator != "" else ""
|
||
|
||
|
||
func _on_title_bar_gui_input(event: InputEvent) -> void:
|
||
var touch_event := event as InputEventScreenTouch
|
||
if touch_event != null:
|
||
drag_active = touch_event.pressed
|
||
if drag_active:
|
||
drag_offset = touch_event.position
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
var touch_drag := event as InputEventScreenDrag
|
||
if touch_drag != null and drag_active:
|
||
global_position += touch_drag.relative
|
||
_clamp_to_viewport()
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
var mouse_event := event as InputEventMouseButton
|
||
if mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_LEFT:
|
||
drag_active = mouse_event.pressed
|
||
if drag_active:
|
||
drag_offset = mouse_event.global_position - global_position
|
||
get_viewport().set_input_as_handled()
|
||
return
|
||
var motion := event as InputEventMouseMotion
|
||
if motion != null and drag_active:
|
||
global_position = motion.global_position - drag_offset
|
||
_clamp_to_viewport()
|
||
get_viewport().set_input_as_handled()
|
||
|
||
|
||
func _on_viewport_size_changed() -> void:
|
||
var viewport_size := get_viewport_rect().size
|
||
custom_minimum_size.x = minf(310.0, maxf(216.0, viewport_size.x - 24.0))
|
||
size.x = custom_minimum_size.x
|
||
_clamp_to_viewport()
|
||
|
||
|
||
func _clamp_to_viewport() -> void:
|
||
if not is_inside_tree():
|
||
return
|
||
var viewport_size := get_viewport_rect().size
|
||
position.x = clampf(position.x, 12.0, maxf(12.0, viewport_size.x - size.x - 12.0))
|
||
position.y = clampf(position.y, 12.0, maxf(12.0, viewport_size.y - size.y - 12.0))
|
||
|
||
|
||
func _token_tooltip(token: String) -> String:
|
||
return {
|
||
"AC": "Clear calculation",
|
||
"⌫": "Backspace",
|
||
"±": "Change sign",
|
||
"√": "Square root",
|
||
"%": "Convert to percent",
|
||
"÷": "Divide",
|
||
"×": "Multiply",
|
||
"−": "Subtract",
|
||
"+": "Add",
|
||
"=": "Equals"
|
||
}.get(token, token)
|