""" QuickNotes - a lightweight Markdown note-taking app for Windows. Revision: 2026-06-06T06:08:09Z Single-file Tkinter app, standard library only. Notes are stored as plain .md files in a folder so they remain readable and portable. Features -------- - Create / edit / delete notes - Full-text search and tag filter - Markdown editor with togglable rendered preview - Auto-save (debounced) to ~/Documents/QuickNotes - "Tools -> Run Command..." for running a Windows shell command and capturing its output - "Tools -> Run selected text as command" (Ctrl+R) executes whatever is currently selected in the note editor Run with: python quicknotes.py """ from __future__ import annotations import os import re import sys import json import subprocess import threading import datetime as dt import webbrowser import html as _htmllib import shutil import tempfile import unicodedata import socket import base64 import shlex import struct import secrets import time from urllib.request import urlopen from urllib.parse import urlparse from pathlib import Path from typing import Optional import tkinter as tk from tkinter import ttk, filedialog, messagebox, simpledialog from tkinter import font as tkfont from tkinter import colorchooser # --------------------------------------------------------------------------- # Storage layer # --------------------------------------------------------------------------- # Brand name derived from this script's filename. Rename the file to # rebrand: e.g. mynotes.py -> "Mynotes" window title, ~/Documents/Mynotes, # ~/.mynotes.json. If __file__ is unavailable (rare -- e.g. some # zipapp configs) we fall back to the original name. try: SCRIPT_STEM = Path(__file__).stem except NameError: SCRIPT_STEM = "quicknotes" BRAND = (SCRIPT_STEM[:1].upper() + SCRIPT_STEM[1:]) if SCRIPT_STEM else "QuickNotes" BRAND_ID = (SCRIPT_STEM or "quicknotes").lower() # Pull the "Revision: ..." line out of the module docstring so the # Help -> Usage / Quick Reference window can show it. Updating the # stamp at the top of this file is the only place to maintain it. _rev_match = re.search(r"^Revision:\s*(.+)$", __doc__ or "", flags=re.MULTILINE) REVISION = _rev_match.group(1).strip() if _rev_match else "unknown" DEFAULT_NOTES_DIR = Path.home() / "Documents" / BRAND CONFIG_PATH = Path.home() / f".{BRAND_ID}.json" FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL) # File extensions the notes pane will list. Curated to text-like # formats only, so the pane stays useful when the notes folder also # contains other working files (scripts, configs, source code, # logs, etc.). Binary formats (.png, .pdf, .exe, .zip, ...) are # excluded -- opening one into a Tk Text widget would produce # gibberish or an error. # # The leading dot is part of each entry to make matching against # Path.suffix.lower() direct. The empty string is also a member: # files with no extension at all (Makefile, README, LICENSE, # CHANGELOG, etc.) ARE listed -- in practice users keep mostly # text-like extensionless files in working folders, and the # alternative (silently dropping them) was confusing. A binary # file with no extension would be listed too, but clicking it # falls back to the same harmless "rendered as garbage" failure # mode as clicking any unrecognized binary, so the trade-off is # acceptable. Dotfiles like .gitignore are still skipped via the # separate name.startswith(".") guard in NoteStore.refresh. NOTE_LIST_EXTENSIONS = frozenset({ # Files with no extension at all (Makefile, README, LICENSE). "", # Notes / prose ".md", ".markdown", ".mdown", ".mkd", ".txt", ".text", ".rst", ".rtf", ".log", # QuickNotes-native ".qns", ".qnu", ".qnk", # Code (common languages) ".py", ".pyw", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".html", ".htm", ".css", ".scss", ".sass", ".less", ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hxx", ".java", ".kt", ".scala", ".groovy", ".rb", ".pl", ".pm", ".php", ".lua", ".tcl", ".go", ".rs", ".swift", ".dart", ".zig", ".sh", ".bash", ".zsh", ".fish", ".ksh", ".bat", ".cmd", ".ps1", ".psm1", ".sql", ".r", ".m", ".jl", ".f", ".f90", ".for", ".vb", ".vbs", ".asm", ".s", # Data / config ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".config", ".properties", ".csv", ".tsv", ".tab", ".xml", ".svg", ".kml", ".gpx", # Docs / typesetting ".tex", ".bib", ".cls", ".sty", ".org", ".adoc", ".asciidoc", # Misc text formats often found in working folders ".diff", ".patch", ".env", ".gitignore", ".gitattributes", ".dockerignore", ".editorconfig", ".srt", ".vtt", ".ass", }) # Phase 3a.4: bundled system library. Parsed at startup as a `.qnu` # file (same grammar as user.qnu) and loaded into self._system_lib. # These routines ship with QuickNotes; the user library can override # any of them by re-defining the same name in user.qnu. Each routine # uses Phase 2 features (specials, IF, INPUT, RETURN, SETERR) so it # also serves as worked-example documentation. _SYSTEM_LIBRARY_SOURCE = """\ # system.qnu -- bundled QuickNotes system library. # # These routines ship with QuickNotes and load automatically at # startup. Override any of them by defining a same-named SUB in # your own user.qnu (the user library wins on collision). The # default loaded set is intentionally small -- enough to be useful # and to demonstrate idiomatic QPL. SUB HELLO # DOC: Friendly hello-world example. # DOC: Prints "Hello, !" -- defaults to "World" with no arg. SET who = $1 IF NOT "$who" SET who = World ENDIF PR Hello, $who! ENDSUB SUB TIMESTAMP # DOC: Insert the current date and time at the cursor in # DOC: YYYY-MM-DD HH:MM:SS format. WR $DA $TM ENDSUB # Note: CHARCOUNT and WORDCOUNT used to live here as routines, but # the interaction between $-substitution, escape handling, and the # expression lexer turned out to be more brittle than the routine # form could reliably handle. They're now built-in commands (see # _cmd_charcount / _cmd_wordcount in QuickNotesApp); the names # behave the same way at the cmdline. # Note: WRAPSEL used to live here as a routine but had unresolved # misbehavior (likely related to substituting a possibly-arbitrary # selection back through the dispatcher). Removed pending a # rewrite; could come back as a Python built-in later if useful. SUB WHEREAMI # DOC: Show diagnostic info about the current location: # DOC: active file path, cursor line/column, and the size # DOC: of any current selection. PR File: $FP PR Cursor: line $LN col $CX IF $DF PR Selection: $SL chars ENDIF ENDSUB """ # Theme palettes (VS-Code inspired). Used by render_markdown and the # app's _apply_theme() method to colorize editor / preview / sidebar. LIGHT_THEME = { "name": "light", "bg": "#ffffff", "fg": "#1a1a1a", "preview_bg": "#fafafa", "preview_fg": "#1a1a1a", "linenum_bg": "#f0f0f0", "linenum_fg": "#888888", "sidebar_bg": "#ffffff", "sidebar_fg": "#1a1a1a", "select_bg": "#cce8ff", "select_fg": "#1a1a1a", "cursor": "#000000", "link": "#1565c0", "hr": "#888888", "code_block_bg": "#f5f5f5", "code_inline_bg": "#f1f1f1", "code_keyword": "#af00db", "code_string": "#067d17", "code_comment": "#6a737d", "code_number": "#098658", "search_match_bg":"#ffe066", "search_match_fg":"#1a1a1a", # Window chrome (frames, ttk widgets, scrollbars, tab strip). # Light mode leaves these alone via the native 'vista' theme. "chrome_bg": "#f0f0f0", "chrome_fg": "#1a1a1a", "entry_bg": "#ffffff", "entry_fg": "#1a1a1a", "tab_active_bg": "#ffffff", "tab_inactive_bg":"#e1e1e1", "scrollbar_bg": "#c8c8c8", "scrollbar_trough":"#e8e8e8", "button_bg": "#e1e1e1", "button_active": "#cce8ff", # Border tint for ttk widget edges (TEntry/TCombobox/TButton/...). # Light mode leaves the native theme to draw these. "border_color": "#a0a0a0", # Status-line tints: own keys so we can tune the bottom strip # independently of the general chrome text. "status_fg": "#1a1a1a", "status_border": "#a0a0a0", "ttk_theme": "vista", } DARK_THEME = { "name": "dark", "bg": "#1e1e1e", "fg": "#d4d4d4", "preview_bg": "#252526", "preview_fg": "#d4d4d4", "linenum_bg": "#252526", "linenum_fg": "#858585", "sidebar_bg": "#252526", "sidebar_fg": "#cccccc", "select_bg": "#264f78", "select_fg": "#ffffff", "cursor": "#ffffff", "link": "#3794ff", "hr": "#666666", "code_block_bg": "#2d2d30", "code_inline_bg": "#333335", "code_keyword": "#c586c0", "code_string": "#ce9178", "code_comment": "#6a9955", "code_number": "#b5cea8", "search_match_bg":"#7a5800", "search_match_fg":"#ffffff", # Window chrome. Dark mode requires the 'clam' ttk theme on # Windows because 'vista' / 'winnative' refuse to honor most # color overrides (they pull colors straight from the OS). "chrome_bg": "#2d2d30", "chrome_fg": "#cccccc", "entry_bg": "#1e1e1e", "entry_fg": "#d4d4d4", "tab_active_bg": "#1e1e1e", "tab_inactive_bg":"#2d2d30", "scrollbar_bg": "#3c3c3c", "scrollbar_trough":"#252526", "button_bg": "#3c3c3c", "button_active": "#264f78", # Black borders around input fields in dark mode -- by default # clam draws them in a bright grey that reads as white against # the dark chrome. "border_color": "#000000", # Status-line tints. The status fg is slightly dimmer than the # general chrome_fg so the message strip reads as ambient info # rather than competing with the brighter editor text. "status_fg": "#a0a0a0", "status_border": "#000000", "ttk_theme": "clam", } def load_config(path: Optional[Path] = None) -> dict: path = path or CONFIG_PATH if path.exists(): try: return json.loads(path.read_text(encoding="utf-8")) except Exception: pass return {} def save_config(cfg: dict, path: Optional[Path] = None) -> None: path = path or CONFIG_PATH try: path.write_text(json.dumps(cfg, indent=2), encoding="utf-8") except Exception: pass def safe_filename(title: str) -> str: """Make a filesystem-safe filename out of a note title.""" cleaned = re.sub(r"[^\w\s.-]", "", title).strip() cleaned = re.sub(r"\s+", "-", cleaned) return cleaned or "untitled" def qn_abspath(p) -> Path: """Canonicalise `p` to an absolute Path WITHOUT dereferencing symlinks or Windows SUBST drive mappings. `Path.resolve()` does both, which on Windows turns a SUBST'd drive like `X:\\notes` (where X: is mapped via the SUBST command to e.g. `C:\\Users\\me\\Documents`) into the underlying physical path. That's a problem in two ways: the user's chosen drive letter disappears from saved paths and recent-files lists, and Windows ACLs sometimes grant access via the SUBST drive but not the underlying physical path, producing "Permission denied" on what would otherwise be a perfectly legal save. `os.path.abspath` makes the path absolute relative to the current working directory; `os.path.normpath` collapses `..` and redundant separators. Neither follows SUBST or symlinks on Windows, so the user's chosen drive letter survives the round trip. """ return Path(os.path.normpath(os.path.abspath(str(p)))) class Note: """In-memory representation of a single note backed by a .md file.""" def __init__(self, path: Path, title: str = "", tags: Optional[list] = None, body: str = "", created: Optional[str] = None, encoding_unsafe: bool = False): self.path = path self.title = title or path.stem self.tags = tags or [] self.body = body self.created = created or dt.datetime.now().isoformat(timespec="seconds") # True when path's bytes were NOT valid UTF-8 -- the body in # memory has U+FFFD replacements for invalid bytes, so saving # back as UTF-8 would mangle the file. The tab layer flips # read_only on for any Note with this flag set, so autosave # can never round-trip through encode-as-UTF-8 and corrupt the # original bytes. Manual save paths still write (and lose the # invalid bytes) -- that's an explicit user action. self.encoding_unsafe = encoding_unsafe @classmethod def load(cls, path: Path) -> "Note": # Strict UTF-8 first so the common case round-trips losslessly. # If that fails, decode with errors="replace" purely for # display, and flag the Note as encoding_unsafe so the tab # layer can short-circuit autosave for it -- writing the # replaced text back as UTF-8 would silently mangle every # non-UTF-8 byte in the file. encoding_unsafe = False try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: text = path.read_text(encoding="utf-8", errors="replace") encoding_unsafe = True meta: dict = {} body = text m = FRONTMATTER_RE.match(text) if m: for line in m.group(1).splitlines(): if ":" in line: k, v = line.split(":", 1) meta[k.strip().lower()] = v.strip() body = text[m.end():] tags = [] if meta.get("tags"): tags = [t.strip() for t in meta["tags"].split(",") if t.strip()] return cls( path=path, title=meta.get("title") or path.stem, tags=tags, body=body, created=meta.get("created"), encoding_unsafe=encoding_unsafe, ) def save(self) -> None: """Write the note to disk. For `.md` files we emit the YAML frontmatter QuickNotes uses to track title / tags / created in the sidebar; for any other extension (.qns, .qnu, .qnk, plain .txt, etc.) we write the body alone so the saved file stays parseable by the tools that consume it. Load is universally lenient: Note.load() strips any frontmatter it finds regardless of extension, so a .qns script that accidentally accumulated a header in an earlier revision will lose it the next time the user edits and saves the file. No migration needed.""" self.path.parent.mkdir(parents=True, exist_ok=True) if self.path.suffix.lower() == ".md": header = ( "---\n" f"title: {self.title}\n" f"tags: {', '.join(self.tags)}\n" f"created: {self.created}\n" "---\n" ) self.path.write_text(header + self.body, encoding="utf-8") else: self.path.write_text(self.body, encoding="utf-8") def rename_to(self, new_title: str) -> None: self.title = new_title new_path = self.path.with_name(safe_filename(new_title) + ".md") if new_path != self.path: counter = 1 base = new_path.stem while new_path.exists(): new_path = new_path.with_name(f"{base}-{counter}.md") counter += 1 try: if self.path.exists(): self.path.rename(new_path) except OSError: pass self.path = new_path def delete(self) -> None: try: if self.path.exists(): self.path.unlink() except OSError: pass class NoteStore: """Loads and tracks all notes inside a folder.""" def __init__(self, folder: Path): self.folder = folder self.folder.mkdir(parents=True, exist_ok=True) self.notes: list[Note] = [] self.refresh() def refresh(self) -> None: """Scan the notes folder and (re)populate `self.notes` with every text-like file we find. Subdirectories and dotfiles are skipped; binary extensions are excluded via the NOTE_LIST_EXTENSIONS allow-list. Files that fail to load (permission denied, etc.) are silently skipped so one broken file can't poison the list.""" self.notes = [] try: entries = sorted(self.folder.iterdir()) except OSError: entries = [] for p in entries: if not p.is_file(): continue if p.name.startswith("."): # Skip dotfiles (.gitignore, quicknotes.config.json # lives elsewhere anyway, etc.) so the pane stays # uncluttered by hidden working files. continue if p.suffix.lower() not in NOTE_LIST_EXTENSIONS: continue try: self.notes.append(Note.load(p)) except Exception: continue self.notes.sort(key=lambda n: n.title.lower()) def create(self, title: str = "Untitled") -> Note: path = self.folder / (safe_filename(title) + ".md") counter = 1 base = path.stem while path.exists(): path = path.with_name(f"{base}-{counter}.md") counter += 1 note = Note(path=path, title=title, body="") note.save() self.notes.append(note) self.notes.sort(key=lambda n: n.title.lower()) return note def all_tags(self) -> list[str]: tags = set() for n in self.notes: for t in n.tags: tags.add(t) return sorted(tags) # --------------------------------------------------------------------------- # Syntax highlighting for fenced code blocks (no external deps). # Each language defines small regex fragments; we combine them into one # alternation with named groups and walk the matches in order. Order # matters: comments must come before strings, strings before keywords. # --------------------------------------------------------------------------- CURSOR_STYLES = { # Width in pixels and blink-off time in ms. insertofftime=0 means # the cursor is steady (no blink); the standard Tk default blink # cycle is insertontime=600 / insertofftime=300. "thin": {"insertwidth": 1, "insertontime": 600, "insertofftime": 300}, "normal": {"insertwidth": 2, "insertontime": 600, "insertofftime": 300}, "thick": {"insertwidth": 4, "insertontime": 600, "insertofftime": 300}, "block": {"insertwidth": 8, "insertontime": 600, "insertofftime": 0}, } _PY_KW = ( "False None True and as assert async await break class continue def del " "elif else except finally for from global if import in is lambda nonlocal " "not or pass raise return try while with yield" ).split() _JS_KW = ( "break case catch class const continue debugger default delete do else " "export extends false finally for function if import in instanceof let " "new null of return super switch this throw true try typeof undefined " "var void while with yield async await" ).split() _SH_KW = ( "if then else elif fi for in do done while until case esac function " "return break continue local export readonly declare typeset " "echo printf cd ls cat grep awk sed cp mv rm mkdir rmdir touch " "chmod chown sudo source true false test" ).split() _SQL_KW = [w.lower() for w in ( "SELECT FROM WHERE JOIN INNER LEFT RIGHT FULL OUTER ON GROUP BY ORDER " "HAVING LIMIT OFFSET INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE " "DROP ALTER ADD COLUMN INDEX VIEW AS DISTINCT UNION ALL CASE WHEN THEN " "ELSE END NULL NOT AND OR IN LIKE BETWEEN IS EXISTS PRIMARY KEY FOREIGN " "REFERENCES DEFAULT CHECK CONSTRAINT UNIQUE WITH RECURSIVE" ).split()] _C_KW = ( "auto break case char const continue default do double else enum extern " "float for goto if inline int long register restrict return short " "signed sizeof static struct switch typedef union unsigned void volatile " "while _Atomic _Bool _Complex _Imaginary _Noreturn _Static_assert " "_Thread_local true false NULL bool" ).split() _CPP_KW = _C_KW + ( "alignas alignof and and_eq asm bitand bitor catch char8_t char16_t " "char32_t class compl concept consteval constexpr constinit const_cast " "co_await co_return co_yield decltype delete dynamic_cast explicit " "export false friend mutable namespace new noexcept not not_eq nullptr " "operator or or_eq private protected public reinterpret_cast requires " "static_assert static_cast template this thread_local throw true try " "typeid typename using virtual wchar_t xor xor_eq" ).split() _CSHARP_KW = ( "abstract as base bool break byte case catch char checked class const " "continue decimal default delegate do double else enum event explicit " "extern false finally fixed float for foreach goto if implicit in int " "interface internal is lock long namespace new null object operator out " "override params private protected public readonly ref return sbyte " "sealed short sizeof stackalloc static string struct switch this throw " "true try typeof uint ulong unchecked unsafe ushort using virtual void " "volatile while " # contextual keywords "add alias ascending async await by descending dynamic equals from get " "global group init into join let nameof on orderby partial record " "remove select set value var when where with yield" ).split() _AUTOIT_KW = ( "And ByRef Case Const ContinueCase ContinueLoop Default Dim Do Else " "ElseIf EndFunc EndIf EndSelect EndSwitch EndWith Enum Exit ExitLoop " "False For Func Global If In Local Next Not Null Or ReDim Return " "Select Static Step Switch Then To True Until Volatile WEnd While With" ).split() _ZIG_KW = ( "addrspace align allowzero and anyframe anytype asm async await break " "callconv catch comptime const continue defer else enum errdefer error " "export extern fn for if inline linksection noalias noinline nosuspend " "opaque or orelse packed pub resume return struct suspend switch test " "threadlocal try undefined union unreachable usingnamespace var volatile " "while true false null" ).split() def _kw_pattern(words): return r"\b(?:" + "|".join(map(re.escape, words)) + r")\b" # Each spec is a list of (tag, regex) pairs in priority order. # Triple-quoted string highlighting is intentionally omitted to keep the # patterns readable; single- and double-quoted literals cover the bulk # of code people paste into notes. _LANG_SPECS_RAW = { "python": [ ("comment", r"#[^\n]*"), ("string", r"[rbuRBU]{0,2}'(?:[^'\\\n]|\\.)*'"), ("string", r'[rbuRBU]{0,2}"(?:[^"\\\n]|\\.)*"'), ("number", r"\b0[xX][0-9a-fA-F_]+\b|\b\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?\b"), ("keyword", _kw_pattern(_PY_KW)), ], "javascript": [ ("comment", r"//[^\n]*"), ("comment", r"/\*[\s\S]*?\*/"), ("string", r"`(?:[^`\\]|\\.)*`"), ("string", r"'(?:[^'\\\n]|\\.)*'"), ("string", r'"(?:[^"\\\n]|\\.)*"'), ("number", r"\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b"), ("keyword", _kw_pattern(_JS_KW)), ], "bash": [ ("comment", r"#[^\n]*"), ("string", r"'[^'\n]*'"), ("string", r'"(?:[^"\\\n]|\\.)*"'), ("number", r"\b\d+\b"), ("keyword", _kw_pattern(_SH_KW)), ], "json": [ ("string", r'"(?:[^"\\\n]|\\.)*"'), ("number", r"-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b"), ("keyword", r"\b(?:true|false|null)\b"), ], "sql": [ ("comment", r"--[^\n]*"), ("comment", r"/\*[\s\S]*?\*/"), ("string", r"'(?:[^'\n]|'')*'"), ("number", r"\b\d+(?:\.\d+)?\b"), ("keyword", "(?i:" + _kw_pattern(_SQL_KW) + ")"), ], "c": [ ("comment", r"//[^\n]*"), ("comment", r"/\*[\s\S]*?\*/"), ("string", r'"(?:[^"\\\n]|\\.)*"'), ("string", r"'(?:[^'\\\n]|\\.)*'"), ("number", r"\b0[xX][0-9a-fA-F]+[uUlL]*\b|\b\d+\.?\d*(?:[eE][+-]?\d+)?[fFlLuU]*\b"), ("keyword", _kw_pattern(_C_KW)), ], "cpp": [ ("comment", r"//[^\n]*"), ("comment", r"/\*[\s\S]*?\*/"), ("string", r'"(?:[^"\\\n]|\\.)*"'), ("string", r"'(?:[^'\\\n]|\\.)*'"), ("number", r"\b0[xX][0-9a-fA-F]+[uUlL]*\b|\b\d+\.?\d*(?:[eE][+-]?\d+)?[fFlLuU]*\b"), ("keyword", _kw_pattern(_CPP_KW)), ], "csharp": [ ("comment", r"///[^\n]*"), ("comment", r"//[^\n]*"), ("comment", r"/\*[\s\S]*?\*/"), # Verbatim strings @"..." use "" as the escape; do these first. ("string", r'@"(?:[^"]|"")*"'), # Regular and $-interpolated strings; standard \ escapes. ("string", r'\$?"(?:[^"\\\n]|\\.)*"'), ("string", r"'(?:[^'\\\n]|\\.)*'"), ("number", r"\b0[xX][0-9a-fA-F]+[uUlL]*\b|\b\d+\.?\d*(?:[eE][+-]?\d+)?[fFdDmMuUlL]*\b"), ("keyword", _kw_pattern(_CSHARP_KW)), ], "autoit": [ # AutoIt line comment uses ; -- everything after, until newline. ("comment", r";[^\n]*"), # AutoIt string escapes are doubled quotes ("" or ''). ("string", r'"(?:[^"\n]|"")*"'), ("string", r"'(?:[^'\n]|'')*'"), ("number", r"\b0[xX][0-9a-fA-F]+\b|\b\d+\.?\d*(?:[eE][+-]?\d+)?\b"), # AutoIt is case-insensitive for keywords. ("keyword", "(?i:" + _kw_pattern(_AUTOIT_KW) + ")"), ], "zig": [ ("comment", r"//[^\n]*"), ("string", r'"(?:[^"\\\n]|\\.)*"'), ("string", r"'(?:[^'\\\n]|\\.)*'"), ("number", r"\b0[xX][0-9a-fA-F_]+\b|\b\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?\b"), ("keyword", _kw_pattern(_ZIG_KW)), ], } # Aliases for fence labels users actually type. _LANG_ALIASES = { "py": "python", "python3": "python", "js": "javascript", "ts": "javascript", "typescript": "javascript", "node": "javascript", "jsx": "javascript", "tsx": "javascript", "sh": "bash", "shell": "bash", "zsh": "bash", "bat": "bash", "cmd": "bash", "batch": "bash", "psql": "sql", "mysql": "sql", "postgres": "sql", # C family "h": "c", "c": "c", "c++": "cpp", "cxx": "cpp", "cc": "cpp", "hpp": "cpp", "hxx": "cpp", "cs": "csharp", "c#": "csharp", # AutoIt "au3": "autoit", "a3x": "autoit", # Zig is just "zig" } def _build_lang_spec(rules): # Combine (tag, pattern) rules into one compiled alternation with # named groups. Group names are g0, g1, ... so we can recover the # semantic tag from whichever group matched. parts = [] tags = [] for i, (tag, pat) in enumerate(rules): parts.append(f"(?P{pat})") tags.append(tag) return re.compile("|".join(parts), re.MULTILINE), tags _LANG_SPECS = { name: _build_lang_spec(rules) for name, rules in _LANG_SPECS_RAW.items() } def _resolve_lang(label): label = (label or "").strip().lower() label = _LANG_ALIASES.get(label, label) return label if label in _LANG_SPECS else None def _tokenize_code(code, lang): # Yield (text, semantic_tag_or_None) for highlighting. pattern, tag_names = _LANG_SPECS[lang] last = 0 for m in pattern.finditer(code): if m.start() > last: yield code[last:m.start()], None gd = m.groupdict() for i, name in enumerate(tag_names): if gd.get(f"g{i}") is not None: yield m.group(0), name break last = m.end() if last < len(code): yield code[last:], None def _insert_highlighted_code(widget, code, lang_label): # Insert a code block, with syntax-highlight tags if we know the lang. # We insert each token with the "codeblock" tag, then add the syntax # tag in a separate tag_add call. Using two calls (instead of passing # a tuple of tags to insert()) is more reliable across Tk versions. lang = _resolve_lang(lang_label) if lang is None: widget.insert("end", code, "codeblock") return for text, tag in _tokenize_code(code, lang): if not text: continue start = widget.index("end-1c") widget.insert("end", text, "codeblock") if tag is not None: end = widget.index("end-1c") widget.tag_add(f"code_{tag}", start, end) # --------------------------------------------------------------------------- # Markdown -> Text widget renderer (no external deps) # --------------------------------------------------------------------------- _LIST_BULLET_RE = re.compile(r"^([ \t]*)([-*+])\s+(.*)$") _LIST_NUMBER_RE = re.compile(r"^([ \t]*)(\d+)[.)]\s+(.*)$") _BULLET_BY_DEPTH = ( "•", # • level 1: solid round (U+2022 BULLET) "○", # ○ level 2: hollow round (U+25CB WHITE CIRCLE) "▪", # ▪ level 3+: small square (U+25AA BLACK SMALL SQUARE) ) def _parse_list_line(line: str): """Detect a Markdown list line (bulleted or numbered). Returns (depth, marker_text, content, kind, task) or None if the line isn't a list item. `depth` is the indent level: every 2 columns of leading whitespace counts as one nesting level (tabs expand to 2 chars). `marker_text` is what to render at the bullet position. For unordered lists, the marker cycles through three glyphs by depth: solid round (`•`) at the top, hollow round (`○`) for nested-once, and small square (`▪`) for nested-twice-or-more. This matches the disc / circle / square default that browsers apply to nested