""" view_settings.py ================ Settings page — theme, custom colours, font size. """ import streamlit as st import os, re CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".streamlit") CONFIG_FILE = os.path.join(CONFIG_DIR, "config.toml") GITHUB_REPO = "alphapapi-ctrl/mt5-tools" GITHUB_BRANCH = "master" # ───────────────────────────────────────────────────────────────────────────── # Version / update check # ───────────────────────────────────────────────────────────────────────────── def _get_local_sha() -> str: """Return the current local git HEAD SHA, or '' if not a git repo.""" try: git_head = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".git", "HEAD") if not os.path.isfile(git_head): return "" ref = open(git_head).read().strip() if ref.startswith("ref:"): # Resolve the ref to a SHA ref_path = ref.split(" ", 1)[1] # e.g. refs/heads/master sha_file = os.path.join( os.path.dirname(os.path.abspath(__file__)), ".git", ref_path ) if os.path.isfile(sha_file): return open(sha_file).read().strip() else: return ref # detached HEAD — ref is the SHA directly except Exception: pass return "" @st.cache_data(ttl=3600) # cache for 1 hour so we don't hammer the API def _get_remote_info() -> dict: """ Fetch the latest commit SHA and message from GitHub API. Returns dict with keys: sha, message, author, date, error """ import urllib.request, json url = f"https://api.github.com/repos/{GITHUB_REPO}/commits/{GITHUB_BRANCH}" try: req = urllib.request.Request(url, headers={"User-Agent": "MT5Tools"}) with urllib.request.urlopen(req, timeout=5) as r: data = json.loads(r.read().decode()) return { "sha": data["sha"], "short": data["sha"][:7], "message": data["commit"]["message"].split("\n")[0], "author": data["commit"]["author"]["name"], "date": data["commit"]["author"]["date"][:10], "error": None, } except Exception as e: return {"sha": "", "short": "", "message": "", "author": "", "date": "", "error": str(e)} @st.cache_data(ttl=3600) def _get_recent_commits(n=10) -> list: """Fetch the last n commits for the changelog.""" import urllib.request, json url = f"https://api.github.com/repos/{GITHUB_REPO}/commits?sha={GITHUB_BRANCH}&per_page={n}" try: req = urllib.request.Request(url, headers={"User-Agent": "MT5Tools"}) with urllib.request.urlopen(req, timeout=5) as r: data = json.loads(r.read().decode()) return [ { "sha": c["sha"][:7], "message": c["commit"]["message"], "date": c["commit"]["author"]["date"][:10], "author": c["commit"]["author"]["name"], } for c in data ] except Exception: return [] def check_for_update_banner(): """ Call from app.py on every page render. Shows a top-of-page banner if a newer version is available on GitHub. Silently does nothing if offline or not a git repo. """ local_sha = _get_local_sha() if not local_sha: return # not a git repo — skip silently remote = _get_remote_info() if remote["error"] or not remote["sha"]: return # offline or API error — skip silently if not remote["sha"].startswith(local_sha) and local_sha != remote["sha"]: st.info( f"🔄 **Update available** — " f"latest commit `{remote['short']}` on {remote['date']}: " f"*{remote['message']}* · " f"Run `git pull` then restart to update.", icon="🔄", ) THEMES = { "Dark": { "base": "dark", "primaryColor": "#7c6af7", "backgroundColor": "#0e1117", "secondaryBackgroundColor": "#1a1f2e", "textColor": "#fafafa", "font": "sans serif", }, "Light": { "base": "light", "primaryColor": "#2E75B6", "backgroundColor": "#ffffff", "secondaryBackgroundColor": "#f0f2f6", "textColor": "#1a1a1a", "font": "sans serif", }, } def _read_config() -> dict: """Read .streamlit/config.toml, return dict of theme keys.""" if not os.path.isfile(CONFIG_FILE): return dict(THEMES["Dark"]) try: text = open(CONFIG_FILE).read() found = {} for key in ["base","primaryColor","backgroundColor", "secondaryBackgroundColor","textColor","font","cardBackgroundColor"]: m = re.search(key + r'\s*=\s*"([^"]*)"', text) if m: found[key] = m.group(1) return {**THEMES["Dark"], **found} except Exception: return dict(THEMES["Dark"]) def _get_lan_ip() -> str: """Return the first 192.168.x.x address found on this machine, or '' if none.""" import socket try: # Get all addresses for this hostname hostname = socket.gethostname() addrs = socket.getaddrinfo(hostname, None) for addr in addrs: ip = addr[4][0] if ip.startswith("192.168."): return ip except Exception: pass # Fallback: connect to a dummy address to get the outbound LAN interface try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("192.168.1.1", 80)) ip = s.getsockname()[0] s.close() if ip.startswith("192.168."): return ip except Exception: pass return "" def _read_server_config() -> dict: """Read [server] section from config.toml.""" if not os.path.isfile(CONFIG_FILE): return {} try: text = open(CONFIG_FILE).read() found = {} m = re.search(r'address\s*=\s*"([^"]*)"', text) if m: found["address"] = m.group(1) m = re.search(r'port\s*=\s*(\d+)', text) if m: found["port"] = int(m.group(1)) return found except Exception: return {} def _write_config(theme: dict): os.makedirs(CONFIG_DIR, exist_ok=True) # Preserve existing [server] section if present server_block = "" if os.path.isfile(CONFIG_FILE): existing = open(CONFIG_FILE).read() m = re.search(r'\[server\].*?(?=\[|\Z)', existing, re.DOTALL) if m: server_block = "\n" + m.group(0).strip() + "\n" lines = ["[theme]\n"] for k, v in theme.items(): lines.append(f'{k} = "{v}"\n') if server_block: lines.append(server_block) with open(CONFIG_FILE, "w") as f: f.writelines(lines) def _write_server_config(address: str, port: int): """Write or update the [server] section in config.toml.""" os.makedirs(CONFIG_DIR, exist_ok=True) # Read existing content, strip any existing [server] block existing = open(CONFIG_FILE).read() if os.path.isfile(CONFIG_FILE) else "" existing = re.sub(r'\[server\].*?(?=\[|\Z)', '', existing, flags=re.DOTALL).strip() server_block = f'\n\n[server]\naddress = "{address}"\nport = {port}\n' with open(CONFIG_FILE, "w") as f: f.write(existing + server_block) def _is_custom(cfg: dict) -> bool: """Return True if the saved config differs from both standard themes.""" for t in THEMES.values(): if all(cfg.get(k) == v for k, v in t.items()): return False return True def inject_theme_css(): """Call from app.py on every page to apply font size and card theme globally.""" _size_map = {"Normal": 0, "Large (+2px)": 2, "Extra Large (+4px)": 4} _delta = _size_map.get(st.session_state.get("st_font_size", "Normal"), 0) # Read card background from config cfg = _read_config() base = cfg.get("base", "dark") card_bg = cfg.get("cardBackgroundColor", "") text_col = cfg.get("textColor", "#fafafa") # Default card colours per base theme if not card_bg: card_bg = "#f0f2f6" if base == "light" else "#131720" card_border = "rgba(0,0,0,0.10)" if base == "light" else "#1E2535" label_col = "#555e70" if base == "light" else "#6C7A9A" kk_col = "#4a5568" if base == "light" else "#7A8898" row_border = "rgba(0,0,0,0.07)" if base == "light" else "#141820" sh_border = "rgba(0,0,0,0.10)" if base == "light" else "#1E2535" th_bg = card_bg td_col = text_col css = f"""""" st.markdown(css, unsafe_allow_html=True) if _delta > 0: st.markdown(f"""""", unsafe_allow_html=True) def render(): st.title("Settings") # ── Theme selector ──────────────────────────────────────────────────────── st.subheader("Theme") cfg = _read_config() # Determine current theme state if _is_custom(cfg): current_mode = "Custom" elif cfg.get("base") == "light": current_mode = "Light" else: current_mode = "Dark" # Always show all three options — Custom opens the colour pickers theme_options = ["Dark", "Light", "Custom"] selected = st.radio("Theme", theme_options, horizontal=True, key="st_theme_pick", index=theme_options.index(current_mode) if current_mode in theme_options else 0) # ── Apply button for Dark / Light ──────────────────────────────────────── if selected in ("Dark", "Light"): if st.button(f"Apply {selected} Theme", type="primary", key="st_apply_base"): _write_config(THEMES[selected]) st.success(f"{selected} theme applied — reloading...") st.rerun() # ── Custom colour section — only show when Custom is selected ───────────── if selected == "Custom": st.caption("Customise colours below then click **Apply Custom Theme**.") st.markdown("---") c1, c2, c3 = st.columns(3) with c1: primary = st.color_picker("Accent colour", value=cfg.get("primaryColor","#7c6af7"), key="st_primary", help="Buttons, links, highlights") card_bg = st.color_picker("Card / stat block background", value=cfg.get("cardBackgroundColor","#131720"), key="st_card_bg", help="Background for stat cards and tables") with c2: bg = st.color_picker("Page background", value=cfg.get("backgroundColor","#0e1117"), key="st_bg") sidebar_bg = st.color_picker("Sidebar / widget background", value=cfg.get("secondaryBackgroundColor","#1a1f2e"), key="st_sidebar_bg") with c3: text_color = st.color_picker("Text colour", value=cfg.get("textColor","#fafafa"), key="st_text") font = st.selectbox("Font", ["sans serif", "serif", "monospace"], index=["sans serif","serif","monospace"].index( cfg.get("font","sans serif")), key="st_font") base_for_custom = st.radio("Base", ["dark","light"], horizontal=True, key="st_custom_base", index=0 if cfg.get("base","dark")=="dark" else 1, help="Underlying dark or light base for your custom colours") # Preview swatches st.markdown("**Preview**") sw1, sw2, sw3, sw4 = st.columns(4) for col, color, label in [ (sw1, bg, "Page BG"), (sw2, sidebar_bg, "Sidebar BG"), (sw3, primary, "Accent"), (sw4, text_color, "Text"), ]: col.markdown( f'
{color}