Files
step3/scripts/ui/calculator_panel.gd
hz 9f836c8765 Fix login/calculator theme bugs, gate flashcard button, hide system label pre-answer
- 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
2026-07-27 00:43:18 -05:00

449 lines
12 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)