@@ -6,6 +6,7 @@ const Cloze := preload("res://scripts/ui/flashcard_cloze.gd")
const CircularProgress := preload ( " res://scripts/ui/circular_progress.gd " )
const MASTERY_STABILITY_LABEL := " Cards graduate to \" mastered \" once they've held up in review for a few weeks straight. "
const PROGRESS_PIP_COUNT := 5
var main
@@ -24,7 +25,26 @@ var awaiting_next := false
var due_remaining := 0
var new_remaining := 0
# Session history: every card that leaves the "live" slot (graded, skipped, buried, or
# suspended) is pushed here, so Previous/Next can page back through what happened this session.
# Invariant: history_cursor == session_history.size() means "at the live card"; anything less
# means we're viewing a past entry in read-only mode.
var session_history : Array = [ ]
var history_cursor := 0
var session_total := - 1
var shuffle_enabled := false
# Shadow copy of the true in-flight card, populated only while browsing history so the live
# card is never lost and can be restored exactly via _return_to_live().
var live_note : Dictionary = { }
var live_card : Dictionary = { }
var live_rating_options : Array = [ ]
var live_revealed := false
var live_due_remaining := 0
var live_new_remaining := 0
var progress_label : Label
var progress_pips : Array = [ ]
var paused_banner : Label
var main_row : HBoxContainer
var card_panel : PanelContainer
@@ -34,13 +54,19 @@ var answer_caption: Label
var back_label : Label
var extra_caption : Label
var extra_label : Label
var card_position_label : Label
var reveal_button : Button
var grade_row : HBoxContainer
var grade_buttons : Dictionary = { }
var undo_button : Button
var previous_button : Button
var next_button : Button
var shuffle_button : Button
var options_button : Button
var options_menu : PanelContainer
var options_backdrop : Control
var options_bury_button : Button
var options_suspend_button : Button
var empty_label : Label
var compact_layout := false
@@ -82,6 +108,9 @@ func _ready() -> void:
func on_show ( payload : Variant = null ) - > void :
deck_id = str ( payload ) if payload != null else " "
seen_card_ids . clear ( )
session_history . clear ( )
history_cursor = 0
session_total = - 1
study_day_start_utc = FlashcardService . current_study_day_start_utc ( )
current_note = { }
current_card = { }
@@ -91,6 +120,7 @@ func on_show(payload: Variant = null) -> void:
undo_button . visible = false
_close_options_menu ( )
_render_loading ( )
_update_footer_button_states ( )
_load_next ( )
FlashcardService . fetch_decks ( )
FlashcardService . fetch_counts ( )
@@ -114,6 +144,17 @@ func _build() -> void:
progress_label . add_theme_font_size_override ( " font_size " , 15 )
top_row . add_child ( progress_label )
var pips_row := HBoxContainer . new ( )
pips_row . alignment = BoxContainer . ALIGNMENT_CENTER
pips_row . add_theme_constant_override ( " separation " , 6 )
root . add_child ( pips_row )
progress_pips = [ ]
for i in PROGRESS_PIP_COUNT :
var pip := PanelContainer . new ( )
pip . custom_minimum_size = Vector2 ( 48 , 6 )
pips_row . add_child ( pip )
progress_pips . append ( pip )
paused_banner = Label . new ( )
paused_banner . horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
paused_banner . add_theme_color_override ( " font_color " , Style . AMBER )
@@ -188,6 +229,16 @@ func _build() -> void:
extra_label . visible = false
extra_block . add_child ( extra_label )
var position_row := HBoxContainer . new ( )
card_content . add_child ( position_row )
var position_spacer := Control . new ( )
position_spacer . size_flags_horizontal = Control . SIZE_EXPAND_FILL
position_row . add_child ( position_spacer )
card_position_label = Label . new ( )
card_position_label . add_theme_font_size_override ( " font_size " , 12 )
card_position_label . visible = false
position_row . add_child ( card_position_label )
reveal_button = Button . new ( )
reveal_button . text = " Show Answer (Space) "
reveal_button . custom_minimum_size = Vector2 ( 0 , 52 )
@@ -208,23 +259,44 @@ func _build() -> void:
grade_row . add_child ( button )
grade_buttons [ spec [ 0 ] ] = button
var footer_row := HBoxContainer . new ( )
footer_row . add_theme_constant_override ( " separation " , 10 )
center_column . add_child ( footer_row )
undo_button = Button . new ( )
undo_button . text = " Undo last review "
undo_button . custom_minimum_size = Vector2 ( 0 , 36 )
undo_button . text = " Undo this rating "
undo_button . custom_minimum_size = Vector2 ( 0 , 40 )
undo_button . visible = false
undo_button . pressed . connect ( _undo )
footer_row . add_child ( undo_button )
var footer_spacer := Control . new ( )
footer_spacer . size_flags_horizontal = Control . SIZE_EXPAND_FILL
footer_row . add_child ( footer_spacer )
center_column . add_child ( undo_button )
var footer_center : = CenterContainer . new ( )
center_column . add_child ( footer_center )
var footer_row := HBoxContainer . new ( )
footer_row . add_theme_constant_override ( " separation " , 10 )
footer_center . add_child ( footer_row )
previous_button = Button . new ( )
previous_button . text = " ‹ Previous"
previous_button . custom_minimum_size = Vector2 ( 120 , 44 )
previous_button . pressed . connect ( _go_previous )
footer_row . add_child ( previous_button )
shuffle_button = Button . new ( )
shuffle_button . text = " Shuffle "
shuffle_button . toggle_mode = true
shuffle_button . custom_minimum_size = Vector2 ( 110 , 44 )
shuffle_button . toggled . connect ( func ( pressed : bool ) : shuffle_enabled = pressed )
footer_row . add_child ( shuffle_button )
_build_options_menu ( footer_row )
next_button = Button . new ( )
next_button . text = " Next › "
next_button . custom_minimum_size = Vector2 ( 120 , 44 )
next_button . pressed . connect ( _go_next )
footer_row . add_child ( next_button )
_build_stats_panel ( )
refresh_theme_controls ( )
_update_footer_button_states ( )
func _caption_label ( text : String ) - > Label :
@@ -267,6 +339,13 @@ func _side_panel_box(dark: bool) -> StyleBoxFlat:
return box
func _pip_box ( filled : bool , dark : bool ) - > StyleBoxFlat :
var box := StyleBoxFlat . new ( )
box . bg_color = Brand . ROYAL_BLUE if filled else ( Color ( " 26324a " ) if dark else Color ( " e2e8f0 " ) )
box . set_corner_radius_all ( 3 )
return box
func _panel_caption ( text : String ) - > Label :
var label := Label . new ( )
label . text = text
@@ -279,6 +358,7 @@ func _panel_caption(text: String) -> Label:
func _build_deck_panel ( ) - > void :
deck_panel = PanelContainer . new ( )
deck_panel . custom_minimum_size = Vector2 ( 220 , 0 )
deck_panel . size_flags_vertical = Control . SIZE_SHRINK_BEGIN
main_row . add_child ( deck_panel )
var content := VBoxContainer . new ( )
content . add_theme_constant_override ( " separation " , 10 )
@@ -315,6 +395,7 @@ func _build_deck_panel() -> void:
func _build_stats_panel ( ) - > void :
stats_panel = PanelContainer . new ( )
stats_panel . custom_minimum_size = Vector2 ( 220 , 0 )
stats_panel . size_flags_vertical = Control . SIZE_SHRINK_BEGIN
main_row . add_child ( stats_panel )
var content := VBoxContainer . new ( )
content . add_theme_constant_override ( " separation " , 10 )
@@ -373,7 +454,7 @@ func _stat_row(parent: VBoxContainer, caption: String) -> Label:
return value
# ---------- Options menu (suspend / bury current card) ----------
# ---------- Options menu ----------
func _build_options_menu ( footer_row : HBoxContainer ) - > void :
options_backdrop = Control . new ( )
@@ -388,7 +469,7 @@ func _build_options_menu(footer_row: HBoxContainer) -> void:
options_button = Button . new ( )
options_button . text = " Options "
options_button . custom_minimum_size = Vector2 ( 0 , 36 )
options_button . custom_minimum_size = Vector2 ( 0 , 44 )
options_button . pressed . connect ( _toggle_options_menu )
footer_row . add_child ( options_button )
@@ -399,27 +480,31 @@ func _build_options_menu(footer_row: HBoxContainer) -> void:
var list := VBoxContainer . new ( )
list . add_theme_constant_override ( " separation " , 2 )
options_menu . add_child ( list )
for item in [
[ " Bury card until tomorrow " , func ( ) : _bury_current_card ( ) ] ,
[ " Suspend card " , func ( ) : _suspend_current_card ( ) ] ,
] :
var button := Button . new ( )
button . text = str ( item [ 0 ] )
button . custom_minimum_size = Vector2 ( 200 , 40 )
button . alignment = HORIZONTAL_ALIGNMENT_LEFT
var action : Callable = item [ 1 ]
button . pressed . connect ( func ( ) :
_close_options_menu ( )
action . call ( ) )
list . add_child ( button )
_options_menu_item ( list , " Manage Flashcards " , func ( ) : main . show_screen ( " flashcards_hub " ) )
options_bury_button = _options_menu_item ( list , " Bury card until tomorrow " , func ( ) : _bury_current_card ( ) )
options_suspend_button = _options_menu_item ( list , " Suspend card " , func ( ) : _suspend_current_card ( ) )
func _options_menu_item ( list : VBoxContainer , text : String , action : Callable ) - > Button :
var button : = Button . new ( )
button . text = text
button . custom_minimum_size = Vector2 ( 200 , 40 )
button . alignment = HORIZONTAL_ALIGNMENT_LEFT
button . pressed . connect ( func ( ) :
_close_options_menu ( )
action . call ( ) )
list . add_child ( button )
return button
func _toggle_options_menu ( ) - > void :
if options_menu . visible :
_close_options_menu ( )
return
if current_card . is_empty ( ) :
return
var at_live_with_card := history_cursor == session_history . size ( ) and not current_card . is_empty ( )
options_bury_button . disabled = not at_live_with_card
options_suspend_button . disabled = not at_live_with_card
options_menu . visible = true
options_backdrop . visible = true
options_menu . position = options_button . get_global_position ( ) - Vector2 ( 0 , options_menu . size . y + 4 )
@@ -433,10 +518,11 @@ func _close_options_menu() -> void:
func _bury_current_card ( ) - > void :
if current_card . is_empty ( ) :
if history_cursor != session_history . size ( ) or current_card . is_empty ( ) :
return
var card_id := str ( current_card . get ( " id " , " " ) )
FlashcardService . bury_cards ( [ card_id ] , _bury_until_tomorrow_utc ( ) )
_push_history ( " buried " , card_id )
if not seen_card_ids . has ( card_id ) :
seen_card_ids . append ( card_id )
_load_next ( )
@@ -444,10 +530,11 @@ func _bury_current_card() -> void:
func _suspend_current_card ( ) - > void :
if current_card . is_empty ( ) :
if history_cursor != session_history . size ( ) or current_card . is_empty ( ) :
return
var card_id := str ( current_card . get ( " id " , " " ) )
FlashcardService . suspend_cards ( [ card_id ] , true )
_push_history ( " suspended " , card_id )
if not seen_card_ids . has ( card_id ) :
seen_card_ids . append ( card_id )
_load_next ( )
@@ -469,10 +556,18 @@ func refresh_theme_controls() -> void:
answer_caption . add_theme_color_override ( " font_color " , Brand . ROYAL_BLUE if dark else Brand . PURPLE )
extra_caption . add_theme_color_override ( " font_color " , Brand . muted_color ( dark ) )
extra_label . add_theme_color_override ( " font_color " , Brand . muted_color ( dark ) )
card_position_label . add_theme_color_override ( " font_color " , Brand . muted_color ( dark ) )
_style_ghost_button ( undo_button , dark )
previous_button . add_theme_stylebox_override ( " normal " , Brand . secondary_button_box ( dark ) )
previous_button . add_theme_stylebox_override ( " hover " , Brand . secondary_button_box ( dark , true ) )
next_button . add_theme_stylebox_override ( " normal " , Brand . secondary_button_box ( dark ) )
next_button . add_theme_stylebox_override ( " hover " , Brand . secondary_button_box ( dark , true ) )
options_button . add_theme_stylebox_override ( " normal " , Brand . secondary_button_box ( dark ) )
options_button . add_theme_stylebox_override ( " hover " , Brand . secondary_button_box ( dark , true ) )
deck_panel . add_theme_stylebox_override ( " panel " , _side_panel_box ( dark ) )
stats_panel . add_theme_stylebox_override ( " panel " , _side_panel_box ( dark ) )
options_menu . add_theme_stylebox_override ( " panel " , Brand . panel_box ( dark ) )
_update_progress_pips ( history_cursor )
_refresh_deck_panel ( )
_refresh_stats_panel ( )
@@ -548,9 +643,125 @@ func _refresh_stats_panel() -> void:
daily_goal_bar . value = float ( reviews_today )
# ---------- Session history / navigation ----------
func _push_history ( action : String , card_id : String , rating : String = " " , client_review_id : String = " " ) - > void :
var entry := {
" note " : current_note , " card " : current_card , " rating_options " : rating_options ,
" action " : action , " card_id " : card_id
}
if not rating . is_empty ( ) :
entry [ " rating " ] = rating
entry [ " client_review_id " ] = client_review_id
session_history . append ( entry )
history_cursor = session_history . size ( )
func _snapshot_live_before_browsing ( ) - > void :
live_note = current_note
live_card = current_card
live_rating_options = rating_options
live_revealed = revealed
live_due_remaining = due_remaining
live_new_remaining = new_remaining
func _return_to_live ( ) - > void :
history_cursor = session_history . size ( )
current_note = live_note
current_card = live_card
rating_options = live_rating_options
due_remaining = live_due_remaining
new_remaining = live_new_remaining
undo_button . visible = false
revealed = false
if current_note . is_empty ( ) and current_card . is_empty ( ) :
_render_empty ( { " dueRemaining " : due_remaining , " newRemaining " : new_remaining } )
else :
_render_card ( )
if live_revealed :
_reveal ( )
_update_card_position_label ( history_cursor )
_update_progress_pips ( history_cursor )
_update_footer_button_states ( )
func _render_history_entry ( index : int ) - > void :
var entry : Dictionary = session_history [ index ]
current_note = entry . get ( " note " , { } )
current_card = entry . get ( " card " , { } )
rating_options = entry . get ( " rating_options " , [ ] )
revealed = false
_render_card ( )
_reveal ( )
grade_row . visible = false
reveal_button . visible = false
var is_graded_entry : bool = entry . get ( " action " , " " ) == " graded "
var is_last_graded_card : bool = str ( entry . get ( " card_id " , " " ) ) == last_card_id
var has_undoable_review : bool = not last_client_review_id . is_empty ( )
undo_button . visible = is_graded_entry and is_last_graded_card and has_undoable_review
undo_button . disabled = false
undo_button . text = " Undo this rating "
_update_card_position_label ( index )
_update_progress_pips ( index )
_update_footer_button_states ( )
func _go_previous ( ) - > void :
if history_cursor < = 0 :
return
if history_cursor == session_history . size ( ) :
_snapshot_live_before_browsing ( )
history_cursor - = 1
_render_history_entry ( history_cursor )
func _go_next ( ) - > void :
if history_cursor > = session_history . size ( ) :
_skip_current_card ( )
return
history_cursor + = 1
if history_cursor > = session_history . size ( ) :
_return_to_live ( )
else :
_render_history_entry ( history_cursor )
func _skip_current_card ( ) - > void :
if current_card . is_empty ( ) or awaiting_next :
return
var card_id := str ( current_card . get ( " id " , " " ) )
_push_history ( " skipped " , card_id )
if not seen_card_ids . has ( card_id ) :
seen_card_ids . append ( card_id )
_load_next ( )
_render_loading ( )
func _update_card_position_label ( position_index : int ) - > void :
card_position_label . visible = session_total > 0
if session_total > 0 :
card_position_label . text = " Card %d of %d " % [ position_index + 1 , maxi ( session_total , position_index + 1 ) ]
func _update_progress_pips ( position_index : int ) - > void :
var dark := UIThemeService . is_dark_mode ( )
var fraction := 0.0 if session_total < = 0 else float ( position_index ) / float ( session_total )
var filled := clampi ( floori ( fraction * progress_pips . size ( ) ) , 0 , progress_pips . size ( ) )
for i in progress_pips . size ( ) :
var pip : PanelContainer = progress_pips [ i ]
pip . add_theme_stylebox_override ( " panel " , _pip_box ( i < filled , dark ) )
func _update_footer_button_states ( ) - > void :
var at_live := history_cursor == session_history . size ( )
previous_button . disabled = history_cursor < = 0
next_button . disabled = at_live and current_card . is_empty ( )
func _load_next ( ) - > void :
awaiting_next = true
FlashcardService . request_next_card ( deck_id , seen_card_ids , study_day_start_utc )
FlashcardService . request_next_card ( deck_id , seen_card_ids , study_day_start_utc , shuffle_enabled )
func _on_next_card_loaded ( card : Dictionary ) - > void :
@@ -561,15 +772,22 @@ func _on_next_card_loaded(card: Dictionary) -> void:
current_note = { }
current_card = { }
_render_empty ( card )
_update_footer_button_states ( )
return
current_note = note
current_card = schedule
rating_options = card . get ( " ratingOptions " , [ ] )
due_remaining = int ( card . get ( " dueRemaining " , 0 ) )
new_remaining = int ( card . get ( " newRemaining " , 0 ) )
if session_total < 0 :
session_total = due_remaining + new_remaining
history_cursor = session_history . size ( )
revealed = false
loaded_started_ms = Time . get_ticks_msec ( )
_render_card ( )
_update_card_position_label ( history_cursor )
_update_progress_pips ( history_cursor )
_update_footer_button_states ( )
func _on_next_card_failed ( message : String ) - > void :
@@ -652,15 +870,15 @@ func _interval_label_for(rating: String) -> String:
func _grade ( rating : String ) - > void :
if current_card . is_empty ( ) :
if current_card . is_empty ( ) or history_cursor != session_history . size ( ) :
return
var card_id := str ( current_card . get ( " id " , " " ) )
var expected_revision := int ( current_card . get ( " revision " , 0 ) )
var duration_seconds := int ( float ( Time . get_ticks_msec ( ) - loaded_started_ms ) / 1000.0 )
last_client_review_id = FlashcardService . submit_review ( card_id , rating , expected_revision , duration_seconds , study_day_start_utc )
var client_review_id : = FlashcardService . submit_review ( card_id , rating , expected_revision , duration_seconds , study_day_start_utc )
last_client_review_id = client_review_id
last_card_id = card_id
undo_button . visible = true
undo_button . disabled = false
_push_history ( " graded " , card_id , rating , client_review_id )
if not seen_card_ids . has ( card_id ) :
seen_card_ids . append ( card_id )
_load_next ( )
@@ -677,10 +895,13 @@ func _undo() -> void:
return
undo_button . disabled = true
FlashcardService . undo_last_review ( last_client_review_id , func ( _restored : Dictionary ) :
var undone_card_id := last_card_id
last_client_review_id = " "
undo_button . visible = false
seen_card_ids . erase ( last_card_id )
last_card_id = " "
seen_card_ids . erase ( undone_card_id )
_return_to_live ( )
_load_next ( )
_render_loading ( )
FlashcardService . fetch_counts ( ) )
@@ -700,6 +921,8 @@ func _input(event: InputEvent) -> void:
return
if not ( event is InputEventKey ) or not event . pressed or event . echo :
return
if history_cursor != session_history . size ( ) :
return
if not revealed :
if event . keycode == KEY_SPACE :
_reveal ( )