"""D&D Initiative Tracker — turn order only, no AI, no external dependencies.""" import gradio as gr from src import content, tool from src.space_config import CONFIG from src.shared.ctas import cta_html from src.shared.page import standard_launch from src.shared.seo import hero_html, section_html, faq_html, related_html COPY_JS = """ () => { const el = document.querySelector('#lf-copy-src textarea'); if (!el || !el.value) return; const flash = () => { const btn = document.querySelector('#lf-copy-btn button') || document.querySelector('#lf-copy-btn'); if (btn) { const t = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => (btn.textContent = t), 1500); } }; const fallback = () => { el.focus({preventScroll: true}); el.select(); try { document.execCommand('copy'); } catch (e) {} flash(); }; try { navigator.clipboard.writeText(el.value).then(flash).catch(fallback); } catch (e) { fallback(); } } """ QUICK_PLACEHOLDER = ( "Kira 18 (player total)\n" "Torvin +2 (roll d20+2 for them)\n" "Goblin x3 +2 (three goblins, rolled separately)\n" "Shadow (bare name — rolls a d20)" ) def _dropdown_choices(state): return [ (f"{e['name']} ({tool._fmt_init(e['initiative'])})", e["id"]) for e in state["entries"] ] def _views(state, selected=None): """Rendered outputs shared by every event: list, banner, dropdown, copy.""" valid = {e["id"] for e in state["entries"]} value = selected if selected in valid else None return ( tool.render_list(state), tool.render_banner(state), gr.update(choices=_dropdown_choices(state), value=value), tool.export_text(state), ) def on_load(saved): state = tool.coerce_state(saved) return (state, *_views(state)) def on_add(state, text, roll_blank): state, messages = tool.quick_add(state, text, roll_blank) return ( state, state, # working state + browser mirror gr.update(value=""), # clear the quick-add box tool.render_roll_log(messages), *_views(state), ) def on_next(state): state = tool.next_turn(state) return (state, state, *_views(state)) def on_prev(state): state = tool.prev_turn(state) return (state, state, *_views(state)) def on_reset(state): state = tool.reset_rounds(state) return (state, state, *_views(state)) def on_clear(state): state = tool.clear_all() return (state, state, "", *_views(state)) def on_move_up(state, eid): if eid is not None: state = tool.move_up(state, eid) return (state, state, *_views(state, selected=eid)) def on_move_down(state, eid): if eid is not None: state = tool.move_down(state, eid) return (state, state, *_views(state, selected=eid)) def on_remove(state, eid): if eid is not None: state = tool.remove_entry(state, eid) return (state, state, *_views(state)) _initial = tool.new_state() with gr.Blocks(title=CONFIG.title) as demo: gr.HTML(hero_html(CONFIG)) gr.HTML(cta_html(CONFIG, CONFIG.hero_cta)) state = gr.State(_initial) browser_state = gr.BrowserState( _initial, storage_key="lf_initiative_tracker_v1" ) with gr.Row(equal_height=False): with gr.Column(scale=5): quick_box = gr.Textbox( label="Quick add — one combatant per line", lines=5, placeholder=QUICK_PLACEHOLDER, ) roll_blank = gr.Checkbox( value=True, label="Roll d20 for names without a number" ) add_btn = gr.Button( "Add to Order", variant="primary", elem_classes=["lf-generate"] ) roll_log = gr.HTML("") entry_dd = gr.Dropdown( choices=[], value=None, label="Manage a combatant", info="Pick an entry, then move it for tie-breaks or remove it.", ) with gr.Row(): up_btn = gr.Button("Move Up") down_btn = gr.Button("Move Down") remove_btn = gr.Button("Remove") with gr.Column(scale=6): banner = gr.HTML(tool.render_banner(_initial)) order_html = gr.HTML(tool.render_list(_initial)) with gr.Row(): next_btn = gr.Button("Next ▶", variant="primary", scale=2, elem_classes=["lf-generate"]) prev_btn = gr.Button("◀ Back", scale=1) with gr.Row(): reset_btn = gr.Button("Reset Rounds") clear_btn = gr.Button("Clear All") copy_btn = gr.Button("Copy Order", elem_id="lf-copy-btn") # Kept in the DOM (offscreen) so the Copy button's JS can read it; # visible=False would remove the textarea entirely in Gradio 6. copy_src = gr.Textbox( elem_id="lf-copy-src", elem_classes=["lf-offscreen"], container=False, show_label=False, ) gr.HTML(cta_html(CONFIG, CONFIG.post_tool_cta)) gr.HTML(section_html("Get Your Turn Order in Seconds", content.HOW_TO_HTML)) gr.HTML(section_html("Why This DnD Initiative Tracker Is Turn Order Only", content.WHY_HTML)) gr.HTML(section_html("Worked Example", content.EXAMPLE_HTML)) gr.HTML(section_html("Initiative Rules Refresher", content.RULES_HTML)) gr.HTML(faq_html(content.FAQS)) gr.HTML(related_html(CONFIG)) gr.HTML(cta_html(CONFIG, CONFIG.footer_cta)) gr.HTML(content.DISCLAIMER_HTML) views = [order_html, banner, entry_dd, copy_src] demo.load(on_load, [browser_state], [state] + views, api_name=False, queue=False) add_btn.click( on_add, [state, quick_box, roll_blank], [state, browser_state, quick_box, roll_log] + views, api_name=False, queue=False, ) next_btn.click(on_next, [state], [state, browser_state] + views, api_name=False, queue=False) prev_btn.click(on_prev, [state], [state, browser_state] + views, api_name=False, queue=False) reset_btn.click(on_reset, [state], [state, browser_state] + views, api_name=False, queue=False) clear_btn.click(on_clear, [state], [state, browser_state, roll_log] + views, api_name=False, queue=False) up_btn.click(on_move_up, [state, entry_dd], [state, browser_state] + views, api_name=False, queue=False) down_btn.click(on_move_down, [state, entry_dd], [state, browser_state] + views, api_name=False, queue=False) remove_btn.click(on_remove, [state, entry_dd], [state, browser_state] + views, api_name=False, queue=False) copy_btn.click(None, None, None, js=COPY_JS, api_name=False, queue=False) if __name__ == "__main__": standard_launch(demo, CONFIG)