33import datetime
44import logging
55import asyncio
6+ import os
67
78# Set up logging
89logging .basicConfig (level = logging .DEBUG , format = '%(asctime)s - %(levelname)s - %(message)s' )
910
11+ # Global variable to track phrases.txt modification time.
12+ last_phrases_mtime = os .path .getmtime ("phrases.txt" )
13+
1014# --- New: Color constants and font ---
1115TILE_CLICKED_BG_COLOR = "#3b82f6" # Blue background for clicked tiles
1216TILE_CLICKED_TEXT_COLOR = "white"
@@ -35,9 +39,9 @@ def get_line_style_for_lines(line_count: int, default_text_color: str) -> str:
3539 elif line_count == 2 :
3640 lh = "1.2em" # Slightly reduced spacing for two lines.
3741 elif line_count == 3 :
38- lh = "0.9em " # Even tighter spacing for three lines.
42+ lh = "0.75em " # Even tighter spacing for three lines.
3943 else :
40- lh = "0.9em " # For four or more lines.
44+ lh = "0.7em " # For four or more lines.
4145 return f"font-family: { FONT_FAMILY } ; padding: 0; margin: 0; color: { default_text_color } ; line-height: { lh } ;"
4246
4347# Read phrases from a text file and convert them to uppercase.
@@ -59,22 +63,97 @@ def get_line_style_for_lines(line_count: int, default_text_color: str) -> str:
5963tile_icons = {} # {(row, col): icon reference}
6064admin_checkboxes = {} # {(row, col): admin checkbox element}
6165
62- def split_phrase_into_lines (phrase : str ) -> list :
66+ def split_phrase_into_lines (phrase : str , forced_lines : int = None ) -> list :
6367 """
6468 Splits the phrase into balanced lines.
65- If the phrase has two or fewer words, return it as a single line.
66- Otherwise, split into two lines at the midpoint.
69+ For phrases of up to 3 words, return one word per line.
70+ For longer phrases, try splitting the phrase into 2, 3, or 4 lines so that the total
71+ number of characters (including spaces) in each line is as similar as possible.
72+ The function will never return more than 4 lines.
73+ If 'forced_lines' is provided (2, 3, or 4), then the candidate with that many lines is chosen
74+ if available; otherwise, the best candidate overall is returned.
6775 """
6876 words = phrase .split ()
69- if len (words ) == 1 :
70- return [words [0 ]]
71- elif len (words ) == 2 :
72- return [words [0 ], words [1 ]]
73- elif len (words ) == 3 :
74- return [words [0 ], words [1 ], words [2 ]]
77+ n = len (words )
78+ if n <= 3 :
79+ return words
80+
81+ # Helper: total length of a list of words (including spaces between words).
82+ def segment_length (segment ):
83+ return sum (len (word ) for word in segment ) + (len (segment ) - 1 if segment else 0 )
84+
85+ candidates = [] # list of tuples: (number_of_lines, diff, candidate)
86+
87+ # 2-line candidate
88+ best_diff_2 = float ('inf' )
89+ best_seg_2 = None
90+ for i in range (1 , n ):
91+ seg1 = words [:i ]
92+ seg2 = words [i :]
93+ len1 = segment_length (seg1 )
94+ len2 = segment_length (seg2 )
95+ diff = abs (len1 - len2 )
96+ if diff < best_diff_2 :
97+ best_diff_2 = diff
98+ best_seg_2 = [" " .join (seg1 ), " " .join (seg2 )]
99+ if best_seg_2 is not None :
100+ candidates .append ((2 , best_diff_2 , best_seg_2 ))
101+
102+ # 3-line candidate (if at least 4 words)
103+ if n >= 4 :
104+ best_diff_3 = float ('inf' )
105+ best_seg_3 = None
106+ for i in range (1 , n - 1 ):
107+ for j in range (i + 1 , n ):
108+ seg1 = words [:i ]
109+ seg2 = words [i :j ]
110+ seg3 = words [j :]
111+ len1 = segment_length (seg1 )
112+ len2 = segment_length (seg2 )
113+ len3 = segment_length (seg3 )
114+ current_diff = max (len1 , len2 , len3 ) - min (len1 , len2 , len3 )
115+ if current_diff < best_diff_3 :
116+ best_diff_3 = current_diff
117+ best_seg_3 = [" " .join (seg1 ), " " .join (seg2 ), " " .join (seg3 )]
118+ if best_seg_3 is not None :
119+ candidates .append ((3 , best_diff_3 , best_seg_3 ))
120+
121+ # 4-line candidate (if at least 5 words)
122+ if n >= 5 :
123+ best_diff_4 = float ('inf' )
124+ best_seg_4 = None
125+ for i in range (1 , n - 2 ):
126+ for j in range (i + 1 , n - 1 ):
127+ for k in range (j + 1 , n ):
128+ seg1 = words [:i ]
129+ seg2 = words [i :j ]
130+ seg3 = words [j :k ]
131+ seg4 = words [k :]
132+ len1 = segment_length (seg1 )
133+ len2 = segment_length (seg2 )
134+ len3 = segment_length (seg3 )
135+ len4 = segment_length (seg4 )
136+ diff = max (len1 , len2 , len3 , len4 ) - min (len1 , len2 , len3 , len4 )
137+ if diff < best_diff_4 :
138+ best_diff_4 = diff
139+ best_seg_4 = [" " .join (seg1 ), " " .join (seg2 ), " " .join (seg3 ), " " .join (seg4 )]
140+ if best_seg_4 is not None :
141+ candidates .append ((4 , best_diff_4 , best_seg_4 ))
142+
143+ # If a forced number of lines is specified, try to return that candidate first.
144+ if forced_lines is not None :
145+ forced_candidates = [cand for cand in candidates if cand [0 ] == forced_lines ]
146+ if forced_candidates :
147+ _ , _ , best_candidate = min (forced_candidates , key = lambda x : x [1 ])
148+ return best_candidate
149+
150+ # Otherwise, choose the candidate with the smallest diff.
151+ if candidates :
152+ _ , best_diff , best_candidate = min (candidates , key = lambda x : x [1 ])
153+ return best_candidate
75154 else :
76- mid = round ( len ( words ) / 2 )
77- return [" " .join (words [: mid ]), " " . join ( words [ mid :] )]
155+ # fallback (should never happen )
156+ return [" " .join (words )]
78157
79158# Function to create the Bingo board UI
80159def create_bingo_board ():
@@ -96,7 +175,10 @@ def create_bingo_board():
96175 line_count = len (lines )
97176 for line in lines :
98177 with ui .row ().classes ("w-full" ):
99- ui .label (line ).classes ("fit-text text-center select-none" ).style (get_line_style_for_lines (line_count , default_text_color ))
178+ if len (line ) <= 3 :
179+ ui .label (line ).classes ("fit-text-small text-center select-none" ).style (get_line_style_for_lines (line_count , default_text_color ))
180+ else :
181+ ui .label (line ).classes ("fit-text text-center select-none" ).style (get_line_style_for_lines (line_count , default_text_color ))
100182
101183 tile_buttons [(row_idx , col_idx )] = card
102184
@@ -169,12 +251,17 @@ def home_page():
169251 # Set up NiceGUI page and head elements
170252 setup_head (HOME_BG_COLOR )
171253
172- global tile_buttons
173- tile_buttons = build_board (ui .element ("div" ).classes ("flex justify-center items-center w-full" ), tile_buttons , toggle_tile )
254+ global home_board_container , tile_buttons
255+ home_board_container = ui .element ("div" ).classes ("flex justify-center items-center w-full" )
256+ tile_buttons = {} # Start with an empty dictionary.
257+ build_board (home_board_container , tile_buttons , toggle_tile )
174258
175- # Add a timer that calls sync_board_state every 1 second to push state updates to all clients
259+ # Add a timer that calls sync_board_state every 0. 1 second to push state updates to all clients
176260 ui .timer (0.1 , sync_board_state )
177261
262+ # Add a timer to check if phrases.txt has changed
263+ ui .timer (1 , check_phrases_file_change )
264+
178265 with ui .element ("div" ).classes ("w-full mt-4" ):
179266 ui .label (f"Seed: { today_seed } " ).classes ("text-md text-gray-300 text-center" )
180267
@@ -242,10 +329,12 @@ def setup_head(background_color: str):
242329 ui .add_head_html ("""<script>
243330 document.addEventListener('DOMContentLoaded', () => {
244331 fitty('.fit-text', { multiLine: true, minSize: 10, maxSize: 1000 });
332+ fitty('.fit-text-small', { multiLine: true, minSize: 10, maxSize: 72 });
245333 fitty('.fit-header', { multiLine: true, minSize: 10, maxSize: 2000 });
246334 });
247335 window.addEventListener('resize', () => {
248336 fitty('.fit-text', { multiLine: true, minSize: 10, maxSize: 1000 });
337+ fitty('.fit-text-small', { multiLine: true, minSize: 10, maxSize: 72 });
249338 fitty('.fit-header', { multiLine: true, minSize: 10, maxSize: 2000 });
250339 });
251340 </script>""" )
@@ -275,7 +364,10 @@ def build_board(parent, tile_buttons_dict: dict, on_tile_click):
275364 line_count = len (lines )
276365 for line in lines :
277366 with ui .row ().classes ("w-full" ):
278- ui .label (line ).classes ("fit-text text-center select-none" ).style (get_line_style_for_lines (line_count , default_text_color ))
367+ if len (line ) <= 3 :
368+ ui .label (line ).classes ("fit-text-small text-center select-none" ).style (get_line_style_for_lines (line_count , default_text_color ))
369+ else :
370+ ui .label (line ).classes ("fit-text text-center select-none" ).style (get_line_style_for_lines (line_count , default_text_color ))
279371 tile_buttons_dict [(row_idx , col_idx )] = card
280372 if phrase .upper () == "FREE MEAT" :
281373 clicked_tiles .add ((row_idx , col_idx ))
@@ -298,5 +390,32 @@ def update_tile_styles(tile_buttons_dict: dict):
298390 card .style (new_style )
299391 card .update ()
300392
393+ def check_phrases_file_change ():
394+ """
395+ Check if phrases.txt has changed. If so, re-read the file, update the board,
396+ and re-render the board UI.
397+ """
398+ global last_phrases_mtime , phrases , board , tile_buttons , home_board_container
399+ try :
400+ mtime = os .path .getmtime ("phrases.txt" )
401+ except Exception as e :
402+ logging .error (f"Error checking phrases.txt: { e } " )
403+ return
404+ if mtime != last_phrases_mtime :
405+ logging .info ("phrases.txt changed, reloading board." )
406+ last_phrases_mtime = mtime
407+ # Re-read phrases.txt
408+ with open ("phrases.txt" , "r" ) as f :
409+ phrases = [line .strip ().upper () for line in f if line .strip ()]
410+ # Rebuild board data: re-shuffle and re-create board structure.
411+ shuffled_phrases = random .sample (phrases , 24 )
412+ shuffled_phrases .insert (12 , "FREE MEAT" )
413+ board = [shuffled_phrases [i :i + 5 ] for i in range (0 , 25 , 5 )]
414+ # Clear the board UI and rebuild it.
415+ home_board_container .clear ()
416+ tile_buttons .clear () # Clear global dictionary.
417+ build_board (home_board_container , tile_buttons , toggle_tile )
418+ home_board_container .update () # Force update so new styles are applied immediately.
419+
301420# Run the NiceGUI app
302421ui .run (port = 8080 , title = "Commit Bingo" , dark = False )
0 commit comments