From bbceb4c6798d43f4b32e73f38fc4907e00733244 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 16 May 2026 01:40:02 -0700 Subject: Rewrite watchlist as custom HTML component with proper styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces st.button rows with a components.html iframe that renders sym·price·Δ% in a 3-column grid with hairline dividers, monospace fonts, and colored change percentages. Adds hidden text_input click receiver so row clicks update session state. Fixes toggle button to show toast when watchlist cap is hit and removes now-redundant psm-watch-toggle CSS. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 35 ++------- components/watchlist.py | 184 +++++++++++++++++++++++------------------------- 2 files changed, 93 insertions(+), 126 deletions(-) diff --git a/app.py b/app.py index b0a9453..f8acd3c 100644 --- a/app.py +++ b/app.py @@ -404,29 +404,9 @@ hr { ::-webkit-scrollbar-thumb { background: var(--ink-3); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--ink-4); } -/* ── Watch toggle button ────────────────────────────────────────────────── */ -[data-testid="stSidebar"] .psm-watch-toggle button { - background: var(--ink-2) !important; - color: var(--fg-3) !important; - border: 1px solid var(--line-2) !important; - border-radius: 2px !important; - font-family: var(--font-sans) !important; - font-size: 0.6875rem !important; - font-weight: 500 !important; - letter-spacing: 0.06em !important; - text-transform: uppercase !important; - padding: 3px 8px !important; - margin-top: 6px !important; -} - -[data-testid="stSidebar"] .psm-watch-toggle button:hover { - background: var(--ink-3) !important; - border-color: var(--line-3) !important; - color: var(--fg-1) !important; -} - -[data-testid="stSidebar"] .psm-watch-toggle button p { - color: inherit !important; +/* Hide the watchlist click receiver input */ +[data-testid="stSidebar"] [data-testid="stTextInput"]:has(input[aria-label="wl_click_receiver"]) { + display: none !important; } /* ── Ticker Header Band ──────────────────────────────────────────────────── */ @@ -762,18 +742,15 @@ with st.sidebar: if ticker: in_watch = ticker in st.session_state["watchlist"] label = "— Remove from watchlist" if in_watch else "+ Save to watchlist" - st.markdown('
', unsafe_allow_html=True) if st.button(label, key="watch_toggle", use_container_width=True): if in_watch: st.session_state["watchlist"].remove(ticker) else: - if ( - ticker not in st.session_state["watchlist"] - and len(st.session_state["watchlist"]) < 10 - ): + if len(st.session_state["watchlist"]) >= 10: + st.toast("Watchlist full — 10 tickers maximum") + else: st.session_state["watchlist"].append(ticker) st.rerun() - st.markdown("
", unsafe_allow_html=True) # ── Watchlist ────────────────────────────────────────────────────────── render_watchlist() diff --git a/components/watchlist.py b/components/watchlist.py index 0b50d3b..64f57f8 100644 --- a/components/watchlist.py +++ b/components/watchlist.py @@ -1,66 +1,9 @@ """Watchlist sidebar component — session-scoped personal watchlist.""" import streamlit as st +import streamlit.components.v1 as components from services.data_service import get_latest_price, get_company_info from utils.security import escape_html -_WATCHLIST_CSS_INJECTED = False - - -def _inject_css(): - global _WATCHLIST_CSS_INJECTED - if _WATCHLIST_CSS_INJECTED: - return - _WATCHLIST_CSS_INJECTED = True - st.markdown(""" - -""", unsafe_allow_html=True) - def render_watchlist(): """Render the personal watchlist section in the sidebar. @@ -71,56 +14,103 @@ def render_watchlist(): Must be called from within a `with st.sidebar:` block. """ - _inject_css() + watchlist = st.session_state.get("watchlist", []) - watchlist: list[str] = st.session_state.get("watchlist", []) + # Process any pending click from the JS click handler + clicked = st.session_state.get("_wl_click", "") + if clicked and clicked in watchlist: + st.session_state["ticker"] = clicked + st.session_state["_wl_click"] = "" + st.rerun() - st.markdown('
Watchlist
', unsafe_allow_html=True) - - if not watchlist: - st.markdown( - '
No saved tickers
', - unsafe_allow_html=True, - ) - return + # Hidden click receiver — JS will set this input's value on row click + st.text_input("wl_click_receiver", key="_wl_click", label_visibility="collapsed") + # Build rows HTML (string concatenation, NOT f-strings — JS {} would break) + rows_html = "" for sym in watchlist: try: price = get_latest_price(sym) info = get_company_info(sym) or {} prev = info.get("regularMarketPreviousClose") or info.get("previousClose") - + sym_e = escape_html(sym) if price is not None and prev and prev > 0: chg_pct = (price - prev) / prev * 100 - sign = "+" if chg_pct >= 0 else "" - chg_color = "#4F8C5E" if chg_pct >= 0 else "#B5494B" - row_label = ( - escape_html(sym) - + " $" + f"{price:,.2f}" - + " " + sign + f"{chg_pct:.2f}%" - ) + sign = "+" if chg_pct >= 0 else "−" + chg_cls = "pos" if chg_pct >= 0 else "neg" + chg_str = sign + f"{abs(chg_pct):.2f}%" + px_str = "$" + f"{price:,.2f}" elif price is not None: - chg_color = "#8E8676" - row_label = escape_html(sym) + " $" + f"{price:,.2f}" + chg_cls = "flat" + chg_str = "—" + px_str = "$" + f"{price:,.2f}" else: - chg_color = "#8E8676" - row_label = escape_html(sym) + " —" - - # Render a styled container div then the Streamlit button inside it - st.markdown('
', unsafe_allow_html=True) - if st.button(row_label, key="watch_row_" + sym, use_container_width=True): - st.session_state["ticker"] = sym - st.rerun() - st.markdown("
", unsafe_allow_html=True) - + chg_cls = "flat" + chg_str = "—" + px_str = "—" + rows_html += ( + "
" + "" + sym_e + "" + "" + px_str + "" + "" + chg_str + "" + "
" + ) except Exception: - # Never let one broken ticker blow up the whole watchlist - sym_safe = escape_html(sym) - st.markdown( - '
', - unsafe_allow_html=True, + sym_e = escape_html(sym) + rows_html += ( + "
" + "" + sym_e + "" + "" + "" + "
" ) - if st.button(sym_safe + " (error)", key="watch_row_" + sym, use_container_width=True): - st.session_state["ticker"] = sym - st.rerun() - st.markdown("
", unsafe_allow_html=True) + + if not rows_html: + content_html = "
No saved tickers
" + else: + content_html = rows_html + + height = 28 + max(len(watchlist), 1) * 34 + 16 # label + rows + buffer + + # Build the full HTML block (string concat, not f-string — JS {} would break) + html = ( + "" + "Watchlist" + + content_html + + "" + ) + + components.html(html, height=height, scrolling=False) -- cgit v1.3-2-g0d8e