Surn commited on
Commit
1d39d8d
·
1 Parent(s): b9c335d

Smoothing transitions

Browse files
Files changed (2) hide show
  1. battlewords/ui.py +71 -53
  2. battlewords/ui_helpers.py +113 -7
battlewords/ui.py CHANGED
@@ -28,24 +28,26 @@ from .audio import (
28
  )
29
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
30
  from .settings_page import render_settings_page
31
- from .ui_helpers import render_footer, inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, pwa_service_worker, show_spinner
32
 
33
  # --- Spinner context manager for custom spinner ---
34
  class CustomSpinner:
35
  """Context manager for showing custom spinner with wrdler.gif"""
36
- def __init__(self, placeholder, message: str = "Loading..."):
37
  self.placeholder = placeholder
38
  self.message = message
 
39
 
40
  def __enter__(self):
41
  with self.placeholder.container():
42
  show_spinner(self.message)
43
  #wait 1 sec to ensure spinner is visible
44
- time.sleep(0.8)
45
-
46
  return self
47
 
48
- def __exit__(self, *args):
 
 
49
  self.placeholder.empty()
50
 
51
  CoordLike = Tuple[int, int]
@@ -160,44 +162,46 @@ def _init_session() -> None:
160
  st.session_state.enable_sound_effects = False
161
 
162
  def _new_game() -> None:
163
- selected = st.session_state.get("selected_wordlist")
164
- mode = st.session_state.get("game_mode")
165
- show_grid_ticks = st.session_state.get("show_grid_ticks", False)
166
- spacer = st.session_state.get("spacer",1)
167
- show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
168
- # --- Preserve music and effects settings ---
169
- music_enabled = st.session_state.get("music_enabled", False)
170
- music_track_path = st.session_state.get("music_track_path")
171
- music_volume = st.session_state.get("music_volume",15)
172
- effects_volume = st.session_state.get("effects_volume",25)
173
- enable_sound_effects = st.session_state.get("enable_sound_effects", True)
174
- # NEW: Preserve Show Challenge Share Links
175
- show_challenge_share_links = st.session_state.get("show_challenge_share_links", False)
176
-
177
- st.session_state.clear()
178
- if selected:
179
- st.session_state.selected_wordlist = selected
180
- if mode:
181
- st.session_state.game_mode = mode
182
- st.session_state.show_grid_ticks = show_grid_ticks
183
- st.session_state.spacer = spacer
184
- st.session_state.show_incorrect_guesses = show_incorrect_guesses
185
- # --- Restore music/effects settings ---
186
- st.session_state.music_enabled = music_enabled
187
- if music_track_path:
188
- st.session_state.music_track_path = music_track_path
189
- st.session_state.music_volume = music_volume
190
- st.session_state.effects_volume = effects_volume
191
- st.session_state.enable_sound_effects = enable_sound_effects
192
- # NEW: Restore Show Challenge Share Links
193
- st.session_state.show_challenge_share_links = show_challenge_share_links
194
-
195
- st.session_state.radar_gif_path = None
196
- st.session_state.radar_gif_signature = None
197
- st.session_state.start_time = datetime.now() # Reset timer on new game
198
- st.session_state.end_time = None
199
- st.session_state.incorrect_guesses = [] # Clear incorrect guesses for new game
200
- _init_session()
 
 
201
 
202
 
203
  def _to_state() -> GameState:
@@ -765,6 +769,17 @@ def _render_guess_form(state: GameState):
765
  st.markdown(
766
  """
767
  <style>
 
 
 
 
 
 
 
 
 
 
 
768
  .bw-incorrect-guesses {
769
  font-size: 0.7rem;
770
  color: #ff9999;
@@ -1412,7 +1427,10 @@ def _on_game_option_change() -> None:
1412
 
1413
  def run_app():
1414
  # Render PWA service worker registration (meta tags in <head> via Docker)
1415
- st.markdown(pwa_service_worker, unsafe_allow_html=True)
 
 
 
1416
 
1417
  # try:
1418
  # st.set_page_config(initial_sidebar_state="collapsed")
@@ -1442,26 +1460,23 @@ def run_app():
1442
  spinner_placeholder = st.empty()
1443
  with CustomSpinner(spinner_placeholder, "Initial Load..."):
1444
  st.session_state["initial_page_loaded"] = True
1445
- st.session_state["last_nav_page"] = page
1446
- pass
1447
- st.rerun()
1448
 
1449
  # Show spinner when navigating via menu (query param page change)
1450
  last_nav_page = st.session_state.get("last_nav_page")
1451
  if last_nav_page is not None and last_nav_page != page:
1452
  spinner_placeholder = st.empty()
1453
  with CustomSpinner(spinner_placeholder, "Loading..."):
1454
- st.session_state["last_nav_page"] = page
1455
- pass
1456
- st.rerun()
1457
 
1458
  # One-time spinner on initial display of the main UI.
1459
  if page == "play" and not st.session_state.get("initial_page_loaded", False):
1460
  spinner_placeholder = st.empty()
1461
  with CustomSpinner(spinner_placeholder, "Loading Game..."):
1462
- st.session_state["initial_page_loaded"] = True
1463
- pass
1464
- st.rerun()
1465
 
1466
  if page == "settings":
1467
  spinner_placeholder = st.empty()
@@ -1537,6 +1552,7 @@ def _render_game_tab():
1537
  """
1538
  Render the main game tab layout.
1539
  """
 
1540
  _init_session()
1541
  st.markdown(ocean_background_css, unsafe_allow_html=True)
1542
  #inject_ocean_layers() # <-- add the animated layers
@@ -1569,3 +1585,5 @@ def _render_game_tab():
1569
  st.session_state.letter_map,
1570
  show_grid_ticks=st.session_state.get("show_grid_ticks", True),
1571
  )
 
 
 
28
  )
29
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
30
  from .settings_page import render_settings_page
31
+ from .ui_helpers import render_footer, inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, pwa_service_worker, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
32
 
33
  # --- Spinner context manager for custom spinner ---
34
  class CustomSpinner:
35
  """Context manager for showing custom spinner with wrdler.gif"""
36
+ def __init__(self, placeholder, message: str = "Loading...", duration: float = 1.0):
37
  self.placeholder = placeholder
38
  self.message = message
39
+ self.duration = duration
40
 
41
  def __enter__(self):
42
  with self.placeholder.container():
43
  show_spinner(self.message)
44
  #wait 1 sec to ensure spinner is visible
45
+ time.sleep(0.7)
 
46
  return self
47
 
48
+ def __exit__(self, *args):
49
+ # fade_out_spinner(self.duration)
50
+ # time.sleep(self.duration)
51
  self.placeholder.empty()
52
 
53
  CoordLike = Tuple[int, int]
 
162
  st.session_state.enable_sound_effects = False
163
 
164
  def _new_game() -> None:
165
+ spinner_placeholder = st.empty()
166
+ with CustomSpinner(spinner_placeholder, "Initializing New Game..."):
167
+ selected = st.session_state.get("selected_wordlist")
168
+ mode = st.session_state.get("game_mode")
169
+ show_grid_ticks = st.session_state.get("show_grid_ticks", False)
170
+ spacer = st.session_state.get("spacer",1)
171
+ show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
172
+ # --- Preserve music and effects settings ---
173
+ music_enabled = st.session_state.get("music_enabled", False)
174
+ music_track_path = st.session_state.get("music_track_path")
175
+ music_volume = st.session_state.get("music_volume",15)
176
+ effects_volume = st.session_state.get("effects_volume",25)
177
+ enable_sound_effects = st.session_state.get("enable_sound_effects", True)
178
+ # NEW: Preserve Show Challenge Share Links
179
+ show_challenge_share_links = st.session_state.get("show_challenge_share_links", False)
180
+
181
+ st.session_state.clear()
182
+ if selected:
183
+ st.session_state.selected_wordlist = selected
184
+ if mode:
185
+ st.session_state.game_mode = mode
186
+ st.session_state.show_grid_ticks = show_grid_ticks
187
+ st.session_state.spacer = spacer
188
+ st.session_state.show_incorrect_guesses = show_incorrect_guesses
189
+ # --- Restore music/effects settings ---
190
+ st.session_state.music_enabled = music_enabled
191
+ if music_track_path:
192
+ st.session_state.music_track_path = music_track_path
193
+ st.session_state.music_volume = music_volume
194
+ st.session_state.effects_volume = effects_volume
195
+ st.session_state.enable_sound_effects = enable_sound_effects
196
+ # NEW: Restore Show Challenge Share Links
197
+ st.session_state.show_challenge_share_links = show_challenge_share_links
198
+
199
+ st.session_state.radar_gif_path = None
200
+ st.session_state.radar_gif_signature = None
201
+ st.session_state.start_time = datetime.now() # Reset timer on new game
202
+ st.session_state.end_time = None
203
+ st.session_state.incorrect_guesses = [] # Clear incorrect guesses for new game
204
+ _init_session()
205
 
206
 
207
  def _to_state() -> GameState:
 
769
  st.markdown(
770
  """
771
  <style>
772
+ /* Grey-out the guess textbox when disabled */
773
+ .st-key-guess_input input:disabled {
774
+ background-color: #ffffff66 !important;
775
+ color: #ffffff !important;
776
+ -webkit-text-fill-color: #ffffff !important;
777
+ opacity: 1 !important;
778
+ }
779
+ /* Match the disabled textbox border to the label color */
780
+ .st-key-guess_input input:disabled {
781
+ border-color: #ffffff !important;
782
+ }
783
  .bw-incorrect-guesses {
784
  font-size: 0.7rem;
785
  color: #ff9999;
 
1427
 
1428
  def run_app():
1429
  # Render PWA service worker registration (meta tags in <head> via Docker)
1430
+ # Streamlit reruns the script frequently; inject this only once per session.
1431
+ if not st.session_state.get("pwa_injected", False):
1432
+ st.markdown(pwa_service_worker, unsafe_allow_html=True)
1433
+ st.session_state["pwa_injected"] = True
1434
 
1435
  # try:
1436
  # st.set_page_config(initial_sidebar_state="collapsed")
 
1460
  spinner_placeholder = st.empty()
1461
  with CustomSpinner(spinner_placeholder, "Initial Load..."):
1462
  st.session_state["initial_page_loaded"] = True
1463
+ st.session_state["last_nav_page"] = page
1464
+ st.rerun()
 
1465
 
1466
  # Show spinner when navigating via menu (query param page change)
1467
  last_nav_page = st.session_state.get("last_nav_page")
1468
  if last_nav_page is not None and last_nav_page != page:
1469
  spinner_placeholder = st.empty()
1470
  with CustomSpinner(spinner_placeholder, "Loading..."):
1471
+ st.session_state["last_nav_page"] = page
1472
+ st.rerun()
 
1473
 
1474
  # One-time spinner on initial display of the main UI.
1475
  if page == "play" and not st.session_state.get("initial_page_loaded", False):
1476
  spinner_placeholder = st.empty()
1477
  with CustomSpinner(spinner_placeholder, "Loading Game..."):
1478
+ st.session_state["initial_page_loaded"] = True
1479
+ st.rerun()
 
1480
 
1481
  if page == "settings":
1482
  spinner_placeholder = st.empty()
 
1552
  """
1553
  Render the main game tab layout.
1554
  """
1555
+ start_root_fade_in(0.3)
1556
  _init_session()
1557
  st.markdown(ocean_background_css, unsafe_allow_html=True)
1558
  #inject_ocean_layers() # <-- add the animated layers
 
1585
  st.session_state.letter_map,
1586
  show_grid_ticks=st.session_state.get("show_grid_ticks", True),
1587
  )
1588
+
1589
+ finish_root_fade_in(0.3)
battlewords/ui_helpers.py CHANGED
@@ -10,6 +10,7 @@ from . import __version__ as version
10
  from .modules.constants import APP_SETTINGS
11
  import base64
12
  import os
 
13
 
14
  def fig_to_pil_rgba(fig):
15
  canvas = FigureCanvas(fig)
@@ -312,6 +313,86 @@ def inject_ocean_layers() -> None:
312
  )
313
 
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  def inject_styles() -> None:
316
  st.markdown(
317
  """
@@ -379,7 +460,10 @@ def inject_styles() -> None:
379
  /* div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 2.5rem;} */
380
  .st-key-new_game_btn, .st-key-sort_wordlist_btn, .st-key-filter_wordlist_btn, .st-key-settings_save_apply_btn, .st-key-settings_discard_btn { margin: 0 auto; aspect-ratio: unset; z-index:9999;}
381
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button, .st-key-settings_save_apply_btn > div[data-testid="stButton"] button, .st-key-settings_discard_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto; width: 160px; border-radius:8px;}
382
-
 
 
 
383
 
384
  div[data-testid="column"], .st-emotion-cache-zh2fnc, .st-emotion-cache-1tj828o, .st-emotion-cache-1anq8dj { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; border-radius:0; background-color: #1e69d2; border: 1px solid transparent;}
385
  .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.15rem !important; min-height: 2.5rem; min-width: 2.5rem;}
@@ -465,10 +549,19 @@ def inject_styles() -> None:
465
  }
466
  }
467
 
468
- /* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
469
- .bw-component-abs { position: fixed !important; inset: 0 !important; z-index: 99999 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; padding: 0 !important; }
470
- /* Generic hide utility */
471
- .hide { display: none !important; pointer-events: none !important; }
 
 
 
 
 
 
 
 
 
472
  </style>
473
  """,
474
  unsafe_allow_html=True,
@@ -582,9 +675,14 @@ def show_spinner(message: str = "Loading..."):
582
  <style>
583
  .bw-spinner-overlay {{
584
  position: fixed; z-index: 99999; top: 0; left: 0; width: 100vw; height: 100vh;
585
- # transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
 
586
  background: rgba(11,42,74,1.0); display: flex; align-items: center; justify-content: center;
587
  }}
 
 
 
 
588
  .bw-spinner-img {{
589
  width: 150px; height: 150px; border-radius: 16px; box-shadow: 0 0 8px #1d64c8;
590
  background: #fff; display: flex; align-items: center; justify-content: center;
@@ -598,7 +696,15 @@ def show_spinner(message: str = "Loading..."):
598
  color: #1d64c8; font-size: 1.2rem; margin-top: 1.5rem; text-align: center; font-weight: 600;
599
  }}
600
  </style>
601
- <div class="bw-spinner-overlay">
 
 
 
 
 
 
 
 
602
  <div class="modal-spinner-inner" style="display:flex; flex-direction:column; align-items:center;">
603
  <div class="bw-spinner-img stImage"><img src="/app/static/battlewords.gif" alt="Loading..." width="192" height="192" /></div>
604
  <div class="bw-spinner-msg">{message}</div>
 
10
  from .modules.constants import APP_SETTINGS
11
  import base64
12
  import os
13
+ import streamlit.components.v1 as components
14
 
15
  def fig_to_pil_rgba(fig):
16
  canvas = FigureCanvas(fig)
 
313
  )
314
 
315
 
316
+ def start_root_fade_in(duration_s: float = 0.35) -> None:
317
+ """Hide the app root and prepare a fade-in transition."""
318
+ components.html(
319
+ f"""
320
+ <script>
321
+ (function() {{
322
+ try {{
323
+ var parentDoc = (window.parent && window.parent.document) ? window.parent.document : document;
324
+ var root = parentDoc.getElementById('root');
325
+ if (!root) return;
326
+ root.classList.add('bw-fadein-prep');
327
+ // Immediately hide before any Streamlit render updates become visible.
328
+ root.style.setProperty('opacity', '0', 'important');
329
+ // Force style/layout flush so the opacity=0 takes effect before transition is applied.
330
+ void root.offsetHeight;
331
+ root.style.setProperty('transition', 'opacity {duration_s:.3f}s ease', 'important');
332
+ }} catch (e) {{ /* no-op */ }}
333
+ }})();
334
+ </script>
335
+ """,
336
+ height=0,
337
+ )
338
+
339
+
340
+ def finish_root_fade_in(duration_s: float = 0.35) -> None:
341
+ """Trigger the fade-in transition for the app root."""
342
+ components.html(
343
+ f"""
344
+ <script>
345
+ (function() {{
346
+ try {{
347
+ var parentDoc = (window.parent && window.parent.document) ? window.parent.document : document;
348
+ var root = parentDoc.getElementById('root');
349
+ if (!root) return;
350
+ root.style.transition = 'opacity {duration_s:.3f}s ease';
351
+ root.style.opacity = '1';
352
+ root.classList.remove('bw-fadein-prep');
353
+ }} catch (e) {{ /* no-op */ }}
354
+ }})();
355
+ </script>
356
+ """,
357
+ height=0,
358
+ )
359
+
360
+
361
+ def fade_out_spinner(duration_s: float = 0.5) -> None:
362
+ """Trigger a client-side fade-out of the spinner overlay (if present)."""
363
+ ms = max(0, int(duration_s * 1000))
364
+ # Use a component iframe so JS actually executes reliably.
365
+ # This script reaches up to the parent document to find the overlay.
366
+ components.html(
367
+ f"""
368
+ <script>
369
+ (function() {{
370
+ try {{
371
+ var parentDoc = (window.parent && window.parent.document) ? window.parent.document : document;
372
+ // Ensure the app stays hidden while we perform the fade-out.
373
+ try {{
374
+ var root = parentDoc.getElementById('root');
375
+ if (root) root.classList.add('bw-spinner-active');
376
+ }} catch (e) {{ /* no-op */ }}
377
+ var el = parentDoc.getElementById('bw-spinner-overlay');
378
+ if (!el) return;
379
+ el.style.transition = 'opacity {duration_s:.3f}s ease';
380
+ el.classList.add('bw-spinner-fadeout');
381
+ window.setTimeout(function() {{
382
+ try {{ el.remove(); }} catch (e) {{ /* no-op */ }}
383
+ try {{
384
+ var root = parentDoc.getElementById('root');
385
+ if (root) root.classList.remove('bw-spinner-active');
386
+ }} catch (e) {{ /* no-op */ }}
387
+ }}, {ms});
388
+ }} catch (e) {{ /* no-op */ }}
389
+ }})();
390
+ </script>
391
+ """,
392
+ height=0,
393
+ )
394
+
395
+
396
  def inject_styles() -> None:
397
  st.markdown(
398
  """
 
460
  /* div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 2.5rem;} */
461
  .st-key-new_game_btn, .st-key-sort_wordlist_btn, .st-key-filter_wordlist_btn, .st-key-settings_save_apply_btn, .st-key-settings_discard_btn { margin: 0 auto; aspect-ratio: unset; z-index:9999;}
462
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button, .st-key-settings_save_apply_btn > div[data-testid="stButton"] button, .st-key-settings_discard_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto; width: 160px; border-radius:8px;}
463
+ .stVerticalBlock .st-emotion-cache-tn0cau.e1wguzas3 div:first-child { display: none; }
464
+ .stVerticalBlock .st-emotion-cache-tn0cau.e1wguzas3 div:nth-child(2) { display: none; }
465
+ .stVerticalBlock .st-emotion-cache-tn0cau.e1wguzas3 div:nth-child(3) { display: none; }
466
+ .st-emotion-cache-18kf3ut.e1wguzas41 { display: none; }
467
 
468
  div[data-testid="column"], .st-emotion-cache-zh2fnc, .st-emotion-cache-1tj828o, .st-emotion-cache-1anq8dj { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; border-radius:0; background-color: #1e69d2; border: 1px solid transparent;}
469
  .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.15rem !important; min-height: 2.5rem; min-width: 2.5rem;}
 
549
  }
550
  }
551
 
552
+ /* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
553
+ .bw-component-abs { position: fixed !important; inset: 0 !important; z-index: 99999 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; padding: 0 !important; }
554
+ /* Generic hide utility */
555
+ .hide { display: none !important; pointer-events: none !important; }
556
+ .invisible { visibility: hidden !important; }
557
+
558
+ /* While spinner is active, hide the underlying app to prevent flashes. */
559
+ #root.bw-spinner-active .stApp {
560
+ visibility: hidden;
561
+ }
562
+ #root.bw-spinner-active #bw-spinner-overlay {
563
+ visibility: visible;
564
+ }
565
  </style>
566
  """,
567
  unsafe_allow_html=True,
 
675
  <style>
676
  .bw-spinner-overlay {{
677
  position: fixed; z-index: 99999; top: 0; left: 0; width: 100vw; height: 100vh;
678
+ opacity: 1;
679
+ transition: opacity 0.5s ease;
680
  background: rgba(11,42,74,1.0); display: flex; align-items: center; justify-content: center;
681
  }}
682
+ .bw-spinner-overlay.bw-spinner-fadeout {{
683
+ opacity: 0;
684
+ pointer-events: none;
685
+ }}
686
  .bw-spinner-img {{
687
  width: 150px; height: 150px; border-radius: 16px; box-shadow: 0 0 8px #1d64c8;
688
  background: #fff; display: flex; align-items: center; justify-content: center;
 
696
  color: #1d64c8; font-size: 1.2rem; margin-top: 1.5rem; text-align: center; font-weight: 600;
697
  }}
698
  </style>
699
+ <script>
700
+ (function() {{
701
+ try {{
702
+ var root = document.getElementById('root');
703
+ if (root) root.classList.add('bw-spinner-active');
704
+ }} catch (e) {{ /* no-op */ }}
705
+ }})();
706
+ </script>
707
+ <div class="bw-spinner-overlay" id="bw-spinner-overlay">
708
  <div class="modal-spinner-inner" style="display:flex; flex-direction:column; align-items:center;">
709
  <div class="bw-spinner-img stImage"><img src="/app/static/battlewords.gif" alt="Loading..." width="192" height="192" /></div>
710
  <div class="bw-spinner-msg">{message}</div>