Surn commited on
Commit
2378444
·
1 Parent(s): 99df003

Implement pop-up as streamlit dialog

Browse files
Files changed (1) hide show
  1. battlewords/ui.py +107 -189
battlewords/ui.py CHANGED
@@ -151,7 +151,7 @@ def inject_styles() -> None:
151
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto;}
152
 
153
  div[data-testid="column"], .st-emotion-cache-zh2fnc { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; }
154
- .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.1rem !important; min-height: 2.5rem; min-width: 2.5rem;}
155
 
156
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
157
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
@@ -905,18 +905,18 @@ def _render_hit_miss(state: GameState):
905
  # Render as a circular radio group, side-by-side
906
  st.markdown(
907
  f"""
908
- <div class="bw-radio-group" role="radiogroup" aria-label="Hit or Miss">
909
- <div class="bw-radio-item">
910
- <div class="bw-radio-circle {'active hit' if is_hit else ''}" role="radio" aria-checked="{'true' if is_hit else 'false'}" aria-label="Hit">
911
- <span class="dot"></span>
912
  </div>
913
- <div class="bw-radio-caption">HIT</div>
914
  </div>
915
- <div class="bw-radio-item">
916
- <div class="bw-radio-circle {'active miss' if is_miss else ''}" role="radio" aria-checked="{'true' if is_miss else 'false'}" aria-label="Miss">
917
- <span class="dot"></span>
918
  </div>
919
- <div class="bw-radio-caption">MISS</div>
920
  </div>
921
  </div>
922
  """,
@@ -941,18 +941,18 @@ def _render_correct_try_again(state: GameState):
941
 
942
  st.markdown(
943
  f"""
944
- <div class="bw-radio-group" role="radiogroup" aria-label="Correct or Try Again">
945
- <div class="bw-radio-item">
946
- <div class="bw-radio-circle {'active hit' if is_correct else ''}" role="radio" aria-checked="{'true' if is_correct else 'false'}" aria-label="Correct">
947
- <span class="dot"></span>
948
  </div>
949
- <div class="bw-radio-caption{' inactive' if not is_correct else ''}">CORRECT!</div>
950
  </div>
951
- <div class="bw-radio-item">
952
- <div class="bw-radio-circle {'active miss' if is_try_again else ''}" role="radio" aria-checked="{'true' if is_try_again else 'false'}" aria-label="Try Again">
953
- <span class="dot"></span>
954
  </div>
955
- <div class="bw-radio-caption{' inactive' if not is_try_again else ''}">TRY AGAIN</div>
956
  </div>
957
  </div>
958
  """,
@@ -1013,14 +1013,10 @@ def _render_score_panel(state: GameState):
1013
  )
1014
  st.markdown(f"<div class='bw-score-panel-container'>{table_html}</div>", unsafe_allow_html=True)
1015
 
1016
- # -------------------- Game Over Modal --------------------
1017
- def _render_game_over(state: GameState):
1018
- import streamlit.components.v1 as components
1019
-
1020
- # Determine visibility
1021
- visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state)
1022
 
1023
- # Build table body HTML for modal content
 
1024
  word_rows = []
1025
  for w in state.puzzle.words:
1026
  pts = st.session_state.points_by_word.get(w.text, 0)
@@ -1028,8 +1024,9 @@ def _render_game_over(state: GameState):
1028
  word_rows.append(
1029
  f"<tr><td class=\"blue-background\">{w.text}</td><td class=\"blue-background\">{len(w.text)}</td><td class=\"blue-background\">{extra_pts}</td></tr>"
1030
  )
 
1031
  table_html = (
1032
- "<table class=\"table table-sm table-dark table-striped mb-0 shiny-border\" style=\"border-radius:0.75rem; overflow:hidden;\">"
1033
  "<thead><tr>"
1034
  "<th scope=\"col\">Word</th>"
1035
  "<th scope=\"col\">Letters</th>"
@@ -1041,168 +1038,89 @@ def _render_game_over(state: GameState):
1041
  "</table>"
1042
  )
1043
 
1044
- # Server-driven initial modal state for CSS class/style
1045
- modal_initial_class = "modal fade show" if visible else "modal fade"
1046
- modal_initial_style = "display:block;" if visible else ""
1047
-
1048
- # Modal markup inside iframe with wrapper div for scoping
1049
- bootstrap_html = f"""
1050
- <style>
1051
- body {{
1052
- background-color: transparent !important;
1053
- }}
1054
- /* Ensure shiny-border is available inside iframe scope */
1055
- .shiny-border {{ position: relative; padding: 12px; color: white; border-radius: 1.25rem; overflow: hidden; }}
1056
- .shiny-border::before {{ content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: left 0.5s; }}
1057
- .bw-modal-iframe-root {{ position: fixed; inset: 0; background: rgba(29, 100, 200, 0.5); /* iframe page bg */ }}
1058
- /* Make modal background transparent */
1059
- .modal-content {{ background-color: transparent !important; box-shadow: none !important; width: calc(100% - 16px) !important;}}
1060
- .modal-backdrop{{ background-color: transparent !important; z-index:999 !important;}}
1061
- .bw-modal-iframe-root{{ z-index: 1000; }}
1062
- .hide {{ display: none !important; z-index: -1000 !important;}}
1063
-
1064
- /* Center the modal content */
1065
- .modal-dialog {{
1066
- display: flex;
1067
- align-items: center;
1068
- min-height: calc(100% - 1rem);
1069
- margin: 0.5rem auto;
1070
- border-radius: 0.75rem;
1071
- max-width: 100%;
1072
- }}
1073
- .modal-title, .text-success {{
1074
- font-weight: bold;
1075
- font-size: 1.5rem;
1076
- text-align: center;
1077
- flex: auto;
1078
- filter:drop-shadow(1px 1px 2px #003);
1079
- }}
1080
- .modal-content {{
1081
- width: 100%;
1082
- max-width: 640px;
1083
- margin: 0 auto;
1084
- border-radius: 0.75rem;
1085
- overflow: hidden;
1086
- }}
1087
- </style>
1088
- <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\">\n
1089
- <div class=\"bw-modal-iframe-root bw-bootstrap-scope\">\n
1090
- <!-- Remove static aria-hidden to avoid focus conflicts; let Bootstrap manage it -->
1091
- <div class=\"{modal_initial_class}\" id=\"bwGameOverModal\" tabindex=\"-1\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"bwGameOverTitle\" data-bs-backdrop=\"true\" style=\"{modal_initial_style}\">\n
1092
- <div class=\"modal-dialog modal-dialog-centered modal-lg\">\n <div class=\"modal-content\">\n
1093
- <div class=\"modal-body p-0\">\n <div class=\"shiny-border\" style=\"border-radius:1rem; box-shadow:0 0 32px #1d64c8; background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666); color:#fff;\">\n <div class=\"d-flex justify-content-between align-items-center px-3 pt-3\">\n <h5 class=\"modal-title m-0\" id=\"bwGameOverTitle\">Game Over</h5>\n <button type=\"button\" class=\"btn-close btn-close-white\" data-bs-dismiss=\"modal\" aria-label=\"Close\"><span class=\"visually-hidden\">Close</span></button>\n </div>\n <div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>\n <div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>\n <div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>\n <div class=\"mb-2\">Game Mode: <strong>{state.game_mode}</strong></div>\n <div class=\"mb-2\">Wordlist: <strong>{st.session_state.get('selected_wordlist', '')}</strong></div>\n <div class=\"mb-0\">{table_html}</div>\n </div>\n </div>\n </div>\n
1094
- </div>\n </div>\n
1095
- </div>\n
1096
- </div>\n
1097
- <script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\"></script>\n
1098
- <script>
1099
- // Human-readable, commented JavaScript to control the Bootstrap modal inside the iframe
1100
- (function() {{
1101
- // Acquire the modal element by id within this iframe
1102
- var modalEl = document.getElementById('bwGameOverModal');
1103
- if (!modalEl) {{
1104
- console.warn('[BW Modal] #bwGameOverModal not found');
1105
- return;
1106
- }}
1107
-
1108
- // Create or get a Bootstrap Modal controller for this element
1109
- var m = bootstrap.Modal.getOrCreateInstance(modalEl, {{
1110
- backdrop: true,
1111
- keyboard: true
1112
- }});
1113
-
1114
- // Server-driven visibility state (computed in Python)
1115
- var shouldShow = {str(visible).lower()};
1116
-
1117
- // Show or hide based on server state
1118
- if (shouldShow) {{
1119
- // Ensure visible and let Bootstrap handle classes/backdrop
1120
- modalEl.style.display = 'block';
1121
- console.debug('[BW Modal] Showing modal');
1122
- try {{ m.show(); }} catch(e) {{ console.warn('[BW Modal] show() failed', e); }}
1123
- }} else {{
1124
- // Hide if currently shown
1125
- if (modalEl.classList.contains('show')) {{
1126
- console.debug('[BW Modal] Hiding modal');
1127
- try {{ m.hide(); }} catch(e) {{ console.warn('[BW Modal] hide() failed', e); }}
1128
- }}
1129
- // Reset styles after hide
1130
- modalEl.classList.remove('show');
1131
- modalEl.style.display = '';
1132
- }}
1133
-
1134
- // Helper to notify the parent page (Streamlit) to toggle wrapper around this iframe
1135
- function notifyParent(visible) {{
1136
- try {{
1137
- window.parent.postMessage({{
1138
- type: 'bw-bootstrap-modal-visibility',
1139
- visible: visible
1140
- }}, '*');
1141
- }} catch (e) {{
1142
- console.warn('[BW Modal] notifyParent failed', e);
1143
- }}
1144
- }}
1145
-
1146
- // Send initial visibility to parent
1147
- notifyParent(shouldShow);
1148
-
1149
- // On show, remove any aria-hidden that might conflict and set focus to close button
1150
- modalEl.addEventListener('show.bs.modal', function() {{
1151
- try {{ modalEl.removeAttribute('aria-hidden'); }} catch (e) {{}}
1152
- }});
1153
-
1154
- modalEl.addEventListener('shown.bs.modal', function() {{
1155
- try {{
1156
- var btn = modalEl.querySelector('.btn-close');
1157
- if (btn) btn.focus();
1158
- }} catch (e) {{}}
1159
- notifyParent(true);
1160
- }});
1161
-
1162
- // Utility to clear focus to avoid aria-hidden focus conflicts
1163
- function blurActive() {{
1164
- try {{
1165
- if (document.activeElement) document.activeElement.blur();
1166
- }} catch (e) {{}}
1167
- }}
1168
-
1169
- // On hide start, clear focus
1170
- modalEl.addEventListener('hide.bs.modal', function() {{
1171
- blurActive();
1172
- }});
1173
-
1174
- // On fully hidden, let Streamlit know and parent wrapper update
1175
- function closeActions() {{
1176
- try {{
1177
- blurActive();
1178
- window.parent.postMessage({{
1179
- type: 'streamlit:setComponentValue',
1180
- key: 'show_gameover_overlay',
1181
- value: false
1182
- }}, '*');
1183
- }} catch (e) {{}}
1184
- notifyParent(false);
1185
- }}
1186
- modalEl.addEventListener('hidden.bs.modal', closeActions);
1187
-
1188
- // Close if backdrop clicked (click outside modal content)
1189
- modalEl.addEventListener('click', function(ev) {{
1190
- if (ev.target === modalEl) {{
1191
- try {{ m.hide(); }} catch (e) {{}}
1192
- }}
1193
- }});
1194
-
1195
- // Note: Streamlit components use an iframe sandbox that may include allow-scripts and allow-same-origin.
1196
- // Browsers may warn that this combination allows escape of sandboxing; this is expected for Streamlit components.
1197
- }})();
1198
- </script>\n
1199
- """
1200
-
1201
- # Mount the iframe; keep zero height when hidden, parent bridge will hide wrapper too
1202
- components.html(bootstrap_html, height=(720 if visible else 0), scrolling=False, width=720)
1203
-
1204
- # Ensure parent bridge exists to control wrapper visibility/position
1205
- _inject_parent_modal_bridge()
1206
 
1207
  def _sort_wordlist(filename):
1208
  import time # Add this import
 
151
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto;}
152
 
153
  div[data-testid="column"], .st-emotion-cache-zh2fnc { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; }
154
+ .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.15rem !important; min-height: 2.5rem; min-width: 2.5rem;}
155
 
156
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
157
  .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
 
905
  # Render as a circular radio group, side-by-side
906
  st.markdown(
907
  f"""
908
+ <div class=\"bw-radio-group\" role=\"radiogroup\" aria-label=\"Hit or Miss\">
909
+ <div class=\"bw-radio-item\">
910
+ <div class=\"bw-radio-circle {'active hit' if is_hit else ''}\" role=\"radio\" aria-checked=\"{'true' if is_hit else 'false'}\" aria-label=\"Hit\">
911
+ <span class=\"dot\"></span>
912
  </div>
913
+ <div class=\"bw-radio-caption\">HIT</div>
914
  </div>
915
+ <div class=\"bw-radio-item\">
916
+ <div class=\"bw-radio-circle {'active miss' if is_miss else ''}\" role=\"radio\" aria-checked=\"{'true' if is_miss else 'false'}\" aria-label=\"Miss\">
917
+ <span class=\"dot\"></span>
918
  </div>
919
+ <div class=\"bw-radio-caption\">MISS</div>
920
  </div>
921
  </div>
922
  """,
 
941
 
942
  st.markdown(
943
  f"""
944
+ <div class=\"bw-radio-group\" role=\"radiogroup\" aria-label=\"Correct or Try Again\">
945
+ <div class=\"bw-radio-item\">
946
+ <div class=\"bw-radio-circle {'active hit' if is_correct else ''}\" role=\"radio\" aria-checked=\"{'true' if is_correct else 'false'}\" aria-label=\"Correct\">
947
+ <span class=\"dot\"></span>
948
  </div>
949
+ <div class=\"bw-radio-caption{' inactive' if not is_correct else ''}\">CORRECT!</div>
950
  </div>
951
+ <div class=\"bw-radio-item\">
952
+ <div class=\"bw-radio-circle {'active miss' if is_try_again else ''}\" role=\"radio\" aria-checked=\"{'true' if is_try_again else 'false'}\" aria-label=\"Try Again\">
953
+ <span class=\"dot\"></span>
954
  </div>
955
+ <div class=\"bw-radio-caption{' inactive' if not is_try_again else ''}\">TRY AGAIN</div>
956
  </div>
957
  </div>
958
  """,
 
1013
  )
1014
  st.markdown(f"<div class='bw-score-panel-container'>{table_html}</div>", unsafe_allow_html=True)
1015
 
1016
+ # -------------------- Game Over Dialog --------------------
 
 
 
 
 
1017
 
1018
+ def _game_over_content(state: GameState) -> None:
1019
+ # Build table body HTML for dialog content
1020
  word_rows = []
1021
  for w in state.puzzle.words:
1022
  pts = st.session_state.points_by_word.get(w.text, 0)
 
1024
  word_rows.append(
1025
  f"<tr><td class=\"blue-background\">{w.text}</td><td class=\"blue-background\">{len(w.text)}</td><td class=\"blue-background\">{extra_pts}</td></tr>"
1026
  )
1027
+
1028
  table_html = (
1029
+ "<table class=\"shiny-border\" style=\"border-radius:0.75rem; overflow:hidden; width:100%; margin:0 auto; border-collapse:separate; border-spacing:0;\">"
1030
  "<thead><tr>"
1031
  "<th scope=\"col\">Word</th>"
1032
  "<th scope=\"col\">Letters</th>"
 
1038
  "</table>"
1039
  )
1040
 
1041
+ # Optional extra styles for this dialog content
1042
+ st.markdown(
1043
+ """
1044
+ <style>
1045
+ .bw-dialog-container {
1046
+ border-radius: 1rem;
1047
+ box-shadow: 0 0 32px #1d64c8;
1048
+ background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);
1049
+ color: #fff;
1050
+ padding: 16px;
1051
+ }
1052
+ .bw-dialog-header { display:flex; justify-content: space-between; align-items:center; }
1053
+ .bw-dialog-title, .st-emotion-cache-11elpad p { margin: 0; font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003);}
1054
+ .text-success { color: #20d46c;font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003); }
1055
+ .st-key-new_game_btn_dialog, .st-key-close_game_over { width: 50% !important;
1056
+ height: auto;
1057
+ min-width: unset !important;
1058
+ margin: 25px auto 0;
1059
+ }
1060
+ .st-key-new_game_btn_dialog button, .st-key-close_game_over button {
1061
+ height: 50px !important;
1062
+ }
1063
+ .st-key-new_game_btn_dialog:hover, .st-key-close_game_over:hover{
1064
+ /*background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);*/
1065
+ background: #1d64c8 !important;
1066
+ /*filter:drop-shadow(1px 1px 2px #003);*/
1067
+ /*filter: invert(1);*/
1068
+ }
1069
+ .st-bb {background-color: rgba(29, 100, 200, 0.5);}
1070
+ </style>
1071
+ """,
1072
+ unsafe_allow_html=True,
1073
+ )
1074
+
1075
+ st.markdown(
1076
+ f"""
1077
+ <div class=\"bw-dialog-container shiny-border\">
1078
+ <div class=\"bw-dialog-header\">
1079
+ <h5 class=\"bw-dialog-title\">Game Over</h5>
1080
+ </div>
1081
+ <div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>
1082
+ <div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>
1083
+ <div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>
1084
+ <div class=\"mb-2\">Game Mode: <strong>{state.game_mode}</strong></div>
1085
+ <div class=\"mb-2\">Wordlist: <strong>{st.session_state.get('selected_wordlist', '')}</strong></div>
1086
+ <div class=\"mb-0\">{table_html}</div>
1087
+ </div>
1088
+ </div>
1089
+ """,
1090
+ unsafe_allow_html=True,
1091
+ )
1092
+
1093
+ # Dialog actions
1094
+ cols = st.columns([1, 1])
1095
+ with cols[0]:
1096
+ if st.button("Close", key="close_game_over"):
1097
+ st.session_state["show_gameover_overlay"] = False
1098
+ st.rerun()
1099
+ with cols[1]:
1100
+ st.button("New Game", key="new_game_btn_dialog", on_click=_new_game)
1101
+
1102
+ # Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable
1103
+ _Dialog = getattr(st, "dialog", getattr(st, "experimental_dialog", None))
1104
+ if _Dialog:
1105
+ @_Dialog("Game Over")
1106
+ def _game_over_dialog(state: GameState):
1107
+ _game_over_content(state)
1108
+ else:
1109
+ def _game_over_dialog(state: GameState):
1110
+ modal_ctx = getattr(st, "modal", None)
1111
+ if callable(modal_ctx):
1112
+ with modal_ctx("Game Over"):
1113
+ _game_over_content(state)
1114
+ else:
1115
+ # Last-resort inline render
1116
+ st.subheader("Game Over")
1117
+ _game_over_content(state)
1118
+
1119
+ def _render_game_over(state: GameState):
1120
+ # Determine visibility
1121
+ visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state)
1122
+ if visible:
1123
+ _game_over_dialog(state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1124
 
1125
  def _sort_wordlist(filename):
1126
  import time # Add this import