Add exam-style question calculator
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect width="16" height="20" x="4" y="2" rx="2"/>
|
||||||
|
<line x1="8" x2="16" y1="6" y2="6"/>
|
||||||
|
<line x1="16" x2="16" y1="14" y2="18"/>
|
||||||
|
<path d="M16 10h.01"/>
|
||||||
|
<path d="M12 10h.01"/>
|
||||||
|
<path d="M8 10h.01"/>
|
||||||
|
<path d="M12 14h.01"/>
|
||||||
|
<path d="M8 14h.01"/>
|
||||||
|
<path d="M12 18h.01"/>
|
||||||
|
<path d="M8 18h.01"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,43 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dy8lr6snglfpm"
|
||||||
|
path="res://.godot/imported/calculator.svg-0f64f216f45259cc59d80d23ea9a79a8.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/icons/lucide/calculator.svg"
|
||||||
|
dest_files=["res://.godot/imported/calculator.svg-0f64f216f45259cc59d80d23ea9a79a8.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
svg/scale=1.0
|
||||||
|
editor/scale_with_editor_scale=false
|
||||||
|
editor/convert_colors_with_editor_theme=false
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
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", Brand.text_color(dark))
|
||||||
|
operation_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||||
|
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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dssyep5a2sime
|
||||||
@@ -3,6 +3,7 @@ extends VBoxContainer
|
|||||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
||||||
|
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
|
||||||
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
||||||
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
||||||
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
|
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
|
||||||
@@ -81,6 +82,7 @@ var skip_button: Button
|
|||||||
var text_smaller_button: Button
|
var text_smaller_button: Button
|
||||||
var text_larger_button: Button
|
var text_larger_button: Button
|
||||||
var labs_button: Button
|
var labs_button: Button
|
||||||
|
var calculator_button: Button
|
||||||
var clear_highlight_button: Button
|
var clear_highlight_button: Button
|
||||||
var flag_button: Button
|
var flag_button: Button
|
||||||
var report_button: Button
|
var report_button: Button
|
||||||
@@ -109,6 +111,7 @@ var feedback_fog: ColorRect
|
|||||||
var labs_overlay: Control
|
var labs_overlay: Control
|
||||||
var labs_panel: PanelContainer
|
var labs_panel: PanelContainer
|
||||||
var labs_content: VBoxContainer
|
var labs_content: VBoxContainer
|
||||||
|
var calculator_panel
|
||||||
var report_overlay: Control
|
var report_overlay: Control
|
||||||
var report_panel: PanelContainer
|
var report_panel: PanelContainer
|
||||||
var report_form_content: VBoxContainer
|
var report_form_content: VBoxContainer
|
||||||
@@ -258,6 +261,19 @@ func _ready() -> void:
|
|||||||
text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP))
|
text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP))
|
||||||
question_toolbar.add_child(text_larger_button)
|
question_toolbar.add_child(text_larger_button)
|
||||||
|
|
||||||
|
calculator_button = Button.new()
|
||||||
|
calculator_button.name = "CalculatorButton"
|
||||||
|
calculator_button.tooltip_text = "Open calculator"
|
||||||
|
calculator_button.icon = preload("res://assets/icons/lucide/calculator.svg")
|
||||||
|
calculator_button.custom_minimum_size = Vector2(44, 44)
|
||||||
|
calculator_button.add_theme_constant_override("icon_max_width", 22)
|
||||||
|
calculator_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
calculator_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
calculator_button.focus_mode = Control.FOCUS_NONE
|
||||||
|
UISoundService.set_button_sound(calculator_button, "open")
|
||||||
|
calculator_button.pressed.connect(_toggle_calculator)
|
||||||
|
question_toolbar.add_child(calculator_button)
|
||||||
|
|
||||||
labs_button = Button.new()
|
labs_button = Button.new()
|
||||||
labs_button.tooltip_text = "Review lab results"
|
labs_button.tooltip_text = "Review lab results"
|
||||||
labs_button.custom_minimum_size = Vector2(44, 44)
|
labs_button.custom_minimum_size = Vector2(44, 44)
|
||||||
@@ -574,6 +590,8 @@ func _ready() -> void:
|
|||||||
add_child(feedback_fog)
|
add_child(feedback_fog)
|
||||||
|
|
||||||
_build_labs_modal()
|
_build_labs_modal()
|
||||||
|
calculator_panel = CalculatorPanel.new()
|
||||||
|
add_child(calculator_panel)
|
||||||
_build_report_modal()
|
_build_report_modal()
|
||||||
_build_media_viewer()
|
_build_media_viewer()
|
||||||
resized.connect(_fit_modal_panels)
|
resized.connect(_fit_modal_panels)
|
||||||
@@ -909,7 +927,7 @@ func _apply_question_theme() -> void:
|
|||||||
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
||||||
if navigator_heading != null:
|
if navigator_heading != null:
|
||||||
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||||
for control in [text_smaller_button, text_larger_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
for control in [text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
||||||
if control == null:
|
if control == null:
|
||||||
continue
|
continue
|
||||||
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
|
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
|
||||||
@@ -923,6 +941,8 @@ func _apply_question_theme() -> void:
|
|||||||
_style_asset_toolbar_button(report_button, REPORT_QUESTION_DARK_ICON if dark else REPORT_QUESTION_LIGHT_ICON, dark)
|
_style_asset_toolbar_button(report_button, REPORT_QUESTION_DARK_ICON if dark else REPORT_QUESTION_LIGHT_ICON, dark)
|
||||||
_style_asset_toolbar_button(flag_button, FLAG_DARK_ICON if dark else FLAG_LIGHT_ICON, dark, flagged)
|
_style_asset_toolbar_button(flag_button, FLAG_DARK_ICON if dark else FLAG_LIGHT_ICON, dark, flagged)
|
||||||
_style_theme_toggle_button(dark)
|
_style_theme_toggle_button(dark)
|
||||||
|
if calculator_panel != null:
|
||||||
|
calculator_panel.refresh_theme_controls()
|
||||||
if skip_button != null:
|
if skip_button != null:
|
||||||
skip_button.add_theme_stylebox_override("normal", _toolbar_box(dark))
|
skip_button.add_theme_stylebox_override("normal", _toolbar_box(dark))
|
||||||
skip_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
|
skip_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true))
|
||||||
@@ -963,6 +983,9 @@ func on_hide() -> void:
|
|||||||
_hide_media_viewer()
|
_hide_media_viewer()
|
||||||
_hide_report_modal(true)
|
_hide_report_modal(true)
|
||||||
_hide_labs_modal()
|
_hide_labs_modal()
|
||||||
|
if calculator_panel != null:
|
||||||
|
calculator_panel.close_panel()
|
||||||
|
calculator_panel.reset_calculation()
|
||||||
|
|
||||||
|
|
||||||
func _save_simulation_current_response() -> void:
|
func _save_simulation_current_response() -> void:
|
||||||
@@ -1049,6 +1072,8 @@ func _input(event: InputEvent) -> void:
|
|||||||
_hide_labs_modal()
|
_hide_labs_modal()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
return
|
return
|
||||||
|
if calculator_panel != null and calculator_panel.visible and event is InputEventKey:
|
||||||
|
return
|
||||||
if not visible or pending_correct_advance or stem_label == null:
|
if not visible or pending_correct_advance or stem_label == null:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2038,9 +2063,16 @@ func _refresh_toolbar_state() -> void:
|
|||||||
var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id)
|
var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id)
|
||||||
var bookmark_pending := BookmarkService.is_pending(bookmark_question_id)
|
var bookmark_pending := BookmarkService.is_pending(bookmark_question_id)
|
||||||
bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question")
|
bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question")
|
||||||
|
if calculator_button != null:
|
||||||
|
calculator_button.visible = true
|
||||||
_apply_question_theme()
|
_apply_question_theme()
|
||||||
|
|
||||||
|
|
||||||
|
func _toggle_calculator() -> void:
|
||||||
|
if calculator_panel != null:
|
||||||
|
calculator_panel.toggle_panel()
|
||||||
|
|
||||||
|
|
||||||
func _toggle_bookmark_current_question() -> void:
|
func _toggle_bookmark_current_question() -> void:
|
||||||
var question := BlockSessionService.get_current_question()
|
var question := BlockSessionService.get_current_question()
|
||||||
if not question.is_empty():
|
if not question.is_empty():
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
_test_basic_arithmetic()
|
||||||
|
_test_decimal_and_chained_operations()
|
||||||
|
_test_percent_and_sign()
|
||||||
|
_test_square_root()
|
||||||
|
_test_repeated_equals()
|
||||||
|
_test_backspace_and_clear()
|
||||||
|
_test_error_recovery()
|
||||||
|
await _test_runtime_panel_behavior()
|
||||||
|
quit(1 if failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _calculator():
|
||||||
|
return load("res://scripts/ui/calculator_panel.gd").new()
|
||||||
|
|
||||||
|
|
||||||
|
func _press(calculator, tokens: Array) -> void:
|
||||||
|
for token in tokens:
|
||||||
|
calculator.press_token(str(token))
|
||||||
|
|
||||||
|
|
||||||
|
func _test_basic_arithmetic() -> void:
|
||||||
|
var calculator = _calculator()
|
||||||
|
_press(calculator, ["9", "+", "6", "="])
|
||||||
|
_assert(calculator.get_display_text() == "15", "calculator should add operands")
|
||||||
|
calculator.reset_calculation()
|
||||||
|
_press(calculator, ["9", "−", "4", "="])
|
||||||
|
_assert(calculator.get_display_text() == "5", "calculator should subtract operands")
|
||||||
|
calculator.reset_calculation()
|
||||||
|
_press(calculator, ["7", "×", "8", "="])
|
||||||
|
_assert(calculator.get_display_text() == "56", "calculator should multiply operands")
|
||||||
|
calculator.reset_calculation()
|
||||||
|
_press(calculator, ["8", "÷", "4", "="])
|
||||||
|
_assert(calculator.get_display_text() == "2", "calculator should divide operands")
|
||||||
|
calculator.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_decimal_and_chained_operations() -> void:
|
||||||
|
var calculator = _calculator()
|
||||||
|
_press(calculator, ["1", ".", "5", "+", "2", ".", "5", "×", "2", "="])
|
||||||
|
_assert(calculator.get_display_text() == "8", "calculator should evaluate chained handheld operations left to right")
|
||||||
|
calculator.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_percent_and_sign() -> void:
|
||||||
|
var calculator = _calculator()
|
||||||
|
_press(calculator, ["5", "%"])
|
||||||
|
_assert(calculator.get_display_text() == "0.05", "percent should convert the displayed operand to a fraction")
|
||||||
|
_press(calculator, ["±"])
|
||||||
|
_assert(calculator.get_display_text() == "-0.05", "sign toggle should negate the displayed operand")
|
||||||
|
calculator.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_square_root() -> void:
|
||||||
|
var calculator = _calculator()
|
||||||
|
_press(calculator, ["8", "1", "√"])
|
||||||
|
_assert(calculator.get_display_text() == "9", "square root should replace the displayed operand")
|
||||||
|
calculator.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_repeated_equals() -> void:
|
||||||
|
var calculator = _calculator()
|
||||||
|
_press(calculator, ["5", "+", "2", "=", "="])
|
||||||
|
_assert(calculator.get_display_text() == "9", "repeated equals should replay the last binary operation")
|
||||||
|
calculator.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_backspace_and_clear() -> void:
|
||||||
|
var calculator = _calculator()
|
||||||
|
_press(calculator, ["1", "2", "3", "⌫"])
|
||||||
|
_assert(calculator.get_display_text() == "12", "backspace should remove the final entry digit")
|
||||||
|
_press(calculator, ["AC"])
|
||||||
|
_assert(calculator.get_display_text() == "0", "clear should reset the display")
|
||||||
|
calculator.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_error_recovery() -> void:
|
||||||
|
var calculator = _calculator()
|
||||||
|
_press(calculator, ["8", "÷", "0", "="])
|
||||||
|
_assert(calculator.get_display_text() == "Error", "division by zero should display an error")
|
||||||
|
_press(calculator, ["4"])
|
||||||
|
_assert(calculator.get_display_text() == "4", "digit entry should recover from an error")
|
||||||
|
_press(calculator, ["±", "√"])
|
||||||
|
_assert(calculator.get_display_text() == "Error", "square root of a negative operand should display an error")
|
||||||
|
calculator.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_runtime_panel_behavior() -> void:
|
||||||
|
var host := Control.new()
|
||||||
|
host.size = Vector2(360, 640)
|
||||||
|
root.add_child(host)
|
||||||
|
var calculator = _calculator()
|
||||||
|
host.add_child(calculator)
|
||||||
|
await process_frame
|
||||||
|
calculator.open_panel()
|
||||||
|
await process_frame
|
||||||
|
_assert(calculator.visible, "calculator should open as a floating panel")
|
||||||
|
_assert(calculator.size.x <= 336.0, "calculator should fit a narrow web viewport")
|
||||||
|
_press(calculator, ["2", "+", "3", "="])
|
||||||
|
calculator.close_panel()
|
||||||
|
calculator.open_panel()
|
||||||
|
_assert(calculator.get_display_text() == "5", "closing and reopening should preserve the active-session value")
|
||||||
|
calculator.position = Vector2(1000, 1000)
|
||||||
|
calculator._clamp_to_viewport()
|
||||||
|
_assert(calculator.position.x + calculator.size.x <= calculator.get_viewport_rect().size.x, "calculator dragging should remain horizontally bounded")
|
||||||
|
_assert(calculator.position.y + calculator.size.y <= calculator.get_viewport_rect().size.y, "calculator dragging should remain vertically bounded")
|
||||||
|
calculator.reset_calculation()
|
||||||
|
_assert(calculator.get_display_text() == "0", "session reset should clear calculator state")
|
||||||
|
host.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _assert(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
return
|
||||||
|
failures += 1
|
||||||
|
push_error(message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://j08gujwjb7jx
|
||||||
@@ -361,6 +361,8 @@ func _test_player_runtime_construction() -> void:
|
|||||||
_assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork")
|
_assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork")
|
||||||
_assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork")
|
_assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork")
|
||||||
_assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork")
|
_assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork")
|
||||||
|
_assert(player.calculator_button != null and player.calculator_button.icon != null, "every question mode should expose the calculator tool")
|
||||||
|
_assert(player.calculator_panel != null, "question player should construct the floating calculator once")
|
||||||
_assert(player.notes_button != null and player.notes_button.icon != null, "practice questions should expose the notes tool")
|
_assert(player.notes_button != null and player.notes_button.icon != null, "practice questions should expose the notes tool")
|
||||||
for icon_button in [player.clear_highlight_button, player.report_button, player.labs_button]:
|
for icon_button in [player.clear_highlight_button, player.report_button, player.labs_button]:
|
||||||
_assert(icon_button.alignment == HORIZONTAL_ALIGNMENT_CENTER and icon_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "question toolbar artwork should be horizontally centered")
|
_assert(icon_button.alignment == HORIZONTAL_ALIGNMENT_CENTER and icon_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "question toolbar artwork should be horizontally centered")
|
||||||
@@ -377,12 +379,16 @@ func _test_player_runtime_construction() -> void:
|
|||||||
_assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot")
|
_assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot")
|
||||||
_assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice")
|
_assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice")
|
||||||
var prior_session_kind: String = root.get_node("BlockSessionService").session_kind
|
var prior_session_kind: String = root.get_node("BlockSessionService").session_kind
|
||||||
root.get_node("BlockSessionService").session_kind = "simulation"
|
for session_kind in ["block", "core", "bookmark", "review", "simulation"]:
|
||||||
player._refresh_toolbar_state()
|
root.get_node("BlockSessionService").session_kind = session_kind
|
||||||
_assert(not player.notes_button.visible, "timed simulations should hide the notes tool")
|
player._refresh_toolbar_state()
|
||||||
|
_assert(player.calculator_button.visible, "%s question sessions should expose the calculator tool" % session_kind)
|
||||||
root.get_node("BlockSessionService").session_kind = "core"
|
root.get_node("BlockSessionService").session_kind = "core"
|
||||||
player._refresh_toolbar_state()
|
player._refresh_toolbar_state()
|
||||||
_assert(player.notes_button.visible, "practice question runs should show the notes tool")
|
_assert(player.notes_button.visible, "practice question runs should show the notes tool")
|
||||||
|
root.get_node("BlockSessionService").session_kind = "simulation"
|
||||||
|
player._refresh_toolbar_state()
|
||||||
|
_assert(not player.notes_button.visible, "timed simulations should hide the notes tool")
|
||||||
root.get_node("BlockSessionService").session_kind = prior_session_kind
|
root.get_node("BlockSessionService").session_kind = prior_session_kind
|
||||||
player.choice_followup_panels[2].visible = true
|
player.choice_followup_panels[2].visible = true
|
||||||
player.locked_wrong_choice_ids = ["C"]
|
player.locked_wrong_choice_ids = ["C"]
|
||||||
|
|||||||
Reference in New Issue
Block a user