├── noteworthy ├── __init__.py ├── core │ ├── __init__.py │ ├── sync.py │ ├── config_mgmt.py │ ├── fs_sync.py │ └── templates.py ├── tui │ ├── __init__.py │ ├── components │ │ ├── __init__.py │ │ └── common.py │ ├── wizards │ │ ├── __init__.py │ │ ├── hierarchy.py │ │ ├── schemes.py │ │ ├── sync.py │ │ └── init.py │ ├── keybinds.py │ ├── app.py │ ├── editors │ │ ├── __init__.py │ │ ├── indexignore.py │ │ ├── snippets.py │ │ ├── text.py │ │ ├── config.py │ │ └── hierarchy.py │ └── menus.py ├── __main__.py ├── config.py └── utils.py ├── tutor.pdf ├── images ├── main.png ├── build.png ├── config.png ├── preface.png ├── scheme.png ├── building.png ├── hierarchy.png ├── snippets.png ├── example-01.png ├── example-02.png ├── example-03.png ├── example-04.png ├── indexignore.png ├── themes │ ├── nord.png │ ├── dracula.png │ ├── gruvbox.png │ ├── everforest.png │ ├── moonlight.png │ ├── rose-pine.png │ ├── tokyo-night.png │ ├── noteworthy-dark.png │ ├── solarized-dark.png │ ├── solarized-light.png │ ├── catppuccin-latte.png │ ├── catppuccin-mocha.png │ └── noteworthy-light.png ├── wizard_color.png ├── wizard_demo.gif ├── wizard_title.png ├── editor-select.png ├── wizard_authors.png ├── wizard_welcome.png ├── wizard_bodyFont.png ├── wizard_subtitle.png ├── wizard_titleFont.png ├── wizard_affiliation.png ├── wizard_chapterLabel.png └── wizard_sectionLabel.png ├── content ├── 0 │ ├── 1.typ │ └── 0.typ ├── 1 │ ├── 1.typ │ ├── 3.typ │ ├── 0.typ │ └── 2.typ └── 2 │ ├── 1.typ │ ├── 0.typ │ └── 2.typ ├── .gitignore ├── templates ├── config │ ├── snippets.typ │ ├── config.json │ ├── preface.typ │ └── hierarchy.json ├── default-schemes.typ ├── covers │ ├── preface.typ │ ├── chapter-cover.typ │ ├── page-title.typ │ └── main-cover.typ ├── parser.typ ├── setup.typ ├── layouts │ ├── outline.typ │ └── blocks.typ ├── plots │ ├── grapher.typ │ ├── tableplot.typ │ ├── spaceplot.typ │ ├── combiplot.typ │ ├── geoplot.typ │ ├── plots.typ │ └── vectorplot.typ └── templater.typ ├── LICENSE ├── themes.md ├── noteworthy.py └── README.md /noteworthy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noteworthy/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noteworthy/tui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noteworthy/tui/components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /noteworthy/tui/wizards/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tutor.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/tutor.pdf -------------------------------------------------------------------------------- /images/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/main.png -------------------------------------------------------------------------------- /images/build.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/build.png -------------------------------------------------------------------------------- /images/config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/config.png -------------------------------------------------------------------------------- /images/preface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/preface.png -------------------------------------------------------------------------------- /images/scheme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/scheme.png -------------------------------------------------------------------------------- /images/building.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/building.png -------------------------------------------------------------------------------- /images/hierarchy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/hierarchy.png -------------------------------------------------------------------------------- /images/snippets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/snippets.png -------------------------------------------------------------------------------- /images/example-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/example-01.png -------------------------------------------------------------------------------- /images/example-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/example-02.png -------------------------------------------------------------------------------- /images/example-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/example-03.png -------------------------------------------------------------------------------- /images/example-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/example-04.png -------------------------------------------------------------------------------- /images/indexignore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/indexignore.png -------------------------------------------------------------------------------- /images/themes/nord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/nord.png -------------------------------------------------------------------------------- /images/wizard_color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_color.png -------------------------------------------------------------------------------- /images/wizard_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_demo.gif -------------------------------------------------------------------------------- /images/wizard_title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_title.png -------------------------------------------------------------------------------- /images/editor-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/editor-select.png -------------------------------------------------------------------------------- /images/themes/dracula.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/dracula.png -------------------------------------------------------------------------------- /images/themes/gruvbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/gruvbox.png -------------------------------------------------------------------------------- /images/wizard_authors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_authors.png -------------------------------------------------------------------------------- /images/wizard_welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_welcome.png -------------------------------------------------------------------------------- /images/themes/everforest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/everforest.png -------------------------------------------------------------------------------- /images/themes/moonlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/moonlight.png -------------------------------------------------------------------------------- /images/themes/rose-pine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/rose-pine.png -------------------------------------------------------------------------------- /images/wizard_bodyFont.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_bodyFont.png -------------------------------------------------------------------------------- /images/wizard_subtitle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_subtitle.png -------------------------------------------------------------------------------- /images/wizard_titleFont.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_titleFont.png -------------------------------------------------------------------------------- /images/themes/tokyo-night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/tokyo-night.png -------------------------------------------------------------------------------- /images/wizard_affiliation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_affiliation.png -------------------------------------------------------------------------------- /images/wizard_chapterLabel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_chapterLabel.png -------------------------------------------------------------------------------- /images/wizard_sectionLabel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/wizard_sectionLabel.png -------------------------------------------------------------------------------- /images/themes/noteworthy-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/noteworthy-dark.png -------------------------------------------------------------------------------- /images/themes/solarized-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/solarized-dark.png -------------------------------------------------------------------------------- /images/themes/solarized-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/solarized-light.png -------------------------------------------------------------------------------- /images/themes/catppuccin-latte.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/catppuccin-latte.png -------------------------------------------------------------------------------- /images/themes/catppuccin-mocha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/catppuccin-mocha.png -------------------------------------------------------------------------------- /images/themes/noteworthy-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sihooleebd/noteworthy/HEAD/images/themes/noteworthy-light.png -------------------------------------------------------------------------------- /content/1/1.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = Geometry (Geoplot) 4 | 5 | The `geoplot` module provides tools for Euclidean geometry constructions. 6 | 7 | == Points & Polygons 8 | 9 | #rect-plot({ 10 | point((1, 1), "A", pos: "north") 11 | point((3, 1), "B", pos: "north") 12 | point((2, 3), "C", pos: "north") 13 | 14 | // Draw triangle connecting the points 15 | add-polygon(((1, 1), (3, 1), (2, 3)), label: "ABC") 16 | }) 17 | -------------------------------------------------------------------------------- /content/2/1.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = Combinatorics 4 | 5 | Visualizations for counting problems. 6 | 7 | == Stars and Bars (Boxes) 8 | 9 | #combi-plot({ 10 | draw-boxes(3, (2, 1, 3)) 11 | }) 12 | 13 | == Linear Arrangements 14 | 15 | #combi-plot({ 16 | draw-linear(("A", "B", "C", "D")) 17 | }) 18 | 19 | == Circular Arrangements 20 | 21 | #combi-plot({ 22 | draw-circular(("A", "B", "C", "D", "E"), radius: 2) 23 | }) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build artifacts 2 | templates/build/ 3 | output.pdf 4 | *.zip 5 | .build_settings.json 6 | 7 | # Typst cache 8 | .typst-cache/ 9 | 10 | # Python 11 | __pycache__/ 12 | *.pyc 13 | *.pyo 14 | 15 | # OS 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # IDE 20 | .vscode/ 21 | .idea/ 22 | *.swp 23 | *.swo 24 | 25 | # Temporary files 26 | *.tmp 27 | *.log 28 | extract_hierarchy.typ 29 | 30 | .indexignore 31 | build_settings.json 32 | 33 | # Exports 34 | exports/ -------------------------------------------------------------------------------- /content/1/3.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = 3D Space (Spaceplot) 4 | 5 | Render 3D scenes with correct perspective. 6 | 7 | #space-plot( 8 | view: (x: -90deg, y: -70deg, z: 0deg), 9 | { 10 | draw-vec((0, 0, 0), (2, 0, 0), label: $x$, color: red) 11 | draw-vec((0, 0, 0), (0, 2, 0), label: $y$, color: green) 12 | draw-vec((0, 0, 0), (0, 0, 2), label: $z$, color: blue) 13 | 14 | point((3, 3, 3), "P") 15 | draw-vec((0, 0, 0), (3, 3, 3), label: $vec(p)$) 16 | }, 17 | ) 18 | -------------------------------------------------------------------------------- /content/2/0.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = Function Graphs 4 | 5 | Plot mathematical functions easily. 6 | 7 | == Cartesian Functions 8 | 9 | #rect-plot({ 10 | plot-function(x => x * x - 2, type: "y=x", label: $y = x^2 - 2$) 11 | }) 12 | 13 | == Parametric Functions 14 | 15 | #rect-plot({ 16 | plot-function(t => (calc.cos(t), calc.sin(t)), type: "parametric", label: "Circle") 17 | }) 18 | 19 | == Polar Functions 20 | 21 | #rect-plot({ 22 | plot-function(t => 1 + calc.cos(t), type: "polar", label: "Cardioid") 23 | }) 24 | -------------------------------------------------------------------------------- /templates/config/snippets.typ: -------------------------------------------------------------------------------- 1 | #let st = [such that] 2 | #let wlog = [without loss of generality] 3 | #let qed = [$therefore$ Q.E.D.] 4 | #let sht = [show that] 5 | #let Sht = [Show that] 6 | #let sr = $attach(, t: 2)$ 7 | #let cb = $attach(, t: 3)$ 8 | #let sq(k) = $sqrt(#k)$ 9 | #let rd(body) = $attach(, t: body)$ 10 | #let invs = $attach(, t: -1)$ 11 | #let comp = $attach(, t: c)$ 12 | #let xy = $x y$ 13 | #let bmat(..cols) = $mat(..cols, delim: "[")$ 14 | #let Bmat(..cols) = $mat(..cols, delim: "{")$ 15 | #let vmat(..cols) = $mat(..cols, delim: "|")$ 16 | #let Vmat(..cols) = $mat(..cols, delim: "||")$ 17 | -------------------------------------------------------------------------------- /content/1/0.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = Basic Plots 4 | 5 | Noteworthy includes a powerful plotting engine based on CeTZ. 6 | 7 | == Rectangular Plots 8 | 9 | #rect-plot( 10 | x-domain: (-2, 2), 11 | y-domain: (-2, 2), 12 | { 13 | point((0, 0), "Origin", pos: "north-west", padding: -0.1) 14 | point((1, 1), "A", color: red, padding: 0) 15 | point((-1, 1), "B", color: blue, padding: 0) 16 | }, 17 | ) 18 | 19 | == Polar Plots 20 | 21 | #polar-plot( 22 | radius: 3, 23 | { 24 | add-polar(t => 2 * calc.sin(3 * t)) 25 | }, 26 | ) 27 | 28 | == Blank Plots (Combi-plot) 29 | 30 | Useful for diagrams without axes. 31 | 32 | #combi-plot({ 33 | draw-circular(("A", "B", "C"), radius: 1.5) 34 | }) 35 | -------------------------------------------------------------------------------- /content/1/2.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = Vectors (Vectorplot) 4 | 5 | Visualize vectors and vector operations. 6 | 7 | == Vector Drawing 8 | 9 | #combi-plot({ 10 | draw-vec((0, 0), (3, 2), label: $vec(u)$) 11 | draw-vec((0, 0), (1, 3), label: $vec(v)$, color: red) 12 | }) 13 | 14 | == Vector Components 15 | 16 | Shows the x and y components of a vector. 17 | 18 | #combi-plot({ 19 | // Draw the main vector first 20 | draw-vec((0, 0), (3, 2), label: $vec(v)$) 21 | // Then show its components 22 | draw-vec-comps((3, 2), label-x: "3", label-y: "2") 23 | }) 24 | 25 | == Vector Addition 26 | 27 | #combi-plot({ 28 | draw-vec-sum((3, 0), (1, 2), mode: "parallelogram") 29 | }) 30 | 31 | == Vector Projection 32 | 33 | #combi-plot({ 34 | draw-vec-proj((2, 3), (4, 0)) 35 | }) 36 | -------------------------------------------------------------------------------- /content/2/2.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = Tables 4 | 5 | Themed tables for data presentation. 6 | 7 | == Standard Table 8 | 9 | #table-plot( 10 | headers: ("Name", "Role", "Level"), 11 | data: ( 12 | ("Alice", "Engineer", "Senior"), 13 | ("Bob", "Designer", "Mid"), 14 | ("Charlie", "Manager", "Lead"), 15 | ), 16 | ) 17 | 18 | == Compact Table 19 | 20 | #compact-table( 21 | headers: ("ID", "Status"), 22 | data: ( 23 | ("001", "OK"), 24 | ("002", "Fail"), 25 | ("003", "OK"), 26 | ), 27 | ) 28 | 29 | == Value Table (Function Values) 30 | 31 | #value-table( 32 | variable: $x$, 33 | values: ("1", "2", "3"), 34 | func: $f(x)$, 35 | results: ("2", "4", "8"), 36 | ) 37 | 38 | == Grid Table 39 | 40 | #grid-table( 41 | data: ( 42 | ("100", "120"), 43 | ("110", "130"), 44 | ), 45 | ) 46 | -------------------------------------------------------------------------------- /templates/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Noteworthy Framework", 3 | "subtitle": "Examples & Documentation", 4 | "authors": [ 5 | "Sihoo Lee", 6 | "Lee Hojun" 7 | ], 8 | "affiliation": "Noteworthy", 9 | "logo": null, 10 | "show-solution": true, 11 | "solutions-text": "Solutions", 12 | "problems-text": "Problems", 13 | "chapter-name": "Chapter", 14 | "subchap-name": "Section", 15 | "font": "IBM Plex Serif", 16 | "title-font": "Noto Sans Adlam", 17 | "display-cover": true, 18 | "display-outline": true, 19 | "display-chap-cover": true, 20 | "box-margin": "5pt", 21 | "box-inset": "15pt", 22 | "render-sample-count": 1000, 23 | "render-implicit-count": 400, 24 | "display-mode": "rose-pine", 25 | "pad-chapter-id": true, 26 | "pad-page-id": true, 27 | "heading-numbering": "1.1" 28 | } -------------------------------------------------------------------------------- /content/0/1.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = Layout Elements 4 | 5 | This section demonstrates various layout utilities available in Noteworthy. 6 | 7 | == Equations 8 | 9 | #equation("Maxwell's Equations")[ 10 | $ 11 | nabla dot vec(E) & = rho / epsilon_0 \ 12 | nabla dot vec(B) & = 0 \ 13 | nabla times vec(E) & = - partial vec(B) / partial t \ 14 | nabla times vec(B) & = mu_0 vec(J) + mu_0 epsilon_0 partial vec(E) / partial t 15 | $ 16 | ] 17 | 18 | == Conditional Content 19 | 20 | Noteworthy supports conditional rendering based on the `show-solution` configuration. 21 | 22 | #if show-solution [ 23 | #note("Instructor's Note")[ 24 | This content is only visible when `show-solution` is set to `true` in `config.typ`. 25 | ] 26 | ] 27 | 28 | == Custom Snippets 29 | 30 | You can define custom math snippets in `config.typ` for faster typing. 31 | 32 | $ 33 | st \ 34 | wlog \ 35 | qed 36 | $ 37 | -------------------------------------------------------------------------------- /templates/config/preface.typ: -------------------------------------------------------------------------------- 1 | Welcome to the *Noteworthy Framework*. This document serves as both a demonstration of the framework's capabilities and a reference for its features. 2 | 3 | #v(1.5em) 4 | 5 | = About Noteworthy 6 | 7 | #v(0.5em) 8 | 9 | Noteworthy is a modular framework for creating beautiful educational documents in Typst. It provides a comprehensive set of tools for: 10 | 11 | - *Structured Layouts*: Automated chapters, sections, and covers. 12 | - *Themed Components*: Pre-styled blocks for definitions, theorems, examples, and more. 13 | - *Advanced Plotting*: Integrated 2D and 3D plotting capabilities. 14 | - *Customizable Themes*: A robust theming engine with multiple built-in presets. 15 | 16 | #v(1.5em) 17 | 18 | = Using This Guide 19 | 20 | #v(0.5em) 21 | 22 | Each section of this document demonstrates a specific module of the framework. You can find the source code for these examples in the `content/` directory, which serves as a practical reference for your own documents. -------------------------------------------------------------------------------- /noteworthy/__main__.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import argparse 3 | import sys 4 | import logging 5 | import shutil 6 | import os 7 | from pathlib import Path 8 | from .config import BUILD_DIR 9 | from .tui.app import run_app 10 | 11 | def main(): 12 | parser = argparse.ArgumentParser(description='Build Noteworthy documentation', add_help=False) 13 | args = parser.parse_args() 14 | logging.basicConfig(level=logging.CRITICAL) 15 | os.environ.setdefault('ESCDELAY', '25') 16 | try: 17 | curses.wrapper(lambda scr: run_app(scr, args)) 18 | except KeyboardInterrupt: 19 | print('\nBuild cancelled.') 20 | if BUILD_DIR.exists(): 21 | shutil.rmtree(BUILD_DIR) 22 | sys.exit(1) 23 | except Exception as e: 24 | print(f'\nBuild failed: {e}') 25 | import traceback 26 | traceback.print_exc() 27 | if BUILD_DIR.exists(): 28 | shutil.rmtree(BUILD_DIR) 29 | sys.exit(1) 30 | if __name__ == '__main__': 31 | main() -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Noteworthy(Sihoo Lee & Hojun Lee) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /noteworthy/core/sync.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from ..config import HIERARCHY_FILE 4 | 5 | def sync_hierarchy_with_content(): 6 | hierarchy = json.loads(HIERARCHY_FILE.read_text()) 7 | missing_files = [] 8 | new_files = [] 9 | for i, ch in enumerate(hierarchy): 10 | for j, pg in enumerate(ch.get('pages', [])): 11 | path = Path(f'content/{i}/{j}.typ') 12 | if not path.exists(): 13 | missing_files.append(str(path)) 14 | content_dir = Path('content') 15 | if content_dir.exists(): 16 | for ch_dir in content_dir.iterdir(): 17 | if ch_dir.is_dir() and ch_dir.name.isdigit(): 18 | i = int(ch_dir.name) 19 | if i >= len(hierarchy): 20 | for f in ch_dir.glob('*.typ'): 21 | new_files.append(str(f)) 22 | else: 23 | for f in ch_dir.glob('*.typ'): 24 | if f.stem.isdigit(): 25 | j = int(f.stem) 26 | pages = hierarchy[i].get('pages', []) 27 | if j >= len(pages): 28 | new_files.append(str(f)) 29 | return (sorted(missing_files), sorted(new_files)) -------------------------------------------------------------------------------- /noteworthy/core/config_mgmt.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | from datetime import datetime 4 | from ..config import BASE_DIR 5 | EXPORT_DIR = BASE_DIR / 'exports' 6 | 7 | def export_file(file_path, suffix=None): 8 | p = Path(file_path) 9 | if not p.exists(): 10 | return None 11 | EXPORT_DIR.mkdir(exist_ok=True, parents=True) 12 | ts = datetime.now().strftime('%Y%m%d_%H%M%S') 13 | name = f'{p.stem}_{ts}' 14 | if suffix: 15 | name += f'_{suffix}' 16 | name += p.suffix 17 | out = EXPORT_DIR / name 18 | try: 19 | shutil.copy(p, out) 20 | return str(out) 21 | except: 22 | return None 23 | 24 | def import_file(export_path, target_path): 25 | src = Path(export_path) 26 | dst = Path(target_path) 27 | if not src.exists(): 28 | return False 29 | try: 30 | dst.parent.mkdir(parents=True, exist_ok=True) 31 | shutil.copy(src, dst) 32 | return True 33 | except: 34 | return False 35 | 36 | def list_exports_for(filename): 37 | if not EXPORT_DIR.exists(): 38 | return [] 39 | stem = Path(filename).stem 40 | ext = Path(filename).suffix 41 | res = [] 42 | for f in EXPORT_DIR.glob(f'{stem}_*{ext}'): 43 | res.append(f.name) 44 | return sorted(res, reverse=True) -------------------------------------------------------------------------------- /noteworthy/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | BASE_DIR = Path(__file__).parent.parent.resolve() 3 | BUILD_DIR = BASE_DIR / 'templates/build' 4 | OUTPUT_FILE = BASE_DIR / 'output.pdf' 5 | RENDERER_FILE = BASE_DIR / 'templates/parser.typ' 6 | SYSTEM_CONFIG_DIR = BASE_DIR / 'templates/systemconfig' 7 | SETTINGS_FILE = SYSTEM_CONFIG_DIR / 'build_settings.json' 8 | INDEXIGNORE_FILE = SYSTEM_CONFIG_DIR / '.indexignore' 9 | CONFIG_FILE = BASE_DIR / 'templates/config/config.json' 10 | HIERARCHY_FILE = BASE_DIR / 'templates/config/hierarchy.json' 11 | PREFACE_FILE = BASE_DIR / 'templates/config/preface.typ' 12 | SNIPPETS_FILE = BASE_DIR / 'templates/config/snippets.typ' 13 | SCHEMES_FILE = BASE_DIR / 'templates/config/schemes.json' 14 | SETUP_FILE = BASE_DIR / 'templates/setup.typ' 15 | 16 | LOGO = [' ,--. ', " ,--.'| ", ' ,--,: : | ', ",`--.'`| ' : ", '| : : | | ', ': | \\ | : ', "| : ' '; | ", "' ' ;. ; ", '| | | \\ | ', "' : | ; .' ", "| | '`--' ", "' : | ", "; |.' ", "'---' "] 17 | HAPPY_FACE = [' __ ', ' _ \\ \\ ', '(_) | |', ' | |', ' _ | |', '(_) | |', ' /_/ '] 18 | HMM_FACE = [' _ ', ' _ | |', '(_) | |', ' | |', ' _ | |', '(_) | |', ' |_|'] 19 | SAD_FACE = [' __', ' _ / /', ' (_) | | ', ' | | ', ' _ | | ', ' (_) | | ', ' \\_\\'] -------------------------------------------------------------------------------- /templates/default-schemes.typ: -------------------------------------------------------------------------------- 1 | // Load color schemes from JSON 2 | #let schemes-data = json("config/schemes.json") 3 | 4 | // Helper to convert hex string to rgb color 5 | #let hex-to-rgb(hex) = { 6 | if hex == none { return none } 7 | rgb(hex) 8 | } 9 | 10 | // Helper to build a scheme from JSON data 11 | #let build-scheme(data) = { 12 | let blocks = (:) 13 | for (name, block) in data.blocks { 14 | blocks.insert(name, ( 15 | fill: hex-to-rgb(block.fill), 16 | stroke: hex-to-rgb(block.stroke), 17 | title: block.title, 18 | )) 19 | } 20 | 21 | ( 22 | page-fill: hex-to-rgb(data.page-fill), 23 | text-main: hex-to-rgb(data.text-main), 24 | text-heading: hex-to-rgb(data.text-heading), 25 | text-muted: hex-to-rgb(data.text-muted), 26 | text-accent: hex-to-rgb(data.text-accent), 27 | blocks: blocks, 28 | plot: ( 29 | stroke: hex-to-rgb(data.plot.stroke), 30 | highlight: hex-to-rgb(data.plot.highlight), 31 | grid: hex-to-rgb(data.text-main).transparentize(100% - data.plot.grid-opacity * 100%), 32 | bg: none, 33 | ), 34 | ) 35 | } 36 | 37 | // Build all schemes from JSON 38 | // Build all schemes from JSON dynamically 39 | #let schemes = { 40 | let s = (:) 41 | for (name, data) in schemes-data { 42 | s.insert(name, build-scheme(data)) 43 | } 44 | s 45 | } 46 | -------------------------------------------------------------------------------- /templates/covers/preface.typ: -------------------------------------------------------------------------------- 1 | #import "../setup.typ": * 2 | 3 | #let preface( 4 | theme: (:), 5 | content: [], 6 | authors: (), 7 | ) = { 8 | page( 9 | paper: "a4", 10 | fill: theme.page-fill, 11 | margin: (x: 2.5cm, y: 2.5cm), 12 | header: none, 13 | footer: none, 14 | )[ 15 | #line(length: 100%, stroke: 1pt + theme.text-muted) 16 | 17 | #v(2cm) 18 | 19 | #text( 20 | font: title-font, 21 | size: 36pt, 22 | weight: "bold", 23 | tracking: 1pt, 24 | fill: theme.text-heading, 25 | )[Preface] 26 | 27 | #v(1.5cm) 28 | 29 | #line(length: 100%, stroke: 1pt + theme.text-muted) 30 | 31 | #v(2cm) 32 | 33 | #text(font: font, fill: theme.text-main, size: 11pt)[ 34 | #content 35 | ] 36 | 37 | #v(2em) 38 | 39 | #line(length: 100%, stroke: 0.5pt + theme.text-muted) 40 | 41 | #v(1em) 42 | 43 | #align(right)[ 44 | #text( 45 | font: title-font, 46 | fill: theme.text-main, 47 | size: 12pt, 48 | style: "italic", 49 | )[ 50 | #if type(authors) == array { 51 | authors.join(" & ") 52 | } else { 53 | authors 54 | }\ 55 | #text(size: 10pt, fill: theme.text-muted)[ 56 | #affiliation 57 | ] 58 | ] 59 | ] 60 | ] 61 | counter(page).step() 62 | } 63 | -------------------------------------------------------------------------------- /content/0/0.typ: -------------------------------------------------------------------------------- 1 | #import "../../templates/templater.typ": * 2 | 3 | = Content Blocks 4 | 5 | Noteworthy provides a variety of semantic blocks to structure your educational content. 6 | 7 | == Definitions & Theorems 8 | 9 | #definition("Vector")[ 10 | A *vector* is a quantity that has both magnitude and direction. It is often represented by an arrow. 11 | ] 12 | 13 | #theorem("Pythagorean Theorem")[ 14 | In a right-angled triangle, the square of the hypotenuse is equal to the sum of the squares of the other two sides: 15 | $ a^2 + b^2 = c^2 $ 16 | ] 17 | 18 | == Proofs & Solutions 19 | 20 | #proof[ 21 | Let $a, b$ be the lengths of the legs and $c$ be the length of the hypotenuse. 22 | Construct a square of side $a+b$... 23 | $therefore$ The area relationships confirm the theorem. 24 | ] 25 | 26 | #example("Finding the Hypotenuse")[ 27 | Given a right triangle with legs of length 3 and 4, find the length of the hypotenuse. 28 | ] 29 | 30 | #solution[ 31 | Using the Pythagorean theorem: 32 | $ c = sqrt(3^2 + 4^2) = sqrt(9 + 16) = sqrt(25) = 5 $ 33 | ] 34 | 35 | == Notes & Remarks 36 | 37 | #note("Important")[ 38 | Always remember to check the units when solving physics problems using vectors. 39 | ] 40 | 41 | #notation("Vector Notation")[ 42 | Vectors are typically denoted by boldface letters ($bold(v)$) or arrows ($arrow(v)$). 43 | ] 44 | 45 | #analysis("Geometric Interpretation")[ 46 | Geometrically, vectors can be added using the parallelogram rule or the triangle rule. 47 | ] 48 | -------------------------------------------------------------------------------- /templates/config/hierarchy.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Core Components", 4 | "summary": "This chapter demonstrates the fundamental building blocks of the Noteworthy framework, including text blocks, layouts, and basic document structure.", 5 | "pages": [ 6 | { 7 | "title": "Content Blocks" 8 | }, 9 | { 10 | "title": "Layout Elements" 11 | } 12 | ] 13 | }, 14 | { 15 | "title": "Plotting & Geometry", 16 | "summary": "Explore the powerful plotting capabilities of Noteworthy, from basic 2D graphs to complex geometric constructions and vector diagrams.", 17 | "pages": [ 18 | { 19 | "title": "Basic Plots" 20 | }, 21 | { 22 | "title": "Geometry (Geoplot)" 23 | }, 24 | { 25 | "title": "Vectors (Vectorplot)" 26 | }, 27 | { 28 | "title": "3D Space (Spaceplot)" 29 | } 30 | ] 31 | }, 32 | { 33 | "title": "Data & Visualization", 34 | "summary": "Learn how to visualize data and mathematical concepts using the Grapher, Combiplot, and Tableplot modules.", 35 | "pages": [ 36 | { 37 | "title": "Function Graphs" 38 | }, 39 | { 40 | "title": "Combinatorics" 41 | }, 42 | { 43 | "title": "Tables" 44 | } 45 | ] 46 | } 47 | ] -------------------------------------------------------------------------------- /templates/covers/chapter-cover.typ: -------------------------------------------------------------------------------- 1 | #import "../setup.typ": * 2 | 3 | #let chapter-cover( 4 | theme: (:), 5 | number: "", 6 | title: "", 7 | summary: none, 8 | ) = { 9 | // Extract chapter ID from number (e.g., "Chapter 01" -> "01") 10 | let chapter-id = if number != "" and number.starts-with("Chapter ") { 11 | number.slice(8) 12 | } else { 13 | number 14 | } 15 | 16 | page( 17 | paper: "a4", 18 | fill: theme.page-fill, 19 | margin: (x: 2.5cm, y: 2.5cm), 20 | header: none, 21 | footer: none, 22 | )[ 23 | #metadata((number, title, chapter-id)) 24 | #line(length: 100%, stroke: 1pt + theme.text-muted) 25 | 26 | #v(2cm) 27 | 28 | #if number != "" { 29 | text( 30 | font: title-font, 31 | size: 22pt, 32 | fill: theme.text-accent, 33 | tracking: 2pt, 34 | )[#number] 35 | } 36 | 37 | #v(0.5em) 38 | 39 | #text( 40 | font: title-font, 41 | size: 40pt, 42 | weight: "bold", 43 | fill: theme.text-heading, 44 | )[ 45 | #title 46 | ] 47 | 48 | #v(1cm) 49 | 50 | #line(length: 100%, stroke: 1pt + theme.text-muted) 51 | 52 | #v(2cm) 53 | 54 | #if summary != none { 55 | block(width: 100%)[ 56 | #text( 57 | font: font, 58 | fill: theme.text-main, 59 | size: 14pt, 60 | style: "italic", 61 | weight: "regular", 62 | )[ 63 | #summary 64 | ] 65 | ] 66 | } 67 | 68 | #v(1fr) 69 | ] 70 | counter(page).step() 71 | } 72 | -------------------------------------------------------------------------------- /noteworthy/core/fs_sync.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | DEFAULT_CONTENT_TEMPLATE = '#import "../../templates/templater.typ": *\n\nWrite your content here.' 5 | 6 | def ensure_content_structure(hierarchy, base_dir=Path('content')): 7 | created = [] 8 | base_dir.mkdir(parents=True, exist_ok=True) 9 | 10 | for ci, ch in enumerate(hierarchy): 11 | ch_dir = base_dir / str(ci) 12 | ch_dir.mkdir(exist_ok=True) 13 | 14 | for pi, pg in enumerate(ch.get('pages', [])): 15 | pg_file = ch_dir / f'{pi}.typ' 16 | if not pg_file.exists(): 17 | pg_file.write_text(DEFAULT_CONTENT_TEMPLATE) 18 | created.append(str(pg_file)) 19 | 20 | return created 21 | 22 | def cleanup_extra_files(hierarchy, base_dir=Path('content')): 23 | if not base_dir.exists(): 24 | return [] 25 | 26 | deleted = [] 27 | 28 | valid_paths = set() 29 | for ci, ch in enumerate(hierarchy): 30 | ch_dir = base_dir / str(ci) 31 | valid_paths.add(ch_dir) 32 | for pi, _ in enumerate(ch.get('pages', [])): 33 | valid_paths.add(ch_dir / f'{pi}.typ') 34 | 35 | for ch_dir in base_dir.iterdir(): 36 | if ch_dir.is_dir() and ch_dir.name.isdigit(): 37 | for f in ch_dir.glob('*.typ'): 38 | if f.stem.isdigit(): 39 | if f not in valid_paths: 40 | f.unlink() 41 | deleted.append(str(f)) 42 | 43 | if ch_dir not in valid_paths: 44 | if not any(ch_dir.iterdir()): 45 | ch_dir.rmdir() 46 | deleted.append(str(ch_dir)) 47 | else: 48 | pass 49 | 50 | return deleted 51 | -------------------------------------------------------------------------------- /themes.md: -------------------------------------------------------------------------------- 1 | # Themes 2 | 3 | Noteworthy comes with a variety of pre-built themes to style your documents. 4 | 5 | | | | | 6 | | :------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: | 7 | |
Catppuccin Latte |
Catppuccin Mocha |
Dracula | 8 | |
Everforest |
Gruvbox |
Moonlight | 9 | |
Nord |
Noteworthy Dark |
Noteworthy Light | 10 | |
Rose Pine |
Solarized Dark |
Solarized Light | 11 | |
Tokyo Night | | | 12 | -------------------------------------------------------------------------------- /noteworthy/tui/keybinds.py: -------------------------------------------------------------------------------- 1 | import curses 2 | 3 | class KeyBind: 4 | def __init__(self, keys, func, desc=None): 5 | self.keys = keys if isinstance(keys, (list, tuple)) else [keys] 6 | self.func = func 7 | self.desc = desc 8 | 9 | def __call__(self, context): 10 | return self.func(context) 11 | 12 | class SaveBind(KeyBind): 13 | def __init__(self, func=None): 14 | keys = [ord('s')] 15 | action = func if func else (lambda ctx: ctx.save() if hasattr(ctx, 'save') else None) 16 | super().__init__(keys, action, "Save") 17 | 18 | class ExitBind(KeyBind): 19 | def __init__(self, func=None): 20 | keys = [27] 21 | super().__init__(keys, func, "Back / Cancel") 22 | 23 | class ConfirmBind(KeyBind): 24 | def __init__(self, func=None): 25 | keys = [ord('\n'), 10, curses.KEY_ENTER] 26 | super().__init__(keys, func, "Select / Confirm") 27 | 28 | class ToggleBind(KeyBind): 29 | def __init__(self, func=None): 30 | keys = [ord(' ')] 31 | super().__init__(keys, func, "Toggle") 32 | 33 | class NavigationBind(KeyBind): 34 | def __init__(self, direction, func): 35 | keys = [] 36 | if direction == 'UP': 37 | keys = [curses.KEY_UP, ord('k')] 38 | elif direction == 'DOWN': 39 | keys = [curses.KEY_DOWN, ord('j')] 40 | elif direction == 'LEFT': 41 | keys = [curses.KEY_LEFT, ord('h')] 42 | elif direction == 'RIGHT': 43 | keys = [curses.KEY_RIGHT, ord('l')] 44 | elif direction == 'HOME': 45 | keys = [curses.KEY_HOME] 46 | elif direction == 'END': 47 | keys = [curses.KEY_END] 48 | elif direction == 'PGUP': 49 | keys = [curses.KEY_PPAGE] 50 | elif direction == 'PGDN': 51 | keys = [curses.KEY_NPAGE] 52 | 53 | super().__init__(keys, func, f"Navigate {direction.title()}") 54 | -------------------------------------------------------------------------------- /templates/covers/page-title.typ: -------------------------------------------------------------------------------- 1 | #import "../setup.typ": * 2 | #let project(theme: (:), number: "", title: "", author: "", affiliation: "", date: none, body) = { 3 | counter(heading).update(0) 4 | 5 | // Extract page ID from number (e.g., "Section 01.01" -> "01.01") 6 | let page-id = if number != "" and number.contains(" ") { 7 | number.split(" ").last() 8 | } else { 9 | number 10 | } 11 | 12 | set document(title: title, author: author) 13 | 14 | set page( 15 | paper: "a4", 16 | margin: (x: 1in, y: 1in), 17 | numbering: "1", 18 | fill: theme.page-fill, // Dynamic 19 | ) 20 | 21 | // Optional stylin 22 | set text(font: font, size: 11pt, fill: theme.text-main) 23 | context { 24 | let chapters = query(selector(metadata).before(here())).filter(el => { 25 | el.label != none and str(el.label).starts-with("chapter-") 26 | }) 27 | let chapter = if chapters.len() > 0 { chapters.last() } else { none } 28 | 29 | [#metadata((number, title, chapter)) #label(page-id)] 30 | 31 | let display_date = if date == none { 32 | datetime.today().display("[Month]/[day], [year]") 33 | } else { 34 | date 35 | } 36 | 37 | // set heading(numbering: "1.1.") 38 | show heading: it => block(below: 1em)[ 39 | #text(weight: "bold", fill: theme.text-heading, font: font, it) 40 | ] 41 | 42 | // Title Block 43 | align(left)[ 44 | #if number != "" [ 45 | #block(below: 1em)[ 46 | #text(size: 22pt, fill: theme.text-accent, font: title-font, number) 47 | ] 48 | ] 49 | #block(below: 1em)[ 50 | #text(weight: "bold", style: "italic", size: 40pt, font: title-font, title) 51 | ] 52 | 53 | #if author != "" [ 54 | #block(below: 0.5em)[ 55 | #text(size: 16pt, font: font, author) 56 | ] 57 | ] 58 | 59 | #if affiliation != "" [ 60 | #block(below: 0.5em)[ 61 | #text(size: 13pt, font: font, fill: theme.text-muted, affiliation) 62 | ] 63 | ] 64 | ] 65 | } 66 | 67 | line(length: 100%, stroke: 1pt + theme.text-muted) 68 | 69 | body 70 | } 71 | 72 | -------------------------------------------------------------------------------- /noteworthy/tui/app.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import json 3 | from ..config import CONFIG_FILE, HIERARCHY_FILE, SCHEMES_FILE 4 | from ..core.templates import restore_templates 5 | from ..core.sync import sync_hierarchy_with_content 6 | from .base import TUI 7 | from .wizards.init import InitWizard 8 | from .wizards.hierarchy import HierarchyWizard 9 | from .wizards.schemes import SchemesWizard 10 | from .wizards.sync import SyncWizard 11 | from .menus import MainMenu 12 | from .editors import show_editor_menu 13 | from .editors.hierarchy import HierarchyEditor 14 | from .components.build import BuildMenu, run_build_process 15 | from .components.common import show_error_screen 16 | 17 | def needs_init(): 18 | return not (CONFIG_FILE.exists() and HIERARCHY_FILE.exists() and SCHEMES_FILE.exists()) 19 | 20 | def run_build(scr): 21 | try: 22 | hierarchy = json.loads(HIERARCHY_FILE.read_text()) 23 | menu = BuildMenu(scr, hierarchy) 24 | res = menu.run() 25 | if res: 26 | run_build_process(scr, hierarchy, res) 27 | except Exception as e: 28 | show_error_screen(scr, e) 29 | 30 | def run_app(scr, args): 31 | TUI.init_colors() 32 | if not TUI.check_terminal_size(scr): 33 | return 34 | restore_templates(scr) 35 | if needs_init(): 36 | if not CONFIG_FILE.exists(): 37 | if InitWizard(scr).run() is None: 38 | return 39 | if not HIERARCHY_FILE.exists(): 40 | res = HierarchyWizard(scr).run() 41 | if res is None: 42 | return 43 | elif res == 'edit': 44 | HierarchyEditor(scr).run() 45 | if not SCHEMES_FILE.exists(): 46 | if SchemesWizard(scr).run() is None: 47 | return 48 | if HIERARCHY_FILE.exists(): 49 | missing, new = sync_hierarchy_with_content() 50 | if missing or new: 51 | if SyncWizard(scr, missing, new).run() is False: 52 | return 53 | while True: 54 | menu = MainMenu(scr) 55 | action = menu.run() 56 | if action is None or action == 'EXIT': 57 | break 58 | elif action == 'editor': 59 | show_editor_menu(scr) 60 | elif action == 'builder': 61 | run_build(scr) -------------------------------------------------------------------------------- /templates/parser.typ: -------------------------------------------------------------------------------- 1 | #import "templater.typ": * 2 | 3 | #let target = sys.inputs.at("target", default: none) 4 | #let page-offset = sys.inputs.at("page-offset", default: none) 5 | #set heading(numbering: heading-numbering) 6 | 7 | // Set page counter based on page offset 8 | #if page-offset != none { 9 | let offset-value = int(page-offset) 10 | counter(page).update(offset-value) 11 | } 12 | 13 | #if target == none or target == "cover" { 14 | if display-cover or target == "cover" { 15 | cover( 16 | title: title, 17 | subtitle: subtitle, 18 | authors: authors, 19 | affiliation: affiliation, 20 | ) 21 | } 22 | } 23 | 24 | #if target == none or target == "preface" { 25 | preface() 26 | } 27 | 28 | #if target == none or target == "outline" { 29 | if display-outline or target == "outline" { 30 | outline() 31 | } 32 | } 33 | 34 | #for (i, chapter) in hierarchy.enumerate() { 35 | let chapter-idx = str(i) 36 | let ch-num = str(chapter.at("number", default: i + 1)) 37 | let total-chapters = hierarchy.len() 38 | let chapter-display-id = format-chapter-id(ch-num, total-chapters) 39 | let total-pages = chapter.pages.len() 40 | 41 | if target == none or target == "chapter-" + chapter-idx { 42 | if display-chap-cover or target != none { 43 | chapter-cover( 44 | number: chapter-name + " " + chapter-display-id, 45 | title: chapter.title, 46 | summary: chapter.summary, 47 | ) 48 | } 49 | } 50 | 51 | for (j, page) in chapter.pages.enumerate() { 52 | let page-idx = str(j) 53 | let pg-num = str(page.at("number", default: j + 1)) 54 | let page-target = chapter-idx + "/" + page-idx 55 | let page-display-id = format-page-id(ch-num + "." + pg-num, total-pages, total-chapters) 56 | 57 | if target == none or target == page-target { 58 | // Inject chapter metadata if missing (for single page compilation) 59 | if target != none and target != "chapter-" + chapter-idx { 60 | [#metadata((chapter-name + " " + chapter-display-id, chapter.title)) #label("chapter-" + str(i + 1))] 61 | } 62 | show: project.with( 63 | number: chapter-name + " " + page-display-id, 64 | title: page.title, 65 | ) 66 | include "../content/" + chapter-idx + "/" + page-idx + ".typ" 67 | } 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /templates/covers/main-cover.typ: -------------------------------------------------------------------------------- 1 | #import "../setup.typ": * 2 | 3 | #let cover( 4 | theme: (:), 5 | title: "", 6 | subtitle: "", 7 | authors: (), 8 | affiliation: "", 9 | date: none, 10 | ) = { 11 | page( 12 | paper: "a4", 13 | fill: theme.page-fill, 14 | margin: (x: 2.5cm, y: 2.5cm), 15 | )[ 16 | #line(length: 100%, stroke: 1pt + theme.text-muted) 17 | 18 | #v(1cm) 19 | 20 | // Main Title 21 | #text( 22 | size: 36pt, 23 | weight: "bold", 24 | tracking: 1pt, 25 | font: title-font, 26 | fill: theme.text-heading, 27 | )[ 28 | #title 29 | ] 30 | 31 | #v(1cm) 32 | 33 | // Subtitle 34 | #if subtitle != "" { 35 | text( 36 | size: 18pt, 37 | fill: theme.text-accent, 38 | font: title-font, 39 | style: "italic", 40 | )[ 41 | #subtitle 42 | ] 43 | } 44 | #if show-solution { 45 | text( 46 | size: 18pt, 47 | fill: theme.text-accent, 48 | font: title-font, 49 | style: "italic", 50 | )[ 51 | (#solutions-text) 52 | ] 53 | } else { 54 | text( 55 | size: 18pt, 56 | fill: theme.text-accent, 57 | font: title-font, 58 | style: "italic", 59 | )[ 60 | (#problems-text) 61 | ] 62 | } 63 | 64 | #v(1cm) 65 | 66 | #line(length: 100%, stroke: 1pt + theme.text-muted) 67 | 68 | #v(3cm) 69 | 70 | #text(fill: theme.text-main, size: 14pt, font: title-font, style: "italic")[Written by :] 71 | 72 | #text(fill: theme.text-main, font: title-font, style: "italic", size: 16pt)[ 73 | #if type(authors) == array { 74 | authors.join(" / ") 75 | } else { 76 | authors 77 | } 78 | ] 79 | 80 | #v(2cm) 81 | #if logo != none { 82 | // We use a box to ensure it doesn't break weirdly across pages (unlikely here but safe) 83 | box(image(logo, width: 3cm)) 84 | v(0.5em) // Small gap between logo and affiliation text 85 | } 86 | #text(fill: theme.text-main, size: 14pt, font: title-font, style: "italic")[ 87 | #upper(affiliation) 88 | ] 89 | 90 | #v(1fr) 91 | 92 | // Date Section 93 | #text(font: font, fill: theme.text-muted)[ 94 | #if date == none { 95 | datetime.today().display() 96 | } else { 97 | date 98 | } 99 | ] 100 | ] 101 | counter(page).update(1) 102 | } 103 | -------------------------------------------------------------------------------- /noteworthy/tui/wizards/hierarchy.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import json 3 | from pathlib import Path 4 | from ..base import TUI 5 | from ...config import HIERARCHY_FILE, CONFIG_FILE 6 | 7 | class HierarchyWizard: 8 | 9 | def __init__(self, scr): 10 | self.scr = scr 11 | 12 | def run(self): 13 | try: 14 | hierarchy = [] 15 | content_dir = Path('content') 16 | has_content = False 17 | if content_dir.exists(): 18 | chapters = {} 19 | for ch_dir in sorted(content_dir.iterdir()): 20 | if not ch_dir.is_dir() or not ch_dir.name.isdigit(): 21 | continue 22 | try: 23 | ch_num = int(ch_dir.name) 24 | pages = [] 25 | for p in sorted(ch_dir.glob('*.typ')): 26 | pages.append({'id': p.stem, 'title': 'Untitled Section'}) 27 | if pages: 28 | try: 29 | config = json.loads(CONFIG_FILE.read_text()) 30 | chap_name = config.get('chapter-name', 'Chapter') 31 | except: 32 | chap_name = 'Chapter' 33 | chapters[ch_num] = {'title': f'{chap_name} {ch_num}', 'summary': '', 'pages': pages} 34 | has_content = True 35 | except: 36 | pass 37 | if has_content: 38 | hierarchy = [chapters[k] for k in sorted(chapters.keys())] 39 | if not has_content: 40 | try: 41 | config = json.loads(CONFIG_FILE.read_text()) 42 | chap_name = config.get('chapter-name', 'Chapter') 43 | sect_name = config.get('subchap-name', 'Section') 44 | except: 45 | chap_name = 'Chapter' 46 | sect_name = 'Section' 47 | hierarchy = [{'title': f'First {chap_name}', 'summary': 'Getting started', 'pages': [{'id': '01.01', 'title': f'First {sect_name}'}]}] 48 | HIERARCHY_FILE.parent.mkdir(parents=True, exist_ok=True) 49 | HIERARCHY_FILE.write_text(json.dumps(hierarchy, indent=4)) 50 | h, w = self.scr.getmaxyx() 51 | self.scr.clear() 52 | msg = 'Hierarchy auto-generated from content' if has_content else 'Created default hierarchy structure' 53 | TUI.safe_addstr(self.scr, h // 2, (w - len(msg)) // 2, msg, curses.color_pair(1) | curses.A_BOLD) 54 | self.scr.refresh() 55 | curses.napms(1000) 56 | return 'edit' 57 | except: 58 | return None -------------------------------------------------------------------------------- /noteworthy/tui/editors/__init__.py: -------------------------------------------------------------------------------- 1 | import curses 2 | from ..base import TUI 3 | from .config import ConfigEditor 4 | from .hierarchy import HierarchyEditor 5 | from .schemes import SchemeEditor 6 | from .snippets import SnippetsEditor 7 | from .indexignore import IndexignoreEditor 8 | 9 | def show_editor_menu(scr): 10 | options = [ 11 | ("c", "General Settings", "Edit configuration"), 12 | ("h", "Chapter Structure", "Edit document structure"), 13 | ("s", "Color Themes", "Edit color themes"), 14 | ("p", "Code Snippets", "Edit custom snippets"), 15 | ("i", "Ignored Files", "Manage ignored files"), 16 | ] 17 | cur = 0 18 | while True: 19 | h_raw, w_raw = scr.getmaxyx() 20 | h, w = (h_raw - 2, w_raw - 2) 21 | scr.clear() 22 | bw = 50 23 | bh = len(options) * 3 + 2 24 | bx = (w - bw) // 2 25 | by = (h - bh) // 2 26 | TUI.draw_box(scr, by, bx, bh + 2, bw, 'Select Editor') 27 | for i, (key, label, desc) in enumerate(options): 28 | y = by + 2 + i * 3 29 | selected = i == cur 30 | style = curses.color_pair(2) | curses.A_BOLD if selected else curses.color_pair(4) 31 | if selected: 32 | TUI.safe_addstr(scr, y, bx + 2, '▶', curses.color_pair(3) | curses.A_BOLD) 33 | TUI.safe_addstr(scr, y, bx + 4, f'{label}', style) 34 | TUI.safe_addstr(scr, y, bx + bw - 5, f'({key.upper()})', curses.color_pair(4) | curses.A_DIM) 35 | TUI.safe_addstr(scr, y + 1, bx + 6, desc, curses.color_pair(4) | curses.A_DIM) 36 | footer = 'Enter: Select Esc: Back' 37 | TUI.safe_addstr(scr, h - 3, (w - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 38 | scr.refresh() 39 | k = scr.getch() 40 | if k == 27: 41 | return 42 | elif k in (curses.KEY_UP, ord('k')): 43 | cur = max(0, cur - 1) 44 | elif k in (curses.KEY_DOWN, ord('j')): 45 | cur = min(len(options) - 1, cur + 1) 46 | elif k in (ord('\n'), 10): 47 | sel = options[cur][0] 48 | if sel == 'c': 49 | ConfigEditor(scr).run() 50 | elif sel == 'h': 51 | HierarchyEditor(scr).run() 52 | elif sel == 's': 53 | SchemeEditor(scr).run() 54 | elif sel == 'p': 55 | SnippetsEditor(scr).run() 56 | elif sel == 'i': 57 | IndexignoreEditor(scr).run() 58 | elif k == ord('c'): 59 | ConfigEditor(scr).run() 60 | elif k == ord('h'): 61 | HierarchyEditor(scr).run() 62 | elif k == ord('s'): 63 | SchemeEditor(scr).run() 64 | elif k == ord('p'): 65 | SnippetsEditor(scr).run() 66 | elif k == ord('i'): 67 | IndexignoreEditor(scr).run() -------------------------------------------------------------------------------- /templates/setup.typ: -------------------------------------------------------------------------------- 1 | // ===================== 2 | // CONFIGURATION LOADING 3 | // ===================== 4 | 5 | // Load configuration from JSON 6 | #let config = json("config/config.json") 7 | 8 | // Export configuration variables 9 | #let title = config.title 10 | #let subtitle = config.subtitle 11 | #let authors = config.authors 12 | #let affiliation = config.affiliation 13 | #let logo = config.logo 14 | #let show-solution = config.show-solution 15 | #let solutions-text = config.solutions-text 16 | #let problems-text = config.problems-text 17 | #let chapter-name = config.chapter-name 18 | #let subchap-name = config.subchap-name 19 | #let font = config.font 20 | #let title-font = config.title-font 21 | #let display-cover = config.display-cover 22 | #let display-outline = config.display-outline 23 | #let display-chap-cover = config.display-chap-cover 24 | #let box-margin = eval(config.box-margin) 25 | #let box-inset = eval(config.box-inset) 26 | #let render-sample-count = config.render-sample-count 27 | #let render-implicit-count = config.render-implicit-count 28 | #let display-mode = config.display-mode 29 | #let pad-chapter-id = config.pad-chapter-id 30 | #let pad-page-id = config.pad-page-id 31 | #let heading-numbering = config.heading-numbering 32 | #let hierarchy = json("config/hierarchy.json") 33 | 34 | // Load schemes 35 | #import "./default-schemes.typ": * 36 | 37 | #import "./default-schemes.typ": schemes 38 | #let colorschemes = schemes 39 | 40 | #let active-theme = colorschemes.at(lower(display-mode), default: schemes.at("noteworthy-dark")) 41 | 42 | // Import snippets 43 | #import "config/snippets.typ": * 44 | 45 | // ===================== 46 | // HELPER FUNCTIONS 47 | // ===================== 48 | 49 | // Helper: Convert any ID (int or string) to string 50 | #let to-str(id) = if type(id) == int { str(id) } else { id } 51 | 52 | // Helper: Zero-pad a number string to a given width 53 | #let zero-pad(s, width) = { 54 | let s = to-str(s) 55 | let padding = width - s.len() 56 | if padding > 0 { "0" * padding + s } else { s } 57 | } 58 | 59 | // Helper: Calculate required width for a count (1-9 -> 2, 10-99 -> 2, 100-999 -> 3) 60 | #let calc-width(count) = { 61 | if count >= 100 { 3 } else { 2 } // Always at least 2 digits for cleaner look 62 | } 63 | 64 | // Helper: Format chapter ID for display with dynamic padding 65 | #let format-chapter-id(id, total-chapters) = { 66 | if not pad-chapter-id { return to-str(id) } 67 | let width = calc-width(total-chapters) 68 | zero-pad(to-str(id), width) 69 | } 70 | 71 | // Helper: Format page ID for display with dynamic padding 72 | #let format-page-id(id, total-pages-in-chapter, total-chapters) = { 73 | let s = to-str(id) 74 | if not pad-page-id { return s } 75 | 76 | let ch-width = calc-width(total-chapters) 77 | let pg-width = calc-width(total-pages-in-chapter) 78 | 79 | if "." in s { 80 | let parts = s.split(".") 81 | zero-pad(parts.at(0), ch-width) + "." + zero-pad(parts.at(1), pg-width) 82 | } else { 83 | // Single ID like "1" -> "01.01" (chapter.first-page) 84 | zero-pad(s, ch-width) + "." + zero-pad("1", pg-width) 85 | } 86 | } 87 | 88 | // Helper: Extract chapter ID from page ID (supports int or string, with or without dot) 89 | #let get-chapter-id(id) = { 90 | let s = to-str(id) 91 | if "." in s { s.split(".").at(0) } else { s } 92 | } 93 | 94 | -------------------------------------------------------------------------------- /noteworthy/tui/editors/indexignore.py: -------------------------------------------------------------------------------- 1 | import curses 2 | from ..base import ListEditor, TUI 3 | from ...config import INDEXIGNORE_FILE 4 | from ..components.common import LineEditor 5 | from ...utils import load_indexignore, save_indexignore, register_key 6 | from ..keybinds import ConfirmBind, KeyBind 7 | 8 | class IndexignoreEditor(ListEditor): 9 | 10 | def __init__(self, scr): 11 | super().__init__(scr, 'Ignored Files') 12 | self.filepath = INDEXIGNORE_FILE 13 | self.ignored = sorted(list(load_indexignore())) 14 | self._update_items() 15 | self.box_title = 'Ignored Files' 16 | self.box_width = 50 17 | 18 | register_key(self.keymap, ConfirmBind(self.action_enter)) 19 | register_key(self.keymap, KeyBind(ord('n'), self.action_add, "Add Pattern")) 20 | register_key(self.keymap, KeyBind(ord('d'), self.action_delete, "Delete Pattern")) 21 | 22 | def action_enter(self, ctx): 23 | if self.items[self.cursor] == "+ Add new ignore pattern...": 24 | self.action_add(ctx) 25 | else: 26 | curr = self.ignored[self.cursor] 27 | new_val = LineEditor(self.scr, initial_value=curr, title="Edit Pattern").run() 28 | if new_val is not None: 29 | self.ignored[self.cursor] = new_val 30 | self.ignored.sort() 31 | self._update_items() 32 | self.modified = True 33 | 34 | def action_add(self, ctx): 35 | val = LineEditor(self.scr, title='Ignore File ID').run() 36 | if val and val not in self.ignored: 37 | self.ignored.append(val) 38 | self.ignored.sort() 39 | self._update_items() 40 | self.modified = True 41 | 42 | def action_delete(self, ctx): 43 | if self.items and self.items[self.cursor] != "+ Add new ignore pattern...": 44 | if TUI.prompt_confirm(self.scr, "Delete pattern? (y/n): "): 45 | del self.ignored[self.cursor] 46 | choice = self.cursor 47 | self._update_items() 48 | self.modified = True 49 | self.cursor = min(choice, len(self.items) - 1) 50 | 51 | def _update_items(self): 52 | self.items = self.ignored + ["+ Add new ignore pattern..."] 53 | 54 | def save(self): 55 | save_indexignore(set(self.ignored)) 56 | self.modified = False 57 | return True 58 | 59 | def _load(self): 60 | self.ignored = sorted(list(load_indexignore())) 61 | self._update_items() 62 | 63 | def _draw_item(self, y, x, item, width, selected): 64 | if item == "+ Add new ignore pattern...": 65 | TUI.safe_addstr(self.scr, y, x + 4, item, curses.color_pair(3 if selected else 4) | (curses.A_BOLD if selected else curses.A_DIM)) 66 | if selected: TUI.safe_addstr(self.scr, y, x + 2, '>', curses.color_pair(3) | curses.A_BOLD) 67 | return 68 | 69 | if selected: 70 | TUI.safe_addstr(self.scr, y, x + 2, '>', curses.color_pair(3) | curses.A_BOLD) 71 | TUI.safe_addstr(self.scr, y, x + 4, item, curses.color_pair(5 if selected else 4)) 72 | 73 | def _draw_footer(self, h, w): 74 | footer = 'Enter:Edit n:Add d:Del Esc:Save x:Export l:Import' 75 | TUI.safe_addstr(self.scr, h - 3, (w - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 76 | -------------------------------------------------------------------------------- /templates/layouts/outline.typ: -------------------------------------------------------------------------------- 1 | #import "../setup.typ": * 2 | 3 | #let outline( 4 | theme: (:), 5 | ) = { 6 | // Get page map from input if available 7 | let page-map-str = sys.inputs.at("page-map", default: none) 8 | let page-map-file = sys.inputs.at("page-map-file", default: none) 9 | 10 | let page-map = if page-map-str != none { 11 | // Parse JSON using bytes 12 | json(bytes(page-map-str)) 13 | } else if page-map-file != none { 14 | json(page-map-file) 15 | } else { 16 | (:) 17 | } 18 | 19 | page( 20 | paper: "a4", 21 | fill: theme.page-fill, 22 | margin: (x: 2.5cm, y: 2.5cm), 23 | )[ 24 | #metadata("outline") 25 | #line(length: 100%, stroke: 1pt + theme.text-muted) 26 | #v(0.5cm) 27 | 28 | #text( 29 | size: 24pt, 30 | weight: "bold", 31 | tracking: 1pt, 32 | font: font, 33 | fill: theme.text-heading, 34 | )[Table of Contents] 35 | 36 | #v(0.5cm) 37 | #line(length: 100%, stroke: 1pt + theme.text-muted) 38 | #v(1.5cm) 39 | 40 | // Read directly from hierarchy in config.typ 41 | #for (i, chapter-entry) in hierarchy.enumerate() { 42 | // Fixed: use custom number if available 43 | let explicit-num = chapter-entry.at("number", default: none) 44 | let ch-num = if explicit-num != none { str(explicit-num) } else { str(i + 1) } 45 | let chap-id = format-chapter-id(ch-num, hierarchy.len()) 46 | 47 | block(breakable: false)[ 48 | #text( 49 | size: 16pt, 50 | weight: "bold", 51 | font: font, 52 | fill: theme.text-accent, 53 | )[ 54 | #chapter-name #chap-id 55 | ] 56 | #h(1fr) 57 | #text( 58 | size: 16pt, 59 | weight: "regular", 60 | style: "italic", 61 | font: font, 62 | fill: theme.text-main, 63 | )[ 64 | #chapter-entry.title 65 | ] 66 | #v(0.5em) 67 | #line(length: 100%, stroke: 0.5pt + theme.text-muted.transparentize(50%)) 68 | ] 69 | 70 | v(0.5em) 71 | 72 | grid( 73 | columns: (auto, 1fr, auto), 74 | row-gutter: 0.8em, 75 | column-gutter: 1.5em, 76 | 77 | ..for (j, page-entry) in chapter-entry.pages.enumerate() { 78 | // Fixed: use index-based keys for page-map 79 | let page-key = str(i) + "/" + str(j) 80 | let page-num = if page-map != (:) and page-key in page-map { 81 | str(page-map.at(page-key)) 82 | } else { 83 | "—" 84 | } 85 | 86 | // Added: use format-page-id for display 87 | let explicit-pg-num = page-entry.at("number", default: none) 88 | let pg-num-val = if explicit-pg-num != none { str(explicit-pg-num) } else { str(j + 1) } 89 | let full-id = ch-num + "." + pg-num-val 90 | let page-display-id = format-page-id(full-id, chapter-entry.pages.len(), hierarchy.len()) 91 | 92 | ( 93 | text(fill: theme.text-muted, font: font, weight: "medium")[#chapter-name #page-display-id], 94 | box(width: 100%)[ 95 | #text(font: font, fill: theme.text-main)[#page-entry.title] 96 | #box(width: 1fr, repeat[#text(fill: theme.text-muted.transparentize(70%))[. ]]) 97 | ], 98 | text(fill: theme.text-muted, font: font, weight: "medium")[#page-num], 99 | ) 100 | } 101 | ) 102 | 103 | v(1.5cm) 104 | } 105 | ] 106 | } 107 | 108 | -------------------------------------------------------------------------------- /templates/layouts/blocks.typ: -------------------------------------------------------------------------------- 1 | #import "../setup.typ": * 2 | #let theorem-block(label, body, fill-color: white, stroke-color: black) = { 3 | v(box-margin) 4 | block( 5 | fill: fill-color, 6 | inset: box-inset, 7 | radius: 4pt, 8 | width: 100%, 9 | above: 1em, 10 | below: 1em, 11 | )[ 12 | #text(size: 13pt, font: title-font, fill: stroke-color, weight: "bold")[#label] 13 | #v(5pt) 14 | #body 15 | ] 16 | } 17 | 18 | 19 | #let create-block(config, ..args) = { 20 | assert( 21 | 1 <= args.pos().len() and args.pos().len() <= 2, 22 | message: "create-block must be called with (config, name, body) or (config, body)", 23 | ) 24 | [#metadata("block") ] 25 | let title = "" 26 | let body = none 27 | if args.pos().len() == 2 { 28 | title = args.at(0) 29 | body = args.at(1) 30 | } else if args.pos().len() == 1 { 31 | body = args.at(0) 32 | } 33 | let label = [#text(smallcaps(config.title)) | #title] 34 | theorem-block(label, body, fill-color: config.fill, stroke-color: config.stroke) 35 | } 36 | 37 | #let create-solution(config, ..args) = { 38 | assert( 39 | 1 <= args.pos().len() and args.pos().len() <= 3, 40 | message: "solution must be called with (config, name, number, body) or (config, name, body) or (config, body)", 41 | ) 42 | let pos = args.pos() 43 | let number = none 44 | let body = none 45 | let name = "" 46 | 47 | if pos.len() == 1 { 48 | body = pos.at(0) 49 | number = auto 50 | } else if pos.len() == 2 { 51 | name = pos.at(0) 52 | body = pos.at(1) 53 | number = auto 54 | } else if pos.len() == 3 { 55 | name = pos.at(0) 56 | body = pos.at(2) 57 | number = pos.at(1) 58 | } 59 | 60 | [#metadata("solution") ] 61 | 62 | let number-content = if number == auto { 63 | context { 64 | let sol-loc = here() 65 | let start-locs = query(selector().before(sol-loc)) 66 | 67 | if start-locs.len() > 0 { 68 | let start-loc = start-locs.last().location() 69 | let sols = query(selector().after(start-loc).before(sol-loc)) 70 | [#sols.len()] 71 | } else { 72 | let sols = query(selector().before(sol-loc)) 73 | [#sols.len()] 74 | } 75 | } 76 | } else { 77 | number 78 | } 79 | 80 | if show-solution { 81 | let label = [#text(weight: "bold", config.title) #number-content | #name] 82 | theorem-block(label, body, fill-color: config.fill, stroke-color: config.stroke) 83 | } 84 | } 85 | 86 | #let create-proof(config, ..args) = { 87 | assert( 88 | 1 <= args.pos().len() and args.pos().len() <= 2, 89 | message: "create-proof must be called with (config, name, body) or (config, body)", 90 | ) 91 | let name = "" 92 | let body = none 93 | if args.pos().len() == 2 { 94 | name = args.at(0) 95 | body = args.at(1) 96 | } else if args.pos().len() == 1 { 97 | body = args.at(0) 98 | } 99 | let label = [#text(weight: "bold", config.title) | #name] 100 | theorem-block(label, body, fill-color: config.fill, stroke-color: config.stroke) 101 | } 102 | 103 | #let analysis(..args) = { 104 | assert( 105 | 1 <= args.pos().len() and args.pos().len() <= 2, 106 | message: "analysis must be called with (name, body) or (body)", 107 | ) 108 | let name = "" 109 | let body = none 110 | if args.pos().len() == 2 { 111 | name = args.at(0) 112 | body = args.at(1) 113 | } else if args.pos().len() == 1 { 114 | body = args.at(0) 115 | } 116 | create-block(theme.blocks.analysis, name, body) 117 | } 118 | -------------------------------------------------------------------------------- /noteworthy/tui/wizards/schemes.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import json 3 | import shutil 4 | from pathlib import Path 5 | from ..base import TUI 6 | from ...config import SCHEMES_FILE, BASE_DIR 7 | 8 | class SchemesWizard: 9 | 10 | def __init__(self, scr): 11 | self.scr = scr 12 | 13 | def run(self): 14 | h, w = self.scr.getmaxyx() 15 | self.scr.clear() 16 | bw, bh = (min(60, w - 4), 8) 17 | bx, by = ((w - bw) // 2, (h - bh) // 2) 18 | TUI.draw_box(self.scr, by, bx, bh, bw, 'SCHEMES SETUP') 19 | TUI.safe_addstr(self.scr, by + 2, bx + 2, 'Schemes configuration is missing.', curses.color_pair(4)) 20 | TUI.safe_addstr(self.scr, by + 3, bx + 2, 'Restore default color themes?', curses.color_pair(1) | curses.A_BOLD) 21 | TUI.safe_addstr(self.scr, by + 5, bx + 2, 'Press Enter to Restore | Esc to Cancel', curses.color_pair(4) | curses.A_DIM) 22 | self.scr.refresh() 23 | while True: 24 | if not TUI.check_terminal_size(self.scr): 25 | return None 26 | k = self.scr.getch() 27 | if k == 27: 28 | return None 29 | elif k in (ord('\n'), 10, curses.KEY_ENTER): 30 | break 31 | try: 32 | minimal_schemes = { 33 | "noteworthy-dark": { 34 | "page-fill": "#262323", 35 | "text-main": "#d8d0cc", 36 | "text-heading": "#ddbfa1", 37 | "text-muted": "#8f8582", 38 | "text-accent": "#d49c93", 39 | "blocks": { 40 | "definition": { 41 | "fill": "#2f332e", 42 | "stroke": "#9cb095", 43 | "title": "Definition" 44 | }, 45 | "equation": { 46 | "fill": "#33302a", 47 | "stroke": "#d1c29b", 48 | "title": "Equation" 49 | }, 50 | "example": { 51 | "fill": "#332b28", 52 | "stroke": "#d4aa8e", 53 | "title": "Example" 54 | }, 55 | "solution": { 56 | "fill": "#2e282d", 57 | "stroke": "#bba3b8", 58 | "title": "Solution" 59 | }, 60 | "proof": { 61 | "fill": "#302626", 62 | "stroke": "#c48378", 63 | "title": "Proof" 64 | }, 65 | "note": { 66 | "fill": "#332729", 67 | "stroke": "#d6999e", 68 | "title": "Note" 69 | }, 70 | "notation": { 71 | "fill": "#262e2e", 72 | "stroke": "#8caeb0", 73 | "title": "Notation" 74 | }, 75 | "analysis": { 76 | "fill": "#262a30", 77 | "stroke": "#8ea4b8", 78 | "title": "Analysis" 79 | }, 80 | "theorem": { 81 | "fill": "#333028", 82 | "stroke": "#e0cda6", 83 | "title": "Theorem" 84 | } 85 | }, 86 | "plot": { 87 | "stroke": "#ddbfa1", 88 | "highlight": "#d4aa8e", 89 | "grid-opacity": 0.15 90 | } 91 | } 92 | } 93 | default_src = BASE_DIR / 'templates/config/schemes.json' 94 | if default_src.exists() and default_src != SCHEMES_FILE: 95 | shutil.copy(default_src, SCHEMES_FILE) 96 | else: 97 | SCHEMES_FILE.parent.mkdir(parents=True, exist_ok=True) 98 | SCHEMES_FILE.write_text(json.dumps(minimal_schemes, indent=4)) 99 | return True 100 | except: 101 | return None -------------------------------------------------------------------------------- /templates/plots/grapher.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/cetz:0.4.2" 2 | #import "@preview/cetz-plot:0.1.3": plot 3 | #import "../setup.typ": render-implicit-count, render-sample-count 4 | 5 | /// Plots a mathematical function in various coordinate systems. 6 | /// Supports standard functions, parametric equations, polar curves, and implicit equations. 7 | /// 8 | /// Parameters: 9 | /// - theme: Theme dictionary containing plot styling 10 | /// - func: Function to plot (signature depends on type) 11 | /// - type: Function type - "y=x" (standard), "parametric", "polar", or "implicit" (default: "y=x") 12 | /// - domain: Input domain - (x-min, x-max) or (t-min, t-max) depending on type (default: auto) 13 | /// - y-domain: Y range for implicit plots only (default: (-5, 5)) 14 | /// - samples: Number of sample points for rendering (default: from config) 15 | /// - label: Optional curve label for legend 16 | /// - style: Additional CeTZ plot style dictionary 17 | /// 18 | /// Function Types: 19 | /// - "y=x": Standard function f(x) → y 20 | /// - "parametric": Parametric function t → (x(t), y(t)) 21 | /// - "polar": Polar function θ → r(θ) 22 | /// - "implicit": Implicit function (x, y) → z for z = 0 contour 23 | #let plot-function( 24 | theme: (:), 25 | func, 26 | type: "y=x", 27 | domain: auto, 28 | y-domain: (-5, 5), 29 | samples: render-sample-count, 30 | label: none, 31 | style: (:), 32 | ) = { 33 | let highlight-col = if "plot" in theme and "highlight" in theme.plot { 34 | theme.plot.highlight 35 | } else { 36 | black 37 | } 38 | 39 | let base-color = if "stroke" in style { style.stroke } else { highlight-col } 40 | let final-style = (stroke: base-color) + style 41 | 42 | let common-args = ( 43 | samples: samples, 44 | style: final-style, 45 | ) 46 | 47 | if label != none { 48 | // Wrap label in theme's text color 49 | let colored-label = text(fill: theme.plot.stroke, label) 50 | common-args.insert("label", colored-label) 51 | } 52 | 53 | if type == "y=x" { 54 | // Standard Function: y = f(x) 55 | let x-dom = if domain == auto { (-5, 5) } else { domain } 56 | plot.add( 57 | domain: x-dom, 58 | ..common-args, 59 | func, 60 | ) 61 | } else if type == "parametric" { 62 | // Parametric Curve: (x(t), y(t)) 63 | let t-dom = if domain == auto { (0, 2 * calc.pi) } else { domain } 64 | plot.add( 65 | domain: t-dom, 66 | ..common-args, 67 | func, 68 | ) 69 | } else if type == "polar" { 70 | // Polar Curve: r(θ) 71 | let t-dom = if domain == auto { (0, 2 * calc.pi) } else { domain } 72 | plot.add( 73 | domain: t-dom, 74 | ..common-args, 75 | t => (func(t) * calc.cos(t), func(t) * calc.sin(t)), 76 | ) 77 | } else if type == "implicit" { 78 | // Implicit Equation: f(x, y) = 0 79 | let x-dom = if domain == auto { (-5, 5) } else { domain } 80 | 81 | // Use implicit count if samples is still default 82 | let effective-samples = if samples == render-sample-count { render-implicit-count } else { samples } 83 | 84 | plot.add-contour( 85 | x-domain: x-dom, 86 | y-domain: y-domain, 87 | x-samples: effective-samples, 88 | y-samples: effective-samples, 89 | z: 0, 90 | fill: false, 91 | style: final-style, 92 | func, 93 | ) 94 | 95 | if label != none { 96 | plot.annotate({ 97 | import cetz.draw: * 98 | content( 99 | (x-dom.at(1), y-domain.at(1)), 100 | text(fill: theme.plot.stroke, label), 101 | anchor: "south-east", 102 | padding: 0.2, 103 | fill: none, 104 | stroke: none, 105 | ) 106 | }) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /templates/templater.typ: -------------------------------------------------------------------------------- 1 | #import "setup.typ": * 2 | #import "./default-schemes.typ": * 3 | 4 | #import "./layouts/blocks.typ" 5 | #import "./plots/geoplot.typ" 6 | #import "./plots/combiplot.typ" 7 | #import "./plots/vectorplot.typ" 8 | #import "./plots/grapher.typ" 9 | #import "./plots/plots.typ" 10 | #import "./plots/spaceplot.typ" 11 | #import "./plots/tableplot.typ" 12 | 13 | #import "@preview/cetz:0.4.2" 14 | #import "@preview/cetz:0.4.2": * 15 | #import "@preview/cetz-plot:0.1.3": plot 16 | #let add-graph(..args) = { 17 | let kwargs = args.named() 18 | if "samples" not in kwargs { 19 | kwargs.insert("samples", render-sample-count) 20 | } 21 | plot.add(..args.pos(), ..kwargs) 22 | } 23 | 24 | // Re-export cetz module for content files 25 | #import "@preview/cetz:0.4.2" as cetz-mod 26 | #let cetz = cetz-mod 27 | 28 | #import "./layouts/blocks.typ": * 29 | #import "./covers/chapter-cover.typ": * 30 | #import "./covers/main-cover.typ": * 31 | #import "./covers/preface.typ": * 32 | #import "./layouts/outline.typ": * 33 | #import "./covers/page-title.typ": * 34 | 35 | 36 | #let active-theme = active-theme 37 | #let project = project.with(theme: active-theme) 38 | #let cover = cover.with(theme: active-theme) 39 | #let preface = preface.with(theme: active-theme, content: include "config/preface.typ", authors: authors) 40 | #let outline = outline.with(theme: active-theme) 41 | #let chapter-cover = chapter-cover.with(theme: active-theme) 42 | #let definition = blocks.create-block.with(active-theme.blocks.definition) 43 | #let equation = blocks.create-block.with(active-theme.blocks.equation) 44 | #let example = blocks.create-block.with(active-theme.blocks.example) 45 | #let note = blocks.create-block.with(active-theme.blocks.note) 46 | #let notation = blocks.create-block.with(active-theme.blocks.notation) 47 | #let analysis = blocks.create-block.with(active-theme.blocks.analysis) 48 | #let solution = blocks.create-solution.with(active-theme.blocks.solution) 49 | #let proof = blocks.create-proof.with(active-theme.blocks.proof) 50 | #let theorem = blocks.create-block.with(active-theme.blocks.theorem) 51 | 52 | 53 | #let rect-plot(..args) = { align(center)[#plots.rect-plot(..args, theme: active-theme)] } 54 | #let polar-plot(..args) = { align(center)[#plots.polar-plot(..args, theme: active-theme)] } 55 | #let combi-plot(..args) = { align(center)[#plots.combi-plot(..args, theme: active-theme)] } 56 | #let blank-plot(..args) = { align(center)[#plots.blank-plot(..args, theme: active-theme)] } 57 | #let space-plot(..args) = { align(center)[#spaceplot.space-plot(..args, theme: active-theme)] } 58 | 59 | 60 | #let point = geoplot.point.with(theme: active-theme) 61 | #let add-polar = geoplot.add-polar 62 | #let add-angle = geoplot.add-angle.with(theme: active-theme) 63 | #let add-right-angle = geoplot.add-right-angle.with(theme: active-theme) 64 | #let add-xy-axes = geoplot.add-xy-axes 65 | #let add-polygon = geoplot.add-polygon.with(theme: active-theme) 66 | 67 | 68 | #let draw-boxes = combiplot.draw-boxes.with(theme: active-theme) 69 | #let draw-linear = combiplot.draw-linear.with(theme: active-theme) 70 | #let draw-circular = combiplot.draw-circular.with(theme: active-theme) 71 | 72 | 73 | 74 | #let draw-vec = vectorplot.draw-vec.with(theme: active-theme) 75 | #let draw-vec-comps = vectorplot.draw-vec-comps.with(theme: active-theme) 76 | #let draw-vec-sum = vectorplot.draw-vec-sum.with(theme: active-theme) 77 | #let draw-vec-proj = vectorplot.draw-vec-proj.with(theme: active-theme) 78 | 79 | #let plot-function = grapher.plot-function.with(theme: active-theme) 80 | 81 | // Table plotting functions 82 | #let table-plot = tableplot.table-plot.with(theme: active-theme) 83 | #let compact-table = tableplot.compact-table.with(theme: active-theme) 84 | #let value-table = tableplot.value-table.with(theme: active-theme) 85 | #let grid-table = tableplot.grid-table.with(theme: active-theme) 86 | -------------------------------------------------------------------------------- /templates/plots/tableplot.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/cetz:0.4.2" 2 | 3 | // Beautiful themed table renderer 4 | #let table-plot( 5 | theme: (:), 6 | headers: (), 7 | data: (), 8 | align-cols: auto, 9 | width: 100%, 10 | header-fill: auto, 11 | row-fill: auto, 12 | stroke-color: auto, 13 | ) = { 14 | // Use theme colors or defaults 15 | let actual-header-fill = if header-fill == auto { 16 | theme.blocks.theorem.fill 17 | } else { 18 | header-fill 19 | } 20 | 21 | let actual-stroke = if stroke-color == auto { 22 | theme.blocks.theorem.stroke.transparentize(50%) 23 | } else { 24 | stroke-color 25 | } 26 | 27 | let actual-row-fill = if row-fill == auto { 28 | (theme.page-fill, theme.blocks.definition.fill.transparentize(50%)) 29 | } else { 30 | row-fill 31 | } 32 | 33 | // Set up alignment 34 | let num-cols = headers.len() 35 | let alignments = if align-cols == auto { 36 | (center,) * num-cols 37 | } else { 38 | align-cols 39 | } 40 | 41 | // Build the table 42 | table( 43 | columns: (auto,) * num-cols, 44 | align: (col, row) => { 45 | if row == 0 { center } else { alignments.at(calc.min(col, alignments.len() - 1)) } 46 | }, 47 | fill: (col, row) => { 48 | if row == 0 { 49 | actual-header-fill 50 | } else { 51 | actual-row-fill.at(calc.rem(row - 1, actual-row-fill.len())) 52 | } 53 | }, 54 | stroke: (x, y) => { 55 | // Thicker stroke for header bottom 56 | if y == 0 { 57 | (bottom: 2pt + actual-stroke, rest: 1pt + actual-stroke) 58 | } else if y == 1 { 59 | (top: 2pt + actual-stroke, rest: 1pt + actual-stroke) 60 | } else { 61 | 1pt + actual-stroke 62 | } 63 | }, 64 | inset: 10pt, 65 | 66 | // Header row 67 | ..headers.map(h => text( 68 | fill: theme.text-heading, 69 | weight: "bold", 70 | size: 11pt, 71 | font: theme.at("title-font", default: "IBM Plex Serif"), 72 | )[#h]), 73 | 74 | // Data rows 75 | ..data 76 | .flatten() 77 | .map(cell => text( 78 | fill: theme.text-main, 79 | size: 10pt, 80 | )[#cell]), 81 | ) 82 | } 83 | 84 | // Compact table version for smaller datasets 85 | #let compact-table( 86 | theme: (:), 87 | headers: (), 88 | data: (), 89 | align-cols: auto, 90 | ) = { 91 | table-plot( 92 | theme: theme, 93 | headers: headers, 94 | data: data, 95 | align-cols: align-cols, 96 | width: auto, 97 | ) 98 | } 99 | 100 | // Value comparison table (for showing function values, etc.) 101 | #let value-table( 102 | theme: (:), 103 | variable: $x$, 104 | values: (), 105 | func: $f(x)$, 106 | results: (), 107 | ) = { 108 | table-plot( 109 | theme: theme, 110 | headers: (variable, func), 111 | data: values.zip(results), 112 | align-cols: (center, center), 113 | ) 114 | } 115 | 116 | // Grid-style table for matrices or coordinate data 117 | #let grid-table( 118 | theme: (:), 119 | data: (), 120 | show-indices: false, 121 | ) = { 122 | let num-cols = if data.len() > 0 { data.at(0).len() } else { 0 } 123 | 124 | if show-indices { 125 | let headers = range(num-cols).map(i => str(i)) 126 | table-plot( 127 | theme: theme, 128 | headers: headers, 129 | data: data, 130 | align-cols: (center,) * num-cols, 131 | ) 132 | } else { 133 | // No headers - render data directly as a simple table 134 | let actual-stroke = theme.blocks.theorem.stroke.transparentize(50%) 135 | let actual-row-fill = (theme.page-fill, theme.blocks.definition.fill.transparentize(50%)) 136 | 137 | table( 138 | columns: (auto,) * num-cols, 139 | align: center, 140 | fill: (col, row) => actual-row-fill.at(calc.rem(row, actual-row-fill.len())), 141 | stroke: 1pt + actual-stroke, 142 | inset: 10pt, 143 | ..data 144 | .flatten() 145 | .map(cell => text( 146 | fill: theme.text-main, 147 | size: 10pt, 148 | )[#cell]), 149 | ) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /templates/plots/spaceplot.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/cetz:0.4.2" 2 | 3 | /// Draws a 3D coordinate system with axes, grid, and optional tick marks. 4 | /// This function creates a complete 3D plotting environment. 5 | /// 6 | /// Parameters: 7 | /// - theme: Theme dictionary containing plot styling 8 | /// - x-domain: Range for X axis as (min, max) (default: (0, 5)) 9 | /// - y-domain: Range for Y axis as (min, max) (default: (0, 5)) 10 | /// - z-domain: Range for Z axis as (min, max) (default: (0, 4)) 11 | /// - view: Camera rotation angles as (x:, y:, z:) dictionary (default: standard isometric view) 12 | /// - step: Grid line spacing (default: 1) 13 | /// - x-label: Label for X axis (default: $x$) 14 | /// - y-label: Label for Y axis (default: $y$) 15 | /// - z-label: Label for Z axis (default: $z$) 16 | /// - draw-axes: Whether to draw coordinate axes (default: true) 17 | /// - draw-grid: Whether to draw XY grid at z=0 (default: true) 18 | /// - draw-ticks: Whether to draw tick marks on axes (default: false) 19 | /// - body: Content to draw within the 3D coordinate system 20 | #let space-plot( 21 | theme: (:), 22 | x-domain: (0, 5), 23 | y-domain: (0, 5), 24 | z-domain: (0, 4), 25 | // Default view: Z pointing up, X left, Y right 26 | view: (x: -90deg, y: -120deg, z: 0deg), 27 | step: 1, 28 | x-label: $x$, 29 | y-label: $y$, 30 | z-label: $z$, 31 | draw-axes: true, 32 | draw-grid: true, 33 | draw-ticks: false, 34 | body, 35 | ) = { 36 | import cetz.draw: * 37 | 38 | // Define styles from theme 39 | let axis-color = theme.at("text-main", default: black) 40 | let grid-color = theme.at("plot", default: (:)).at("grid", default: gray) 41 | 42 | let axis-style = (paint: axis-color, thickness: 1pt) 43 | let grid-style = (paint: grid-color, thickness: 0.5pt) 44 | let tick-style = (paint: axis-color, thickness: 1pt) 45 | 46 | cetz.canvas({ 47 | // Apply rotation to achieve desired 3D view 48 | rotate(x: view.at("x", default: 0deg), y: view.at("y", default: 0deg), z: view.at("z", default: 0deg)) 49 | 50 | let (x-min, x-max) = x-domain 51 | let (y-min, y-max) = y-domain 52 | let (z-min, z-max) = z-domain 53 | 54 | // --- DRAW GRID --- 55 | if draw-grid { 56 | for i in range(int(x-min / step), int(x-max / step) + 1) { 57 | let x = i * step 58 | line((x, y-min, 0), (x, y-max, 0), stroke: grid-style) 59 | } 60 | for i in range(int(y-min / step), int(y-max / step) + 1) { 61 | let y = i * step 62 | line((x-min, y, 0), (x-max, y, 0), stroke: grid-style) 63 | } 64 | } 65 | 66 | // --- DRAW AXES --- 67 | if draw-axes { 68 | // Z-Axis (vertical) 69 | line((0, 0, 0), (0, 0, z-max + 1), stroke: axis-style, name: "z-axis") 70 | content((0, 0, z-max + 1.2), z-label) 71 | 72 | // X-Axis 73 | line((0, 0, 0), (x-max + 1, 0, 0), stroke: axis-style, name: "x-axis") 74 | content((x-max + 1.2, 0, 0), x-label) 75 | 76 | // Y-Axis 77 | line((0, 0, 0), (0, y-max + 1, 0), stroke: axis-style, name: "y-axis") 78 | content((0, y-max + 1.2, 0), y-label) 79 | 80 | // --- DRAW TICKS --- 81 | if draw-ticks { 82 | let tick-len = 0.2 83 | // X-ticks 84 | for i in range(int(x-min / step), int(x-max / step) + 1) { 85 | if i != 0 { 86 | let x = i * step 87 | line((x, 0, -tick-len), (x, 0, tick-len), stroke: tick-style) 88 | } 89 | } 90 | // Y-ticks 91 | for i in range(int(y-min / step), int(y-max / step) + 1) { 92 | if i != 0 { 93 | let y = i * step 94 | line((0, y, -tick-len), (0, y, tick-len), stroke: tick-style) 95 | } 96 | } 97 | // Z-ticks 98 | for i in range(int(z-min / step), int(z-max / step) + 1) { 99 | if i != 0 { 100 | let z = i * step 101 | line((-tick-len, 0, z), (tick-len, 0, z), stroke: tick-style) 102 | } 103 | } 104 | } 105 | } 106 | 107 | // Draw the user content 108 | body 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /noteworthy/core/templates.py: -------------------------------------------------------------------------------- 1 | import urllib.request 2 | import urllib.parse 3 | import json 4 | import curses 5 | import shutil 6 | from pathlib import Path 7 | from ..config import SCHEMES_FILE 8 | from ..tui.base import TUI 9 | 10 | def restore_templates(scr): 11 | EXCLUDE_FILES = { 12 | 'templates/config/config.json', 13 | 'templates/config/hierarchy.json', 14 | 'templates/config/snippets.typ', 15 | } 16 | try: 17 | api_url = 'https://api.github.com/repos/sihooleebd/noteworthy/git/trees/master?recursive=1' 18 | req = urllib.request.Request(api_url, headers={'User-Agent': 'Noteworthy-Builder'}) 19 | with urllib.request.urlopen(req, timeout=5) as response: 20 | data = json.loads(response.read().decode()) 21 | if 'tree' not in data: 22 | return 23 | # Helper to fetch file content 24 | def fetch_content(path): 25 | safe_path = urllib.parse.quote(path) 26 | url = f'https://raw.githubusercontent.com/sihooleebd/noteworthy/master/{safe_path}' 27 | with urllib.request.urlopen(url, timeout=10) as response: 28 | return response.read() 29 | 30 | missing_files = [] 31 | for item in data['tree']: 32 | if item['type'] == 'blob' and item['path'].startswith('templates/'): 33 | path_str = item['path'] 34 | 35 | # Special handling for schemes.json (Smart Merge) 36 | if path_str == 'templates/config/schemes.json': 37 | local_path = Path(path_str) 38 | try: 39 | remote_json = json.loads(fetch_content(path_str)) 40 | if local_path.exists(): 41 | try: 42 | local_json = json.loads(local_path.read_text()) 43 | except: 44 | local_json = {} 45 | else: 46 | local_json = {} 47 | 48 | # Merge: Add missing defaults, preserve local changes/customs 49 | modified = False 50 | for name, scheme in remote_json.items(): 51 | if name not in local_json: 52 | local_json[name] = scheme 53 | modified = True 54 | 55 | if modified or not local_path.exists(): 56 | local_path.parent.mkdir(parents=True, exist_ok=True) 57 | local_path.write_text(json.dumps(local_json, indent=4)) 58 | except: 59 | pass 60 | continue 61 | 62 | if path_str in EXCLUDE_FILES: 63 | continue 64 | 65 | if path_str == 'templates/config/preface.typ': 66 | local_path = Path(path_str) 67 | if not local_path.exists(): 68 | local_path.parent.mkdir(parents=True, exist_ok=True) 69 | local_path.write_text('') 70 | continue 71 | 72 | local_path = Path(path_str) 73 | if not local_path.exists(): 74 | missing_files.append(path_str) 75 | 76 | if not missing_files: 77 | return 78 | 79 | scr.clear() 80 | h, w = scr.getmaxyx() 81 | msg = f'Restoring {len(missing_files)} missing templates...' 82 | TUI.safe_addstr(scr, h // 2 + 2, (w - len(msg)) // 2, msg, curses.color_pair(4)) 83 | scr.refresh() 84 | 85 | for fpath in missing_files: 86 | try: 87 | content = fetch_content(fpath) 88 | local_path = Path(fpath) 89 | local_path.parent.mkdir(parents=True, exist_ok=True) 90 | with open(local_path, 'wb') as f: 91 | f.write(content) 92 | except: 93 | pass 94 | 95 | msg = 'Restoration complete!' 96 | TUI.safe_addstr(scr, h // 2 + 3, (w - len(msg)) // 2, msg, curses.color_pair(2)) 97 | scr.refresh() 98 | curses.napms(1000) 99 | except Exception: 100 | pass -------------------------------------------------------------------------------- /noteworthy/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shutil 3 | import sys 4 | import subprocess 5 | from pathlib import Path 6 | from .config import CONFIG_FILE, SETTINGS_FILE, SYSTEM_CONFIG_DIR, INDEXIGNORE_FILE, HIERARCHY_FILE, BASE_DIR 7 | 8 | def load_config_safe(): 9 | try: 10 | if CONFIG_FILE.exists(): 11 | return json.loads(CONFIG_FILE.read_text()) 12 | except: 13 | pass 14 | return {} 15 | 16 | def save_config(config): 17 | try: 18 | CONFIG_FILE.write_text(json.dumps(config, indent=4)) 19 | return True 20 | except: 21 | return False 22 | 23 | def load_settings(): 24 | try: 25 | SYSTEM_CONFIG_DIR.mkdir(parents=True, exist_ok=True) 26 | if SETTINGS_FILE.exists(): 27 | return json.loads(SETTINGS_FILE.read_text()) 28 | except: 29 | pass 30 | return {} 31 | 32 | def save_settings(settings): 33 | try: 34 | SYSTEM_CONFIG_DIR.mkdir(parents=True, exist_ok=True) 35 | SETTINGS_FILE.write_text(json.dumps(settings, indent=2)) 36 | except: 37 | pass 38 | 39 | def load_indexignore(): 40 | try: 41 | if INDEXIGNORE_FILE.exists(): 42 | lines = INDEXIGNORE_FILE.read_text().strip().split('\n') 43 | return {l.strip() for l in lines if l.strip() and (not l.startswith('#'))} 44 | except: 45 | pass 46 | return set() 47 | 48 | def register_key(keymap, bind): 49 | if isinstance(bind.keys, list): 50 | for k in bind.keys: 51 | keymap[k] = bind 52 | else: 53 | keymap[bind.keys] = bind 54 | 55 | def handle_key_event(key_code, keymap, context=None): 56 | if key_code in keymap: 57 | bind = keymap[key_code] 58 | res = bind(context) 59 | return True, res 60 | return False, None 61 | 62 | def save_indexignore(ignored_set): 63 | try: 64 | SYSTEM_CONFIG_DIR.mkdir(parents=True, exist_ok=True) 65 | content = '# Files to ignore during hierarchy sync\n# One file ID per line (e.g., 01.03)\n\n' 66 | content += '\n'.join(sorted(ignored_set)) 67 | INDEXIGNORE_FILE.write_text(content) 68 | except: 69 | pass 70 | 71 | def check_dependencies(): 72 | if not shutil.which('typst'): 73 | print("Error: 'typst' not found. Install from https://typst.app") 74 | sys.exit(1) 75 | if not shutil.which('pdfinfo'): 76 | print("Error: 'pdfinfo' not found. Install with: brew install poppler") 77 | sys.exit(1) 78 | if not (shutil.which('pdfunite') or shutil.which('gs')): 79 | print("Error: Neither 'pdfunite' nor 'gs' (ghostscript) found. Install poppler-utils or ghostscript.") 80 | sys.exit(1) 81 | 82 | def get_formatted_name(path_str, hierarchy, config=None): 83 | if config is None: 84 | config = load_config_safe() 85 | path = Path(path_str) 86 | if not path.stem.isdigit() or not path.parent.name.isdigit(): 87 | return path.name 88 | ci = int(path.parent.name) 89 | pi = int(path.stem) 90 | total_chapters = len(hierarchy) 91 | total_pages = 0 92 | if ci < len(hierarchy): 93 | total_pages = len(hierarchy[ci].get('pages', [])) 94 | ch_width = len(str(total_chapters)) 95 | pg_width = len(str(total_pages)) if total_pages > 0 else 2 96 | 97 | def get_num(idx, item): 98 | return str(item.get('number', idx + 1)) 99 | ch_item = hierarchy[ci] if ci < len(hierarchy) else {} 100 | ch_num_str = get_num(ci, ch_item) 101 | pg_item = {} 102 | if ci < len(hierarchy) and pi < len(hierarchy[ci].get('pages', [])): 103 | pg_item = hierarchy[ci]['pages'][pi] 104 | pg_num_str = get_num(pi, pg_item) 105 | ch_disp = ch_num_str.zfill(ch_width) if ch_num_str.isdigit() else ch_num_str 106 | pg_disp = pg_num_str.zfill(pg_width) if pg_num_str.isdigit() else pg_num_str 107 | label = config.get('subchap-name', 'Section') 108 | return f'{label} {ch_disp}.{pg_disp}' 109 | 110 | def extract_hierarchy(): 111 | temp_file = Path('extract_hierarchy.typ') 112 | temp_file.write_text('#import "templates/setup.typ": hierarchy\n#metadata(hierarchy) ') 113 | try: 114 | result = subprocess.run(['typst', 'query', str(temp_file), ''], capture_output=True, text=True, check=True) 115 | return json.loads(result.stdout)[0]['value'] 116 | except subprocess.CalledProcessError as e: 117 | print(f'Error extracting hierarchy: {e.stderr}') 118 | sys.exit(1) 119 | finally: 120 | temp_file.unlink(missing_ok=True) -------------------------------------------------------------------------------- /templates/plots/combiplot.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/cetz:0.4.2" 2 | 3 | /// Visualizes combinatorics problems using boxes and balls. 4 | /// Displays a row of boxes with filled circles representing items. 5 | /// 6 | /// Parameters: 7 | /// - theme: Theme dictionary containing plot styling 8 | /// - n-boxes: Number of boxes to draw 9 | /// - counts: Array of ball counts for each box 10 | /// - x: X-coordinate offset (default: 0) 11 | /// - y: Y-coordinate offset (default: 0) 12 | /// - label: Optional label to display below the boxes 13 | #let draw-boxes(theme: (:), n-boxes, counts, x: 0, y: 0, label: none) = { 14 | import cetz.draw: * 15 | let stroke-col = theme.plot.stroke 16 | let fill-col = theme.plot.highlight 17 | 18 | let box-width = 1.2 19 | let box-height = 2.5 20 | let spacing = 0.5 21 | let ball-radius = 0.25 22 | let start-x = float(x) 23 | let start-y = float(y) 24 | 25 | for i in range(n-boxes) { 26 | let current-x = start-x + i * (box-width + spacing) 27 | let n-balls = counts.at(i, default: 0) 28 | 29 | line( 30 | (current-x, start-y + box-height), 31 | (current-x, start-y), 32 | (current-x + box-width, start-y), 33 | (current-x + box-width, start-y + box-height), 34 | stroke: (paint: stroke-col, thickness: 1.5pt), 35 | ) 36 | 37 | for b in range(n-balls) { 38 | let ball-cx = current-x + (box-width / 2) 39 | let ball-cy = start-y + ball-radius + (b * ball-radius * 2.1) 40 | 41 | circle( 42 | (ball-cx, ball-cy), 43 | radius: ball-radius * 0.85, 44 | fill: fill-col, 45 | stroke: none, 46 | ) 47 | } 48 | } 49 | 50 | if label != none { 51 | let total-width = (n-boxes * box-width) + ((n-boxes - 1) * spacing) 52 | let center-x = start-x + (total-width / 2) 53 | content((center-x, start-y - 0.5), text(fill: stroke-col, label)) 54 | } 55 | } 56 | 57 | 58 | /// Draws a linear arrangement of items (e.g., for permutations). 59 | /// Items are displayed in a horizontal row of circles. 60 | /// 61 | /// Parameters: 62 | /// - theme: Theme dictionary containing plot styling 63 | /// - items: Array of items to display (can be numbers, letters, etc.) 64 | /// - x: X-coordinate offset (default: 0) 65 | /// - y: Y-coordinate offset (default: 0) 66 | /// - label: Optional label to display below the arrangement 67 | #let draw-linear(theme: (:), items, x: 0, y: 0, label: none) = { 68 | import cetz.draw: * 69 | 70 | let stroke-col = theme.text-accent 71 | let text-col = theme.text-main 72 | 73 | let ball-radius = 0.4 74 | let spacing = 0.4 75 | let start-x = float(x) 76 | let start-y = float(y) 77 | 78 | for (i, item) in items.enumerate() { 79 | let current-x = start-x + i * (ball-radius * 2 + spacing) 80 | 81 | circle( 82 | (current-x, start-y), 83 | radius: ball-radius, 84 | stroke: (paint: stroke-col, thickness: 1.5pt), 85 | fill: none, 86 | ) 87 | 88 | content( 89 | (current-x, start-y), 90 | text(fill: text-col, weight: "bold")[#item], 91 | ) 92 | } 93 | 94 | if label != none { 95 | let total-width = (items.len() * ball-radius * 2) + ((items.len() - 1) * spacing) 96 | let center-x = start-x + (total-width / 2) - ball-radius 97 | content((center-x, start-y - 1), text(fill: stroke-col, label)) 98 | } 99 | } 100 | 101 | 102 | /// Draws a circular arrangement of items (e.g., for circular permutations). 103 | /// Items are displayed around a circle. 104 | /// 105 | /// Parameters: 106 | /// - theme: Theme dictionary containing plot styling 107 | /// - items: Array of items to display 108 | /// - x: X-coordinate of circle center (default: 0) 109 | /// - y: Y-coordinate of circle center (default: 0) 110 | /// - radius: Radius of the arrangement circle (default: 1.5) 111 | /// - label: Optional label to display at center 112 | #let draw-circular(theme: (:), items, x: 0, y: 0, radius: 1.5, label: none) = { 113 | import cetz.draw: * 114 | 115 | let fill-col = theme.plot.highlight 116 | let grid-col = theme.plot.stroke 117 | 118 | let n = items.len() 119 | let ball-radius = 0.4 120 | let start-x = float(x) 121 | let start-y = float(y) 122 | 123 | // Draw dashed circle 124 | circle( 125 | (start-x, start-y), 126 | radius: radius, 127 | stroke: (paint: grid-col, dash: "dashed", thickness: 0.5pt), 128 | ) 129 | 130 | for (i, item) in items.enumerate() { 131 | // Start at top (90°) and go clockwise 132 | let angle = 90deg - (i * 360deg / n) 133 | 134 | let cx = start-x + radius * calc.cos(angle) 135 | let cy = start-y + radius * calc.sin(angle) 136 | 137 | circle( 138 | (cx, cy), 139 | radius: ball-radius, 140 | fill: fill-col, 141 | stroke: none, 142 | ) 143 | 144 | content( 145 | (cx, cy), 146 | text(fill: theme.text-main, weight: "bold")[#item], 147 | ) 148 | } 149 | 150 | if label != none { 151 | content( 152 | (start-x, start-y), 153 | text(fill: theme.text-accent, weight: "bold", label), 154 | ) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /noteworthy/tui/wizards/sync.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import json 3 | from pathlib import Path 4 | from ..base import TUI 5 | from ...config import HIERARCHY_FILE, CONFIG_FILE 6 | from ...utils import load_config_safe, get_formatted_name 7 | 8 | class SyncWizard: 9 | 10 | def __init__(self, scr, missing_files, new_files): 11 | self.scr = scr 12 | self.missing_files = missing_files 13 | self.new_files = new_files 14 | self.config = load_config_safe() 15 | try: 16 | self.hierarchy = json.loads(HIERARCHY_FILE.read_text()) 17 | except: 18 | self.hierarchy = [] 19 | 20 | def refresh(self): 21 | h, w = self.scr.getmaxyx() 22 | self.scr.clear() 23 | TUI.safe_addstr(self.scr, 2, (w - 25) // 2, 'HIERARCHY SYNC REQUIRED', curses.color_pair(6) | curses.A_BOLD) 24 | TUI.safe_addstr(self.scr, 3, (w - 45) // 2, 'The hierarchy.json does not match your content folder.', curses.color_pair(4)) 25 | col_w = (w - 8) // 2 26 | left_x = 2 27 | right_x = left_x + col_w + 4 28 | list_h = h - 13 29 | TUI.draw_box(self.scr, 5, left_x, list_h + 2, col_w, f' Missing on Disk ({len(self.missing_files)}) ') 30 | for i, f in enumerate(self.missing_files[:list_h]): 31 | name = get_formatted_name(f, self.hierarchy, self.config) 32 | TUI.safe_addstr(self.scr, 6 + i, left_x + 2, f'- {name} ({f})', curses.color_pair(4)) 33 | TUI.draw_box(self.scr, 5, right_x, list_h + 2, col_w, f' New on Disk ({len(self.new_files)}) ') 34 | for i, f in enumerate(self.new_files[:list_h]): 35 | name = get_formatted_name(f, self.hierarchy, self.config) 36 | TUI.safe_addstr(self.scr, 6 + i, right_x + 2, f'+ {name} ({f})', curses.color_pair(2)) 37 | opts_y = h - 5 38 | TUI.safe_addstr(self.scr, opts_y, 4, '[A] Adopt Disk State (Update Hierarchy)', curses.color_pair(1) | curses.A_BOLD) 39 | TUI.safe_addstr(self.scr, opts_y + 1, 8, 'Removes missing, Adds new', curses.color_pair(4)) 40 | TUI.safe_addstr(self.scr, opts_y, w // 2 + 4, '[B] Create Missing Files', curses.color_pair(1) | curses.A_BOLD) 41 | TUI.safe_addstr(self.scr, opts_y + 1, w // 2 + 8, 'Creates scaffold for missing files', curses.color_pair(4)) 42 | TUI.safe_addstr(self.scr, opts_y + 3, 4, '[D] Delete Extra Files', curses.color_pair(1) | curses.A_BOLD) 43 | TUI.safe_addstr(self.scr, opts_y + 4, 8, 'Deletes files not in hierarchy', curses.color_pair(4)) 44 | TUI.safe_addstr(self.scr, h - 3, (w - 20) // 2, 'Esc: Cancel Q: Quit', curses.color_pair(4) | curses.A_DIM) 45 | self.scr.refresh() 46 | 47 | def run(self): 48 | while True: 49 | if not TUI.check_terminal_size(self.scr): 50 | return None 51 | self.refresh() 52 | k = self.scr.getch() 53 | if k == 27 or k == ord('q'): 54 | return None 55 | if k in (ord('a'), ord('A')): 56 | return self.adopt_disk() 57 | elif k in (ord('b'), ord('B')): 58 | return self.adopt_hierarchy() 59 | elif k in (ord('d'), ord('D')): 60 | return self.delete_extra() 61 | 62 | def adopt_disk(self): 63 | try: 64 | new_hierarchy = [] 65 | content_dir = Path('content') 66 | if not content_dir.exists(): 67 | return False 68 | ch_idxs = [] 69 | for d in content_dir.iterdir(): 70 | if d.is_dir() and d.name.isdigit(): 71 | ch_idxs.append(int(d.name)) 72 | ch_idxs.sort() 73 | for i in ch_idxs: 74 | old_ch = self.hierarchy[i] if i < len(self.hierarchy) else {} 75 | title = old_ch.get('title', f'Chapter {i + 1}') 76 | summary = old_ch.get('summary', '') 77 | pages = [] 78 | ch_dir = content_dir / str(i) 79 | pg_idxs = [] 80 | for f in ch_dir.glob('*.typ'): 81 | if f.stem.isdigit(): 82 | pg_idxs.append(int(f.stem)) 83 | pg_idxs.sort() 84 | for j in pg_idxs: 85 | old_pg = old_ch.get('pages', [])[j] if 'pages' in old_ch and j < len(old_ch['pages']) else {} 86 | pg_title = old_pg.get('title', 'Untitled Section') 87 | pages.append({'title': pg_title}) 88 | new_hierarchy.append({'title': title, 'summary': summary, 'pages': pages}) 89 | HIERARCHY_FILE.write_text(json.dumps(new_hierarchy, indent=4)) 90 | return True 91 | except Exception as e: 92 | return False 93 | 94 | def adopt_hierarchy(self): 95 | from ...core.fs_sync import ensure_content_structure 96 | try: 97 | ensure_content_structure(self.hierarchy) 98 | return True 99 | except: 100 | return False 101 | 102 | def delete_extra(self): 103 | from ...core.fs_sync import cleanup_extra_files 104 | try: 105 | cleanup_extra_files(self.hierarchy) 106 | return True 107 | except: 108 | return False -------------------------------------------------------------------------------- /noteworthy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | import json 5 | import urllib.request 6 | import urllib.parse 7 | import shutil 8 | from pathlib import Path 9 | 10 | # ... (rest of imports) 11 | 12 | def bootstrap(branch='master'): 13 | repo_api = f'https://api.github.com/repos/sihooleebd/noteworthy/git/trees/{branch}?recursive=1' 14 | raw_base = f'https://raw.githubusercontent.com/sihooleebd/noteworthy/{branch}/' 15 | 16 | print(f'Fetching file list from {branch}...') 17 | try: 18 | req = urllib.request.Request(repo_api, headers={'User-Agent': 'Noteworthy-Loader'}) 19 | with urllib.request.urlopen(req) as response: 20 | data = json.loads(response.read().decode()) 21 | except Exception as e: 22 | print(f'Error fetching file list: {e}') 23 | return False 24 | 25 | files = [] 26 | for item in data.get('tree', []): 27 | if item.get('type') != 'blob': 28 | continue 29 | p = item['path'] 30 | if p.startswith('noteworthy/') or p.startswith('templates/') or p == 'noteworthy.py': 31 | # Changes for the user request: "JUST BRING THE SCHEMES" 32 | # We want to skip the default config/hierarchy/preface so we don't load the "tutor" content. 33 | # But we must ensure schemes.json is still downloaded. 34 | if p.startswith('templates/config/') and not p.endswith('schemes.json'): 35 | continue 36 | files.append(p) 37 | 38 | print(f'Downloading {len(files)} files...') 39 | success_count = 0 40 | for p in files: 41 | target = Path(p) 42 | url = raw_base + urllib.parse.quote(p) 43 | try: 44 | target.parent.mkdir(parents=True, exist_ok=True) 45 | with urllib.request.urlopen(url) as r, open(target, 'wb') as f: 46 | f.write(r.read()) 47 | print(f'Downloaded {p}') 48 | success_count += 1 49 | except Exception as e: 50 | print(f'Failed {p}: {e}') 51 | 52 | return success_count > 0 53 | 54 | if __name__ == "__main__": 55 | do_install = False 56 | branch = 'master' 57 | force = False 58 | 59 | # Parse flags 60 | if '--force-update-nightly' in sys.argv: 61 | do_install = True 62 | branch = 'nightly' 63 | force = True 64 | sys.argv.remove('--force-update-nightly') 65 | elif '--force-update' in sys.argv: 66 | do_install = True 67 | branch = 'master' 68 | force = True 69 | sys.argv.remove('--force-update') 70 | elif '--load-nightly' in sys.argv: 71 | do_install = True 72 | branch = 'nightly' 73 | sys.argv.remove('--load-nightly') 74 | elif '--load' in sys.argv: 75 | do_install = True 76 | sys.argv.remove('--load') 77 | 78 | # Auto-install if missing package 79 | if not Path('noteworthy').exists(): 80 | do_install = True 81 | print("Noteworthy folder not found. Initiating download...") 82 | 83 | if do_install: 84 | if force: 85 | print("Force updating: Removing existing directories...") 86 | 87 | # Backup config files 88 | backups = [] 89 | files_to_save = ['config.json', 'hierarchy.json', 'preface.typ'] 90 | for fname in files_to_save: 91 | src = Path(f'templates/config/{fname}') 92 | if src.exists(): 93 | dst = Path(f'{fname}.bak') 94 | try: 95 | shutil.copy2(src, dst) 96 | backups.append((dst, src)) 97 | print(f"Backed up {fname}") 98 | except Exception as e: 99 | print(f"Warning: Failed to backup {fname}: {e}") 100 | 101 | if Path('noteworthy').exists(): 102 | shutil.rmtree('noteworthy') 103 | if Path('templates').exists(): 104 | shutil.rmtree('templates') 105 | 106 | print(f"Updating/Installing Noteworthy from branch: {branch}") 107 | 108 | success = bootstrap(branch) 109 | 110 | # Restore backups regardless of success (to save user data) 111 | # If bootstrap successful, it overwrites defaults. If failed, it restores what it can. 112 | if force and backups: 113 | print("Restoring configuration files...") 114 | for dst, src in backups: 115 | try: 116 | if dst.exists(): 117 | src.parent.mkdir(parents=True, exist_ok=True) 118 | shutil.move(str(dst), str(src)) 119 | print(f"Restored {src.name}") 120 | except Exception as e: 121 | print(f"Error restoring {src.name} (backup at {dst}): {e}") 122 | 123 | if not success: 124 | print("Update failed or incomplete.") 125 | if not Path('noteworthy').exists(): 126 | sys.exit(1) 127 | else: 128 | print("Update complete.") 129 | 130 | # Avoid running main if we just updated and main is potentially old/new mix? 131 | # Actually standard flow falls through. But we modified logic flow slightly. 132 | # Original code had if not bootstrap: ... else: print. 133 | # I refactored to check success variable. 134 | 135 | 136 | # Ensure preface.typ exists to prevent build errors, but keep it empty 137 | preface_path = Path('templates/config/preface.typ') 138 | if Path('templates/config').exists() and not preface_path.exists(): 139 | try: 140 | preface_path.write_text('') 141 | print("Created empty preface.typ") 142 | except Exception as e: 143 | print(f"Warning: Could not create default preface: {e}") 144 | 145 | try: 146 | from noteworthy.__main__ import main 147 | except ImportError: 148 | # Fallback for local development or if just installed 149 | sys.path.append(str(Path(__file__).parent)) 150 | from noteworthy.__main__ import main 151 | 152 | main() 153 | -------------------------------------------------------------------------------- /noteworthy/tui/menus.py: -------------------------------------------------------------------------------- 1 | import curses 2 | from .base import TUI 3 | from ..config import LOGO 4 | from ..utils import register_key, handle_key_event 5 | from .keybinds import KeyBind, NavigationBind, ConfirmBind 6 | 7 | def show_keybindings_menu(scr): 8 | h_raw, w_raw = scr.getmaxyx() 9 | h, w = (h_raw - 2, w_raw - 2) 10 | bw, bh = (60, 20) 11 | bx, by = ((w - bw) // 2, (h - bh) // 2) 12 | win = curses.newwin(bh, bw, by, bx) 13 | win.box() 14 | start_y = 2 15 | win.addstr(0, 2, ' KEYBINDINGS ', curses.color_pair(1) | curses.A_BOLD) 16 | keys = [('General', ''), ('Arrows/hjkl', 'Navigation'), ('Enter', 'Select / Confirm'), ('Esc', 'Back / Cancel'), ('?', 'Show this help'), ('', ''), ('Editors', ''), ('Space', 'Toggle Checkbox / Bool'), ('i', 'Insert (Text)'), ('d', 'Delete (Item/Line)'), ('n', 'New Item'), ('s', 'Save (explicit)'), ('', ''), ('Builder', ''), ('Space', 'Toggle Chapter/Page'), ('a / n', 'Select All / None'), ('d / f / l', 'Toggle Options'), ('c', 'Configure Flags')] 17 | for k, v in keys: 18 | if not v: 19 | win.addstr(start_y, 2, k, curses.color_pair(5) | curses.A_BOLD) 20 | else: 21 | win.addstr(start_y, 4, k, curses.color_pair(4) | curses.A_BOLD) 22 | win.addstr(start_y, 24, v, curses.color_pair(4)) 23 | start_y += 1 24 | if start_y >= bh - 2: 25 | break 26 | win.addstr(bh - 2, 2, 'Press any key to close...', curses.color_pair(4) | curses.A_DIM) 27 | win.refresh() 28 | win.getch() 29 | 30 | class MainMenu: 31 | 32 | def __init__(self, scr): 33 | self.scr = scr 34 | self.options = [('e', 'Editor', 'Edit configuration and content'), ('b', 'Builder', 'Build PDF document')] 35 | self.selected = 1 36 | 37 | self.keymap = {} 38 | register_key(self.keymap, NavigationBind('LEFT', self.move_prev)) 39 | register_key(self.keymap, NavigationBind('UP', self.move_prev)) 40 | register_key(self.keymap, NavigationBind('RIGHT', self.move_next)) 41 | register_key(self.keymap, NavigationBind('DOWN', self.move_next)) 42 | register_key(self.keymap, ConfirmBind(self.action_confirm)) 43 | register_key(self.keymap, KeyBind(27, self.action_exit, "Exit")) 44 | register_key(self.keymap, KeyBind(ord('?'), self.action_help, "Help")) 45 | register_key(self.keymap, KeyBind(ord('e'), self.action_editor, "Editor")) 46 | register_key(self.keymap, KeyBind(ord('b'), self.action_builder, "Builder")) 47 | 48 | def move_prev(self, ctx): 49 | self.selected = max(0, self.selected - 1) 50 | 51 | def move_next(self, ctx): 52 | self.selected = min(len(self.options) - 1, self.selected + 1) 53 | 54 | def action_confirm(self, ctx): 55 | return self.options[self.selected][1].lower() 56 | 57 | def action_editor(self, ctx): 58 | return 'editor' 59 | 60 | def action_builder(self, ctx): 61 | return 'builder' 62 | 63 | def action_exit(self, ctx): 64 | return 'EXIT' 65 | 66 | def action_help(self, ctx): 67 | show_keybindings_menu(self.scr) 68 | 69 | def draw(self): 70 | h_raw, w_raw = self.scr.getmaxyx() 71 | h, w = (h_raw - 2, w_raw - 2) 72 | self.scr.clear() 73 | lh = len(LOGO) 74 | layout = 'vert' 75 | if h < lh + 18 and w > 80: 76 | layout = 'horz' 77 | if layout == 'vert': 78 | start_y = max(1, (h - lh - 10) // 2) 79 | lgx = (w - 14) // 2 80 | for i, line in enumerate(LOGO): 81 | TUI.safe_addstr(self.scr, start_y + i, lgx, line, curses.color_pair(1) | curses.A_BOLD) 82 | TUI.safe_addstr(self.scr, start_y + lh + 1, (w - 10) // 2, 'NOTEWORTHY', curses.color_pair(1) | curses.A_BOLD) 83 | btn_y = start_y + lh + 4 84 | btn_w = 20 85 | start_x = (w - (btn_w * 2 + 4)) // 2 86 | for i, (key, label, desc) in enumerate(self.options): 87 | bx = start_x + i * (btn_w + 4) 88 | style = curses.color_pair(2) | curses.A_BOLD if i == self.selected else curses.color_pair(4) 89 | TUI.draw_box(self.scr, btn_y, bx, 5, btn_w, '') 90 | TUI.safe_addstr(self.scr, btn_y + 2, bx + (btn_w - len(label)) // 2, label, style) 91 | TUI.safe_addstr(self.scr, btn_y + 5, bx + (btn_w - 3) // 2, f'({key.upper()})', curses.color_pair(4) | curses.A_DIM) 92 | else: 93 | total_w = 16 + 8 + 30 94 | start_x = (w - total_w) // 2 95 | start_y = (h - lh) // 2 96 | for i, line in enumerate(LOGO): 97 | TUI.safe_addstr(self.scr, start_y + i, start_x, line, curses.color_pair(1) | curses.A_BOLD) 98 | TUI.safe_addstr(self.scr, start_y + lh + 1, start_x + 2, 'NOTEWORTHY', curses.color_pair(1) | curses.A_BOLD) 99 | btn_x = start_x + 24 100 | btn_start_y = start_y + (lh - 10) // 2 101 | btn_w = 20 102 | for i, (key, label, desc) in enumerate(self.options): 103 | by = btn_start_y + i * 6 104 | style = curses.color_pair(2) | curses.A_BOLD if i == self.selected else curses.color_pair(4) 105 | TUI.draw_box(self.scr, by, btn_x, 5, btn_w, '') 106 | TUI.safe_addstr(self.scr, by + 2, btn_x + (btn_w - len(label)) // 2, label, style) 107 | TUI.safe_addstr(self.scr, by + 2, btn_x + btn_w + 2, f'({key.upper()})', curses.color_pair(4) | curses.A_DIM) 108 | footer = 'Arrows: Select Enter: Confirm Esc: Quit' 109 | TUI.safe_addstr(self.scr, h - 3, (w - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 110 | self.scr.refresh() 111 | 112 | def run(self): 113 | self.scr.timeout(-1) 114 | while True: 115 | if not TUI.check_terminal_size(self.scr): 116 | return None 117 | self.draw() 118 | k = self.scr.getch() 119 | handled, res = handle_key_event(k, self.keymap, self) 120 | if handled: 121 | if res == 'EXIT': 122 | return 'EXIT' 123 | elif res: 124 | return res -------------------------------------------------------------------------------- /noteworthy/tui/editors/snippets.py: -------------------------------------------------------------------------------- 1 | import curses 2 | from ..base import ListEditor, TUI 3 | from ..components.common import LineEditor 4 | from ...config import SNIPPETS_FILE 5 | from ..keybinds import ConfirmBind, KeyBind 6 | from ...utils import register_key 7 | 8 | class SnippetsEditor(ListEditor): 9 | def __init__(self, scr): 10 | super().__init__(scr, "Code Snippets") 11 | self.filepath = SNIPPETS_FILE 12 | self._load_snippets() 13 | self.box_title = "Snippets" 14 | self.box_width = 80 15 | 16 | register_key(self.keymap, ConfirmBind(self.action_select)) 17 | register_key(self.keymap, KeyBind(ord('n'), self.action_new, "New Snippet")) 18 | register_key(self.keymap, KeyBind(ord('d'), self.action_delete, "Delete Snippet")) 19 | 20 | def action_select(self, ctx): 21 | if self.cursor >= len(self.snippets): 22 | self.action_new(ctx) 23 | else: 24 | name, definition = self.snippets[self.cursor] 25 | new_name = LineEditor(self.scr, initial_value=name, title="Edit Snippet Name").run() 26 | if new_name is not None: 27 | self.snippets[self.cursor][0] = new_name 28 | self.modified = True 29 | new_def = LineEditor(self.scr, initial_value=definition, title="Edit Definition").run() 30 | if new_def is not None: 31 | self.snippets[self.cursor][1] = new_def 32 | self.modified = True 33 | 34 | def action_new(self, ctx): 35 | self.snippets.append(["new_snippet", "[definition]"]) 36 | self.cursor = len(self.snippets) - 1 37 | self.modified = True 38 | self._update_items() 39 | 40 | name, definition = self.snippets[self.cursor] 41 | new_name = LineEditor(self.scr, initial_value=name, title="New Snippet Name").run() 42 | if new_name is not None: self.snippets[self.cursor][0] = new_name 43 | new_def = LineEditor(self.scr, initial_value=definition, title="New Definition").run() 44 | if new_def is not None: self.snippets[self.cursor][1] = new_def 45 | 46 | def action_delete(self, ctx): 47 | if self.cursor < len(self.snippets) and self.snippets: 48 | if TUI.prompt_confirm(self.scr, "Delete snippet? (y/n): "): 49 | del self.snippets[self.cursor] 50 | if self.cursor >= len(self.snippets): self.cursor = max(0, len(self.snippets) - 1) 51 | self.modified = True 52 | self._update_items() 53 | 54 | def _load_snippets(self): 55 | self.snippets = [] 56 | try: 57 | content = SNIPPETS_FILE.read_text() 58 | for line in content.split('\n'): 59 | line = line.strip() 60 | if line.startswith('#let ') and '=' in line: 61 | rest = line[5:] 62 | eq_pos = rest.find('=') 63 | if eq_pos != -1: 64 | name = rest[:eq_pos].strip() 65 | if '(' in name: 66 | name = name[:name.find('(') + 1] + name[name.find('(') + 1:name.find(')') + 1] 67 | definition = rest[eq_pos + 1:].strip() 68 | self.snippets.append([name, definition]) 69 | except: pass 70 | if not self.snippets: self.snippets = [["example", "[example text]"]] 71 | self._update_items() 72 | 73 | def _update_items(self): 74 | self.items = self.snippets + [["+ Add new snippet...", ""]] 75 | 76 | def _load(self): 77 | self._load_snippets() 78 | 79 | def _save_snippets(self): 80 | lines = [] 81 | for name, definition in self.snippets: 82 | lines.append(f"#let {name} = {definition}") 83 | SNIPPETS_FILE.write_text('\n'.join(lines) + '\n') 84 | self.modified = False 85 | 86 | def save(self): 87 | try: self._save_snippets(); return True 88 | except: return False 89 | 90 | def _draw_item(self, y, x, item, width, selected): 91 | name, definition = item 92 | left_w = 22 93 | 94 | if selected: TUI.safe_addstr(self.scr, y, x + 2, ">", curses.color_pair(3) | curses.A_BOLD) 95 | 96 | if name == "+ Add new snippet...": 97 | TUI.safe_addstr(self.scr, y, x + 4, name, curses.color_pair(3 if selected else 4) | (curses.A_BOLD if selected else curses.A_DIM)) 98 | else: 99 | TUI.safe_addstr(self.scr, y, x + 4, name[:left_w - 6], curses.color_pair(5 if selected else 4) | (curses.A_BOLD if selected else 0)) 100 | TUI.safe_addstr(self.scr, y, x + left_w + 2, definition[:width - left_w - 6], curses.color_pair(4) | (curses.A_BOLD if selected else 0)) 101 | 102 | def refresh(self): 103 | h, w = self.scr.getmaxyx() 104 | self.scr.clear() 105 | 106 | list_h = min(len(self.items) + 2, h - 8) 107 | total_h = 2 + list_h + 2 108 | start_y = max(1, (h - total_h) // 2) 109 | 110 | title_str = f"{self.title}{' *' if self.modified else ''}" 111 | TUI.safe_addstr(self.scr, start_y, (w - len(title_str)) // 2, title_str, curses.color_pair(1) | curses.A_BOLD) 112 | 113 | bw = min(self.box_width, w - 4) 114 | bx = (w - bw) // 2 115 | left_w = 22 116 | 117 | TUI.draw_box(self.scr, start_y + 2, bx, list_h, bw, self.box_title) 118 | 119 | TUI.safe_addstr(self.scr, start_y + 3, bx + 4, "Name", curses.color_pair(1) | curses.A_BOLD) 120 | TUI.safe_addstr(self.scr, start_y + 3, bx + left_w + 2, "Definition", curses.color_pair(1) | curses.A_BOLD) 121 | 122 | for i in range(1, list_h - 1): 123 | TUI.safe_addstr(self.scr, start_y + 2 + i, bx + left_w, "│", curses.color_pair(4) | curses.A_DIM) 124 | 125 | vis = list_h - 3 126 | if self.cursor < self.scroll: self.scroll = self.cursor 127 | elif self.cursor >= self.scroll + vis: self.scroll = self.cursor - vis + 1 128 | 129 | for i in range(vis): 130 | idx = self.scroll + i 131 | if idx >= len(self.items): break 132 | y = start_y + 4 + i 133 | self._draw_item(y, bx, self.items[idx], bw, idx == self.cursor) 134 | 135 | self._draw_footer(h, w) 136 | self.scr.refresh() 137 | 138 | def _draw_footer(self, h, w): 139 | footer = "n: New d: Delete Enter: Edit Esc: Save & Exit x: Export l: Import" 140 | TUI.safe_addstr(self.scr, h - 3, (w - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 141 | -------------------------------------------------------------------------------- /templates/plots/geoplot.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/cetz:0.4.2" 2 | #import "@preview/cetz-plot:0.1.3": plot 3 | #import "../setup.typ": render-sample-count 4 | 5 | /// Draws a point on a 2D or 3D plot with optional label. 6 | /// 7 | /// Parameters: 8 | /// - theme: Theme dictionary containing plot styling 9 | /// - xy: Coordinate tuple (x, y) for 2D or (x, y, z) for 3D 10 | /// - label-content: Content to display as label 11 | /// - pos: Anchor position for label (default: "west") 12 | /// - padding: Padding around label (default: 0.2) 13 | /// - color: Point fill color (default: theme.plot.highlight) 14 | #let point(theme: (:), xy, label-content, pos: "west", padding: 0.2, color: auto) = { 15 | let fill-col = if color == auto { theme.plot.highlight } else { color } 16 | 17 | if xy.len() == 3 { 18 | // 3D mode: Raw draw for spaceplot 19 | import cetz.draw: * 20 | circle(xy, radius: 0.05, fill: fill-col, stroke: none) 21 | content(xy, padding: padding, anchor: pos, text(fill: theme.plot.stroke, label-content)) 22 | } else { 23 | // 2D mode: Plot item for rect-plot 24 | plot.add((xy,), mark: "o", mark-style: (fill: fill-col, stroke: none)) 25 | plot.annotate({ 26 | import cetz.draw: * 27 | content(xy, padding: padding, anchor: pos, text(fill: theme.plot.stroke, label-content)) 28 | }) 29 | } 30 | } 31 | 32 | /// Adds a polar curve to a plot by converting polar coordinates to Cartesian. 33 | /// 34 | /// Parameters: 35 | /// - func: Function r(θ) defining the polar curve 36 | /// - domain: Range of θ values (default: (0, 2π)) 37 | /// - style: Plot style dictionary 38 | #let add-polar(func, domain: (0, 2 * calc.pi), style: (:)) = { 39 | plot.add( 40 | domain: domain, 41 | samples: render-sample-count, 42 | 43 | t => (func(t) * calc.cos(t), func(t) * calc.sin(t)), 44 | style: style, 45 | ) 46 | } 47 | 48 | /// Draws an angle arc between two rays from an origin point. 49 | /// 50 | /// Parameters: 51 | /// - theme: Theme dictionary containing plot styling 52 | /// - origin: Origin point of the angle (x, y) or (x, y, z) 53 | /// - start-ang: Starting angle in degrees or radians 54 | /// - delta: Angular width (positive = counterclockwise) 55 | /// - label: Label content to display 56 | /// - radius: Arc radius (default: 0.5) 57 | /// - label-dist: Distance from origin to label (default: auto) 58 | /// - col: Angle color (default: green) 59 | #let add-angle(theme: (:), origin, start-ang, delta, label, radius: 0.5, label-dist: 0, col: green) = { 60 | let fill-color = if type(col) == color { col.transparentize(70%) } else { none } 61 | let label-radius = if label-dist == 0 { radius * 7 / 5 } else { label-dist } 62 | 63 | // Note: Currently only supports 2D angles 64 | // 3D angle support requires specifying the plane of rotation 65 | 66 | let starting = if delta.deg() >= 0 { (origin.at(0) + calc.cos(start-ang), origin.at(1) + calc.sin(start-ang)) } else { 67 | (origin.at(0) + calc.cos(start-ang + delta), origin.at(1) + calc.sin(start-ang + delta)) 68 | } 69 | let ending = if delta.deg() <= 0 { (origin.at(0) + calc.cos(start-ang), origin.at(1) + calc.sin(start-ang)) } else { 70 | (origin.at(0) + calc.cos(start-ang + delta), origin.at(1) + calc.sin(start-ang + delta)) 71 | } 72 | plot.annotate({ 73 | cetz.angle.angle( 74 | origin, 75 | starting, 76 | ending, 77 | label: text(col, label), 78 | fill: fill-color, 79 | radius: radius, 80 | label-radius: label-radius, 81 | stroke: (theme.plot.stroke), 82 | ) 83 | }) 84 | } 85 | 86 | /// Draws a right angle (90°) marker at a specified orientation. 87 | /// 88 | /// Parameters: 89 | /// - theme: Theme dictionary containing plot styling 90 | /// - origin: Origin point of the angle (x, y) or (x, y, z) 91 | /// - start-ang: Starting angle in degrees or radians 92 | /// - radius: Marker size (default: 0.5) 93 | /// - label-dist: Unused parameter (kept for compatibility) 94 | #let add-right-angle(theme: (:), origin, start-ang, radius: 0.5, label-dist: 0) = { 95 | let delta = 90deg 96 | let starting = if delta.deg() >= 0 { (origin.at(0) + calc.cos(start-ang), origin.at(1) + calc.sin(start-ang)) } else { 97 | (origin.at(0) + calc.cos(start-ang + delta), origin.at(1) + calc.sin(start-ang + delta)) 98 | } 99 | let ending = if delta.deg() <= 0 { (origin.at(0) + calc.cos(start-ang), origin.at(1) + calc.sin(start-ang)) } else { 100 | (origin.at(0) + calc.cos(start-ang + delta), origin.at(1) + calc.sin(start-ang + delta)) 101 | } 102 | plot.annotate({ 103 | cetz.angle.right-angle( 104 | origin, 105 | starting, 106 | ending, 107 | label: "", 108 | radius: radius, 109 | stroke: (theme.plot.stroke), 110 | ) 111 | }) 112 | } 113 | 114 | /// Draws a rotated coordinate system with X and Y axes. 115 | /// 116 | /// Parameters: 117 | /// - phi-ang: Rotation angle of the coordinate system 118 | /// - scale: Length scale factor for axes 119 | /// - rad: Radius of angle marker (default: auto-calculated) 120 | /// - X-pad: Padding for X label (default: 0) 121 | /// - Y-pad: Padding for Y label (default: 0) 122 | /// - X-pos: Anchor position for X label (default: "west") 123 | /// - Y-pos: Anchor position for Y label (default: "north-east") 124 | #let add-xy-axes(phi-ang, scale, rad: 0, X-pad: 0, Y-pad: 0, X-pos: "west", Y-pos: "north-east") = { 125 | import "../templater.typ": active-theme 126 | let radrad = if rad == 0 { scale / 6 } else { rad } 127 | scale = scale * calc.sqrt(2) 128 | plot.annotate({ 129 | cetz.draw.line( 130 | (-scale * calc.cos(phi-ang), -scale * calc.sin(phi-ang)), 131 | (scale * calc.cos(phi-ang), scale * calc.sin(phi-ang)), 132 | name: "X", 133 | stroke: (paint: gray, dash: "dashed"), 134 | mark: (end: "stealth", scale: scale / 4), 135 | ) 136 | cetz.draw.line( 137 | (-scale * calc.cos(phi-ang + 90deg), -scale * calc.sin(phi-ang + 90deg)), 138 | (scale * calc.cos(phi-ang + 90deg), scale * calc.sin(phi-ang + 90deg)), 139 | name: "Y", 140 | stroke: (paint: gray, dash: "dashed"), 141 | mark: (end: "stealth", scale: scale / 4), 142 | ) 143 | cetz.draw.content("X.end", $X$, anchor: X-pos, padding: X-pad) 144 | cetz.draw.content("Y.end", $Y$, anchor: Y-pos, padding: Y-pad) 145 | }) 146 | add-angle((0, 0), 0deg, phi-ang, $phi$, col: color.green, radius: radrad, theme: active-theme) 147 | } 148 | 149 | /// Draws a polygon with optional fill and label. 150 | /// Automatically detects 2D vs 3D based on coordinate dimensions. 151 | /// 152 | /// Parameters: 153 | /// - theme: Theme dictionary containing plot styling 154 | /// - points: Array of coordinate tuples 155 | /// - fill: Fill color (default: none) 156 | /// - stroke: Stroke style (default: theme.plot.stroke) 157 | /// - label: Optional label to place at polygon centroid 158 | /// - label-color: Label text color (default: theme.plot.stroke) 159 | #let add-polygon(theme: (:), points, fill: none, stroke: auto, label: none, label-color: auto) = { 160 | let stroke-style = if stroke == auto { (paint: theme.plot.stroke) } else { stroke } 161 | let txt-color = if label-color == auto { theme.plot.stroke } else { label-color } 162 | 163 | let is-3d = points.any(p => p.len() == 3) 164 | 165 | let draw-cmd = { 166 | import cetz.draw: * 167 | group({ 168 | // In plot.annotate context, we can't use fill parameter 169 | // So we just draw the outline 170 | line(..points, close: true, stroke: stroke-style) 171 | 172 | if label != none { 173 | let sum-x = 0 174 | let sum-y = 0 175 | let sum-z = 0 176 | for p in points { 177 | sum-x += p.at(0) 178 | sum-y += p.at(1) 179 | if p.len() > 2 { sum-z += p.at(2) } 180 | } 181 | let center = if is-3d { 182 | (sum-x / points.len(), sum-y / points.len(), sum-z / points.len()) 183 | } else { 184 | (sum-x / points.len(), sum-y / points.len()) 185 | } 186 | content(center, text(fill: txt-color, label)) 187 | } 188 | }) 189 | } 190 | 191 | if is-3d { 192 | // 3D mode: Raw draw 193 | draw-cmd 194 | } else { 195 | // 2D mode: Annotate 196 | plot.annotate(draw-cmd) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /templates/plots/plots.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/cetz:0.4.2" 2 | #import "@preview/cetz-plot:0.1.3": plot 3 | 4 | /// Creates a rectangular coordinate plot with labeled axes and grid. 5 | /// This is the standard Cartesian plotting environment for 2D functions. 6 | /// 7 | /// Parameters: 8 | /// - theme: Theme dictionary containing plot styling 9 | /// - size: Canvas size as (width, height) tuple (default: auto → (10, 10)) 10 | /// - x-domain: X-axis range as (min, max) (default: (-4.5, 5.5)) 11 | /// - y-domain: Y-axis range as (min, max) (default: (-5.5, 5.5)) 12 | /// - x-tick: X-axis tick spacing (default: 1) 13 | /// - y-tick: Y-axis tick spacing (default: 1) 14 | /// - is-pi: If false, use normal numbers. If a positive integer, format x-axis 15 | /// labels as multiples of π/is-pi (e.g., is-pi: 2 → π/2, π, 3π/2, ...) 16 | /// - body: Plot content (functions, points, annotations) 17 | /// - draw-content: Additional CeTZ drawing commands (optional) 18 | #let rect-plot( 19 | theme: (:), 20 | size: auto, 21 | x-domain: (-4.5, 5.5), 22 | y-domain: (-5.5, 5.5), 23 | x-tick: 1, 24 | y-tick: 1, 25 | is-pi: false, 26 | body, 27 | draw-content: none, 28 | ) = { 29 | let actual-size = if size == auto { (10, 10) } else { size } 30 | 31 | // Define x-format function based on is-pi 32 | let x-format-fn = if is-pi != false { 33 | let d = if is-pi == true { 1 } else { is-pi } 34 | let gcd(a, b) = if b == 0 { a } else { gcd(b, calc.rem(a, b)) } 35 | x => { 36 | let n = int(calc.round(x * d / calc.pi)) 37 | let g = gcd(calc.abs(n), d) 38 | let num = calc.quo(n, g) 39 | let denom = calc.quo(d, g) 40 | 41 | text(fill: theme.plot.stroke, size: 8pt, { 42 | if num == 0 { $0$ } else if denom == 1 { 43 | if num == 1 { $pi$ } else if num == -1 { $-pi$ } else { $#num pi$ } 44 | } else { 45 | if num == 1 { $pi / #denom$ } else if num == -1 { $-pi / #denom$ } else { $#num / #denom pi$ } 46 | } 47 | }) 48 | } 49 | } else { 50 | x => text(fill: theme.plot.stroke, size: 8pt, str(x)) 51 | } 52 | 53 | cetz.canvas({ 54 | import cetz.draw: * 55 | 56 | set-style( 57 | stroke: theme.plot.stroke, 58 | fill: none, 59 | ) 60 | 61 | plot.plot( 62 | size: actual-size, 63 | axis-style: "school-book", 64 | x-tick-step: x-tick, 65 | y-tick-step: y-tick, 66 | x-grid: false, 67 | y-grid: false, 68 | 69 | legend-style: ( 70 | stroke: theme.plot.stroke, 71 | fill: none, 72 | padding: 0.5, 73 | item: (spacing: 0.5), 74 | ), 75 | 76 | x-label: text(fill: theme.plot.stroke, $x$), 77 | y-label: text(fill: theme.plot.stroke, $y$), 78 | 79 | x-format: x-format-fn, 80 | y-format: y => text(fill: theme.plot.stroke, size: 8pt, str(y)), 81 | 82 | x-min: x-domain.at(0), 83 | x-max: x-domain.at(1), 84 | y-min: y-domain.at(0), 85 | y-max: y-domain.at(1), 86 | 87 | { 88 | body 89 | if draw-content != none { 90 | plot.annotate({ 91 | draw-content 92 | }) 93 | } 94 | }, 95 | ) 96 | }) 97 | } 98 | 99 | 100 | /// Creates a polar coordinate plot with concentric circles and radial lines. 101 | /// 102 | /// Parameters: 103 | /// - theme: Theme dictionary containing plot styling 104 | /// - size: Canvas size as (width, height) tuple (default: auto → (10, 10)) 105 | /// - radius: Maximum radius for polar grid (default: 5.5) 106 | /// - tick: Radial tick spacing (default: 1) 107 | /// - margin: Extra margin beyond radius (default: 0.5) 108 | /// - body: Plot content (polar curves, points) 109 | /// - draw-content: Additional CeTZ drawing commands (optional) 110 | #let polar-plot( 111 | theme: (:), 112 | size: auto, 113 | radius: 5.5, 114 | tick: 1, 115 | margin: 0.5, 116 | body, 117 | draw-content: none, 118 | ) = { 119 | let actual-size = if size == auto { (10, 10) } else { size } 120 | let effective-radius = radius + margin 121 | let grid-color = theme.plot.grid 122 | 123 | cetz.canvas({ 124 | import cetz.draw: * 125 | set-style( 126 | stroke: theme.plot.stroke, 127 | fill: theme.plot.stroke, 128 | ) 129 | 130 | plot.plot( 131 | size: actual-size, 132 | axis-style: none, 133 | x-tick-step: none, 134 | y-tick-step: none, 135 | x-grid: false, 136 | y-grid: false, 137 | x-min: -effective-radius, 138 | x-max: effective-radius, 139 | y-min: -effective-radius, 140 | y-max: effective-radius, 141 | { 142 | // Draw polar grid inside plot coordinate system 143 | plot.annotate({ 144 | on-layer( 145 | -1, 146 | { 147 | // Draw concentric circles 148 | let num-circles = calc.floor(radius / tick) 149 | for i in range(1, num-circles + 1) { 150 | let r = i * tick 151 | circle((0, 0), radius: r, stroke: (paint: grid-color, thickness: 0.75pt), fill: none) 152 | } 153 | // Draw radial lines 154 | for deg in range(0, 180, step: 30) { 155 | line( 156 | (calc.cos(deg * 1deg) * effective-radius, calc.sin(deg * 1deg) * effective-radius), 157 | (calc.cos((deg + 180) * 1deg) * effective-radius, calc.sin((deg + 180) * 1deg) * effective-radius), 158 | stroke: (paint: grid-color, thickness: 0.75pt), 159 | ) 160 | } 161 | // Draw polar axis (bold line from origin to right with arrow) 162 | line( 163 | (0, 0), 164 | (effective-radius, 0), 165 | stroke: (paint: theme.plot.stroke, thickness: 1pt), 166 | mark: (end: ">", fill: theme.plot.stroke), 167 | ) 168 | 169 | // Add radius tick labels along polar axis 170 | for i in range(1, num-circles + 1) { 171 | let r = i * tick 172 | let label = if calc.rem(r, 1) == 0 { str(int(r)) } else { str(calc.round(r, digits: 2)) } 173 | content((r, -0.3), text(fill: theme.plot.stroke, size: 7.5pt, label), anchor: "north") 174 | } 175 | 176 | if draw-content != none { 177 | draw-content 178 | } 179 | }, 180 | ) 181 | }) 182 | 183 | // Draw the actual polar curve 184 | body 185 | }, 186 | ) 187 | }) 188 | } 189 | 190 | /// Creates a simple canvas for drawing combinatorics diagrams. 191 | /// No axes or grids, just a blank CeTZ canvas. 192 | /// 193 | /// Parameters: 194 | /// - theme: Theme dictionary containing plot styling 195 | /// - body: Drawing commands 196 | #let combi-plot( 197 | theme: (:), 198 | body, 199 | ) = { 200 | cetz.canvas({ 201 | import cetz.draw: * 202 | set-style(stroke: theme.plot.stroke, fill: none) 203 | body 204 | }) 205 | } 206 | 207 | /// Creates a coordinate system without visible axes for custom drawings. 208 | /// Useful for diagrams where you need coordinate mapping but no visible grid. 209 | /// 210 | /// Parameters: 211 | /// - theme: Theme dictionary containing plot styling 212 | /// - size: Canvas size as (width, height) tuple (default: auto → (10, 10)) 213 | /// - x-domain: X-axis range for coordinate mapping (default: (-5, 5)) 214 | /// - y-domain: Y-axis range for coordinate mapping (default: (-5, 5)) 215 | /// - body: Plot content 216 | /// - draw-content: Additional CeTZ drawing commands (optional) 217 | #let blank-plot( 218 | theme: (:), 219 | size: auto, 220 | x-domain: (-5, 5), 221 | y-domain: (-5, 5), 222 | body, 223 | draw-content: none, 224 | ) = { 225 | let actual-size = if size == auto { (10, 10) } else { size } 226 | cetz.canvas({ 227 | import cetz.draw: * 228 | 229 | set-style(stroke: theme.plot.stroke, fill: theme.plot.stroke) 230 | 231 | plot.plot( 232 | size: actual-size, 233 | axis-style: none, 234 | x-grid: false, 235 | y-grid: false, 236 | 237 | x-min: x-domain.at(0), 238 | x-max: x-domain.at(1), 239 | y-min: y-domain.at(0), 240 | y-max: y-domain.at(1), 241 | 242 | { 243 | body 244 | 245 | if draw-content != none { 246 | plot.annotate({ 247 | draw-content 248 | }) 249 | } 250 | }, 251 | ) 252 | }) 253 | } 254 | 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Noteworthy 2 | 3 | ``` 4 | ,--. 5 | ,--.'| 6 | ,--,: : | 7 | ,`--.'`| ' : 8 | | : : | | 9 | : | \ | : 10 | | : ' '; | 11 | ' ' ;. ; 12 | | | | \ | 13 | ' : | ; .' 14 | | | '`--' 15 | ' : | 16 | ; |.' 17 | '---' 18 | ``` 19 | 20 | **A powerful Typst framework for creating beautiful, themed educational documents.** 21 | 22 | [![Typst](https://img.shields.io/badge/Typst-0.12%2B-239DAD?logo=typst)](https://typst.app/) 23 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 24 | [![Discord](https://img.shields.io/badge/Discord-Community-5865F2?logo=discord&logoColor=white)](https://discord.gg/W3S2UQCJzM) 25 | [![Website](https://img.shields.io/badge/Website-noteworthy.benjaminlee.kr-purple)](https://noteworthy.benjaminlee.kr/) 26 | 27 | Say hi to **Noteworthy**, an academic parser and framework for creating massive and complex documents in one go. It can be used for building educational textbooks, lecture notes, and technical documentation with Typst. It provides a complete ecosystem of tools, themes, and components that work together seamlessly. 28 | 29 | An example project is available at https://github.com/sihooleebd/math-noteworthy. 30 | 31 | ## Gallery 32 | 33 |

34 | Cover Page 35 | Table of Contents 36 | Content Page 37 | Another Content Page 38 |

39 | 40 | ### Themes 41 | 42 | [View the Themes Gallery](themes.md) 43 | 44 | ### Framework Components 45 | 46 | - **Theme System**: 13+ pre-built color schemes with easy customization 47 | - **Content Block Library**: Pre-styled components for definitions, theorems, examples, proofs, and solutions 48 | - **Plotting Engine**: Advanced 2D/3D plotting, vector diagrams, and geometric constructions 49 | - **Document Structure**: Automated table of contents, chapter covers, and page headers 50 | - **Configuration Layer**: JSON-based settings in `templates/config/` 51 | - **Build System**: Incremental compilation with automatic PDF merging 52 | - **Interactive Editors**: TUI-based editors for config, hierarchy, schemes, and snippets 53 | 54 | ## Key Features 55 | 56 | - **Theme-Driven Design**: Switch between 13+ themes instantly 57 | - **Modular Architecture**: Import only what you need 58 | - **Rich Typography**: Beautiful math typesetting with custom snippets 59 | - **Extensible**: Add custom blocks, themes, and plotting functions 60 | - **Production-Ready**: Used for real educational materials 61 | - **Incremental Build**: Compile sections individually, merge automatically 62 | 63 | ## Quick Start 64 | 65 | ### Prerequisites 66 | 67 | - **Typst** (v0.12.0+): [Install Typst](https://github.com/typst/typst#installation) 68 | - **Python 3**: Required for the build system 69 | - **Poppler** (provides `pdfinfo` for page counting): 70 | - macOS: `brew install poppler` 71 | - Linux: `apt-get install poppler-utils` 72 | - Windows: Download from [poppler releases](https://github.com/oschwartz10612/poppler-windows/releases) and add to PATH 73 | - **PDF Tool** (for merging and metadata): 74 | - macOS: `brew install pdftk-java` 75 | - Linux: `apt-get install pdftk` 76 | - Windows: Download from [pdftk releases](https://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/) 77 | - *Fallback*: Ghostscript (usually pre-installed on macOS/Linux, [download for Windows](https://ghostscript.com/releases/gsdnld.html)) 78 | 79 | > **Note |** `pdftk` is required for adding PDF metadata (title, author) and clickable bookmarks/outline that appear in the PDF viewer sidebar for easy navigation. 80 | 81 | ### Installation 82 | 83 | ```bash 84 | mkdir project 85 | cd project 86 | mkdir content 87 | curl -O https://raw.githubusercontent.com/sihooleebd/noteworthy/master/noteworthy.py 88 | python3 noteworthy.py 89 | ``` 90 | 91 | ### Quickstart 92 | 93 | Add the neccesary content for your project and run the build script. The setup wizard will guide you through configuration: 94 | 95 | ```bash 96 | python3 noteworthy.py 97 | ``` 98 | 99 | ### Advanced Usage 100 | 101 | You can force an update or switch branches using CLI flags: 102 | 103 | | Flag | Description | 104 | | ------------------------ | ----------------------------------------------------------------------------------------------------- | 105 | | `--load` | Force update/install from `master` branch. | 106 | | `--load-nightly` | Force update/install from `nightly` branch. | 107 | | `--force-update` | **Destructive**. Removes existing `noteworthy` and `templates` folders and reinstalls from `master`. | 108 | | `--force-update-nightly` | **Destructive**. Removes existing `noteworthy` and `templates` folders and reinstalls from `nightly`. | 109 | 110 | The noteworthy system guides you through the initialization, the configuration, and the build. Upon first run, the template will load the necessary template files. 111 | 112 |

113 | Setup Wizard Demo 114 |

115 | 116 | **TUI Features:** 117 | - **Chapter Selection**: Toggle individual chapters/sections to compile 118 | - **Options**: 119 | - `d` - Debug mode (verbose output) 120 | - `f` - Include/exclude frontmatter (cover, preface, outline) 121 | - `l` - Keep individual PDFs after merge 122 | - `c` - Configure custom Typst flags (e.g., `--font-path`) 123 | - `e` - Open configuration editors 124 | - **Editor Menu** (`e` key): 125 | - Config Editor - Document settings (title, authors, theme, preface content, etc.) 126 | - Hierarchy Editor - Chapter/page structure with add/delete 127 | - Scheme Editor - Color themes with create/delete 128 | - Snippets Editor - Custom macros 129 | - Ignored Files - Manage files excluded from indexing 130 | - **Controls**: Arrow keys to navigate, Space to toggle, Enter to build, `q` to quit 131 | - **Build Progress**: Real-time compilation status with Typst log toggle (`v`) 132 | - **Template Integrity Check**: Verify that the template files are not corrupted and auto fix 133 | - **Backup & Restore**: Export and Import configuration files individually 134 | 135 | #### Interface Preview 136 | 137 |

138 | Main Menu & Editor Selection
139 | 140 | 141 |

142 | 143 |

144 | Editors
145 | 146 | 147 |

148 | 149 |

150 | 151 | 152 |

153 | 154 |

155 | 156 | 157 |

158 | 159 |

160 | Build Process
161 | 162 | 163 |

164 | 165 | ### Single File Compilation 166 | 167 | ```bash 168 | # Compile specific section 169 | typst compile templates/parser.typ --input target=01.01 section.pdf 170 | ``` 171 | 172 | ## Contributing 173 | 174 | Contributions are welcome! To contribute: 175 | 176 | 1. Fork the repository 177 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 178 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 179 | 4. Push to the branch (`git push origin feature/amazing-feature`) 180 | 5. Open a Pull Request 181 | 182 | ## License 183 | 184 | MIT License - See [LICENSE](LICENSE) for details. 185 | 186 | ## Acknowledgments 187 | 188 | Built with: 189 | - [Typst](https://typst.app/) - The typesetting system 190 | - [CeTZ](https://github.com/cetz-package/cetz) - Drawing library 191 | - [CeTZ-Plot](https://github.com/cetz-package/cetz-plot) - Plotting extension 192 | 193 | ## Contact 194 | 195 | Created by [Sihoo Lee](https://github.com/sihooleebd) & [Hojun Lee](https://github.com/R0K0R) 196 | 197 | --- 198 | 199 | **Noteworthy** - *A framework for noteworthy educational documents.* 200 | -------------------------------------------------------------------------------- /templates/plots/vectorplot.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/cetz:0.4.2" 2 | 3 | /// Draws a vector arrow from start to end point with optional label. 4 | /// Supports both 2D (x, y) and 3D (x, y, z) coordinates. 5 | /// 6 | /// Parameters: 7 | /// - theme: Theme dictionary containing plot styling 8 | /// - start: Starting point coordinates 9 | /// - end: Ending point coordinates 10 | /// - label: Optional label content to display 11 | /// - label-pos: Position along vector for label (0 to 1, default: 0.5) 12 | /// - label-dist: Distance offset from vector line (default: 0.3) 13 | /// - label-anchor: Text anchor position (default: auto) 14 | /// - color: Vector color (default: theme.plot.stroke) 15 | /// - thickness: Line thickness (default: 1.5pt) 16 | /// - arrow-scale: Arrow head scale factor (default: 1) 17 | #let draw-vec( 18 | theme: (:), 19 | start, 20 | end, 21 | label: none, 22 | label-pos: 0.5, 23 | label-dist: 0.3, 24 | label-anchor: auto, 25 | color: auto, 26 | thickness: 1.5pt, 27 | arrow-scale: 1, 28 | ) = { 29 | import cetz.draw: * 30 | 31 | let stroke-col = if color == auto { theme.plot.stroke } else { color } 32 | let fill-col = if color == auto { theme.plot.stroke } else { color } 33 | 34 | group({ 35 | line( 36 | start, 37 | end, 38 | stroke: (paint: stroke-col, thickness: thickness), 39 | mark: (end: "stealth", scale: arrow-scale, fill: stroke-col), 40 | ) 41 | if label != none { 42 | let dx = end.at(0) - start.at(0) 43 | let dy = end.at(1) - start.at(1) 44 | let dz = if start.len() > 2 { end.at(2) - start.at(2) } else { 0 } 45 | 46 | let mx = start.at(0) + dx * label-pos 47 | let my = start.at(1) + dy * label-pos 48 | let mz = if start.len() > 2 { start.at(2) + dz * label-pos } else { 0 } 49 | 50 | let len-sq = dx * dx + dy * dy + dz * dz 51 | let len = calc.sqrt(len-sq) 52 | 53 | let ox = 0 54 | let oy = 0 55 | let oz = 0 56 | 57 | if len != 0 { 58 | // Calculate perpendicular offset for label positioning 59 | if start.len() == 2 { 60 | // 2D: Use perpendicular direction 61 | ox = (-dy / len) * label-dist 62 | oy = (dx / len) * label-dist 63 | } else { 64 | // 3D: Use small Z offset for visibility 65 | oz = label-dist 66 | } 67 | } 68 | 69 | let label-x = mx + ox 70 | let label-y = my + oy 71 | let label-z = mz + oz 72 | 73 | let txt-anchor = if label-anchor != auto { label-anchor } else { 74 | "center" 75 | } 76 | 77 | let pos = if start.len() > 2 { (label-x, label-y, label-z) } else { (label-x, label-y) } 78 | 79 | content( 80 | pos, 81 | text(fill: stroke-col, label), 82 | anchor: txt-anchor, 83 | ) 84 | } 85 | }) 86 | } 87 | 88 | 89 | /// Draws dashed lines showing the component breakdown of a vector. 90 | /// Displays X and Y components (and Z in 3D) of a vector from an origin. 91 | /// 92 | /// Parameters: 93 | /// - theme: Theme dictionary containing plot styling 94 | /// - origin: Starting point (default: (0, 0)) 95 | /// - vec: Vector components (x, y) or (x, y, z) 96 | /// - label-x: Optional label for X component 97 | /// - label-y: Optional label for Y component 98 | /// - color: Component line color (default: gray) 99 | #let draw-vec-comps( 100 | theme: (:), 101 | origin: (0, 0), 102 | vec, 103 | label-x: none, 104 | label-y: none, 105 | color: gray, 106 | ) = { 107 | import cetz.draw: * 108 | 109 | let is-3d = vec.len() > 2 110 | let vx = vec.at(0) 111 | let vy = vec.at(1) 112 | let vz = if is-3d { vec.at(2) } else { 0 } 113 | 114 | let ox = origin.at(0) 115 | let oy = origin.at(1) 116 | let oz = if origin.len() > 2 { origin.at(2) } else { 0 } 117 | 118 | let end-pt = if is-3d { (ox + vx, oy + vy, oz + vz) } else { (ox + vx, oy + vy) } 119 | 120 | group({ 121 | if is-3d { 122 | // Draw 3D projection box 123 | let xy-proj = (ox + vx, oy + vy, oz) 124 | line(origin, (ox + vx, oy, oz), stroke: (paint: color, dash: "dashed", thickness: 0.5pt)) 125 | line((ox + vx, oy, oz), xy-proj, stroke: (paint: color, dash: "dashed", thickness: 0.5pt)) 126 | line(origin, (ox, oy + vy, oz), stroke: (paint: color, dash: "dashed", thickness: 0.5pt)) 127 | line((ox, oy + vy, oz), xy-proj, stroke: (paint: color, dash: "dashed", thickness: 0.5pt)) 128 | 129 | // Vertical line to final point 130 | line(xy-proj, end-pt, stroke: (paint: color, dash: "dotted", thickness: 0.8pt)) 131 | } else { 132 | // 2D projection 133 | let x-proj = (end-pt.at(0), origin.at(1)) 134 | let y-proj = (origin.at(0), end-pt.at(1)) 135 | 136 | line(origin, x-proj, stroke: (paint: color, dash: "dashed", thickness: 0.8pt)) 137 | line(x-proj, end-pt, stroke: (paint: color, dash: "dotted", thickness: 0.8pt)) 138 | line(origin, y-proj, stroke: (paint: color, dash: "dashed", thickness: 0.8pt)) 139 | line(y-proj, end-pt, stroke: (paint: color, dash: "dotted", thickness: 0.8pt)) 140 | } 141 | 142 | if label-x != none { 143 | content((ox + vx / 2, oy, oz), text(fill: color, size: 8pt, label-x), anchor: "north", padding: 0.2) 144 | } 145 | if label-y != none { 146 | content((ox, oy + vy / 2, oz), text(fill: color, size: 8pt, label-y), anchor: "east", padding: 0.2) 147 | } 148 | }) 149 | } 150 | 151 | /// Draws vector addition visualization using parallelogram or tip-to-tail method. 152 | /// 153 | /// Parameters: 154 | /// - theme: Theme dictionary containing plot styling 155 | /// - origin: Starting point (default: (0, 0)) 156 | /// - u: First vector components 157 | /// - v: Second vector components 158 | /// - label-u: Label for vector u (default: $vec(u)$) 159 | /// - label-v: Label for vector v (default: $vec(v)$) 160 | /// - label-sum: Label for sum vector (default: $vec(u) + vec(v)$) 161 | /// - mode: Visualization mode: "parallelogram" or "tip-to-tail" (default: "parallelogram") 162 | #let draw-vec-sum( 163 | theme: (:), 164 | origin: (0, 0), 165 | u, 166 | v, 167 | label-u: $vec(u)$, 168 | label-v: $vec(v)$, 169 | label-sum: $vec(u) + vec(v)$, 170 | mode: "parallelogram", 171 | ) = { 172 | import cetz.draw: * 173 | let main-col = theme.plot.stroke 174 | let hl-col = theme.plot.highlight 175 | let sec-col = gray 176 | 177 | let ox = origin.at(0) 178 | let oy = origin.at(1) 179 | let oz = if origin.len() > 2 { origin.at(2) } else { 0 } 180 | 181 | let ux = u.at(0) 182 | let uy = u.at(1) 183 | let uz = if u.len() > 2 { u.at(2) } else { 0 } 184 | 185 | let vx = v.at(0) 186 | let vy = v.at(1) 187 | let vz = if v.len() > 2 { v.at(2) } else { 0 } 188 | 189 | let u-end = (ox + ux, oy + uy, oz + uz) 190 | let v-end = (ox + vx, oy + vy, oz + vz) 191 | let sum-end = (ox + ux + vx, oy + uy + vy, oz + uz + vz) 192 | 193 | draw-vec(theme: theme, origin, u-end, label: label-u, color: main-col) 194 | draw-vec(theme: theme, origin, v-end, label: label-v, color: main-col) 195 | 196 | if mode == "parallelogram" { 197 | draw-vec(theme: theme, u-end, sum-end, color: sec-col, thickness: 0.5pt, arrow-scale: 0) 198 | draw-vec(theme: theme, v-end, sum-end, color: sec-col, thickness: 0.5pt, arrow-scale: 0) 199 | draw-vec(theme: theme, origin, sum-end, label: label-sum, color: hl-col) 200 | } else { 201 | // Tip-to-tail 202 | draw-vec(theme: theme, u-end, sum-end, label: label-v, color: main-col) 203 | draw-vec(theme: theme, origin, sum-end, label: label-sum, color: hl-col) 204 | } 205 | } 206 | 207 | /// Draws vector projection visualization showing proj_b(a). 208 | /// 209 | /// Parameters: 210 | /// - theme: Theme dictionary containing plot styling 211 | /// - origin: Starting point (default: (0, 0)) 212 | /// - vec-a: Vector to be projected 213 | /// - vec-b: Vector to project onto 214 | /// - label-a: Label for vector a (default: $vec(a)$) 215 | /// - label-b: Label for vector b (default: $vec(b)$) 216 | /// - label-proj: Label for projection (default: $text("proj")_b a$) 217 | #let draw-vec-proj( 218 | theme: (:), 219 | origin: (0, 0), 220 | vec-a, 221 | vec-b, 222 | label-a: $vec(a)$, 223 | label-b: $vec(b)$, 224 | label-proj: $text("proj")_b a$, 225 | ) = { 226 | import cetz.draw: * 227 | 228 | let ox = origin.at(0) 229 | let oy = origin.at(1) 230 | let oz = if origin.len() > 2 { origin.at(2) } else { 0 } 231 | 232 | let ax = vec-a.at(0) 233 | let ay = vec-a.at(1) 234 | let az = if vec-a.len() > 2 { vec-a.at(2) } else { 0 } 235 | 236 | let bx = vec-b.at(0) 237 | let by = vec-b.at(1) 238 | let bz = if vec-b.len() > 2 { vec-b.at(2) } else { 0 } 239 | 240 | // Calculate projection: proj_b(a) = (a · b / |b|²) * b 241 | let dot = ax * bx + ay * by + az * bz 242 | let mag-b-sq = bx * bx + by * by + bz * bz 243 | let scale = if mag-b-sq != 0 { dot / mag-b-sq } else { 0 } 244 | 245 | let proj-x = bx * scale 246 | let proj-y = by * scale 247 | let proj-z = bz * scale 248 | 249 | let a-end = (ox + ax, oy + ay, oz + az) 250 | let b-end = (ox + bx, oy + by, oz + bz) 251 | let proj-end = (ox + proj-x, oy + proj-y, oz + proj-z) 252 | 253 | // Draw extended b axis 254 | line( 255 | (ox - bx * 0.2, oy - by * 0.2, oz - bz * 0.2), 256 | (ox + bx * 1.2, oy + by * 1.2, oz + bz * 1.2), 257 | stroke: (paint: gray, dash: "dashed"), 258 | ) 259 | 260 | draw-vec(theme: theme, origin, a-end, label: label-a, color: theme.plot.stroke) 261 | 262 | // Draw perpendicular from a to projection 263 | line( 264 | a-end, 265 | proj-end, 266 | stroke: (paint: theme.text-accent, dash: "dotted"), 267 | ) 268 | 269 | draw-vec( 270 | theme: theme, 271 | origin, 272 | proj-end, 273 | label: label-proj, 274 | color: theme.plot.highlight, 275 | thickness: 2pt, 276 | ) 277 | 278 | draw-vec(theme: theme, origin, b-end, label: label-b, color: theme.plot.stroke) 279 | } 280 | -------------------------------------------------------------------------------- /noteworthy/tui/editors/text.py: -------------------------------------------------------------------------------- 1 | import curses 2 | from pathlib import Path 3 | from ..base import BaseEditor, TUI 4 | from ..keybinds import KeyBind, NavigationBind, ConfirmBind 5 | from ...utils import register_key, handle_key_event 6 | 7 | class TextEditor(BaseEditor): 8 | 9 | def __init__(self, scr, filepath=None, initial_text=None, title='Text Editor'): 10 | super().__init__(scr, title) 11 | self.filepath = Path(filepath) if filepath else None 12 | if initial_text is not None: 13 | self.lines = initial_text.split('\n') 14 | elif self.filepath and self.filepath.exists(): 15 | self.lines = self.filepath.read_text().split('\n') 16 | else: 17 | self.lines = [''] 18 | self.cy, self.cx = (0, 0) 19 | self.scroll_y = 0 20 | self.preferred_x = 0 21 | 22 | register_key(self.keymap, NavigationBind('UP', self.move_up)) 23 | register_key(self.keymap, NavigationBind('DOWN', self.move_down)) 24 | register_key(self.keymap, NavigationBind('LEFT', self.move_left)) 25 | register_key(self.keymap, NavigationBind('RIGHT', self.move_right)) 26 | register_key(self.keymap, NavigationBind('HOME', self.move_home)) 27 | register_key(self.keymap, NavigationBind('END', self.move_end)) 28 | register_key(self.keymap, NavigationBind('PGUP', self.move_pgup)) 29 | register_key(self.keymap, NavigationBind('PGDN', self.move_pgdn)) 30 | 31 | register_key(self.keymap, KeyBind([curses.KEY_BACKSPACE, 127, 8], self.handle_backspace, "Backspace")) 32 | register_key(self.keymap, KeyBind([curses.KEY_DC], self.handle_delete, "Delete")) 33 | register_key(self.keymap, ConfirmBind(self.handle_enter)) 34 | register_key(self.keymap, KeyBind(9, self.handle_tab, "Tab")) 35 | register_key(self.keymap, KeyBind(24, self.do_export, "Export")) 36 | register_key(self.keymap, KeyBind(12, self.do_import, "Import")) 37 | 38 | register_key(self.keymap, KeyBind(27, self.do_exit_text, "Save & Exit")) 39 | 40 | def do_exit_text(self, ctx=None): 41 | curses.curs_set(0) 42 | if self.modified: 43 | self.save() 44 | return 'EXIT_WITH_CONTENT' 45 | 46 | def save(self): 47 | if self.filepath: 48 | try: 49 | self.filepath.write_text('\n'.join(self.lines)) 50 | self.modified = False 51 | return True 52 | except: 53 | return False 54 | return True 55 | 56 | def _load(self): 57 | if self.filepath and self.filepath.exists(): 58 | self.lines = self.filepath.read_text().split('\n') 59 | self.refresh() 60 | 61 | def refresh(self): 62 | h, w = TUI.get_dims(self.scr) 63 | self.scr.clear() 64 | 65 | title_str = f"{self.title}{(' *' if self.modified else '')}" 66 | _, tx = TUI.center(self.scr, content_w=len(title_str)) 67 | TUI.safe_addstr(self.scr, 0, tx, title_str, curses.color_pair(1) | curses.A_BOLD) 68 | 69 | visual_lines = self._get_visual_lines(w - 5) 70 | vcy = 0 71 | for i, (text, l_idx, start_idx) in enumerate(visual_lines): 72 | if l_idx == self.cy: 73 | is_last_chunk = True 74 | if i + 1 < len(visual_lines) and visual_lines[i + 1][1] == l_idx: 75 | is_last_chunk = False 76 | if self.cx >= start_idx and (self.cx < start_idx + len(text) or (self.cx == start_idx + len(text) and is_last_chunk)): 77 | vcy = i 78 | break 79 | if vcy < self.scroll_y: 80 | self.scroll_y = vcy 81 | elif vcy >= self.scroll_y + (h - 5): 82 | self.scroll_y = vcy - (h - 6) 83 | for i in range(h - 5): 84 | idx = self.scroll_y + i 85 | if idx >= len(visual_lines): 86 | break 87 | text, l_idx, start_idx = visual_lines[idx] 88 | y = i + 2 89 | if start_idx == 0: 90 | TUI.safe_addstr(self.scr, y, 0, f'{l_idx + 1:3d} ', curses.color_pair(4) | curses.A_DIM) 91 | else: 92 | TUI.safe_addstr(self.scr, y, 0, ' · ', curses.color_pair(4) | curses.A_DIM) 93 | TUI.safe_addstr(self.scr, y, 6, text) 94 | footer = 'Esc: Save & Exit ^X: Export ^L: Import' 95 | _, fx = TUI.center(self.scr, content_w=len(footer)) 96 | TUI.safe_addstr(self.scr, h - 1, fx, footer, curses.color_pair(4) | curses.A_DIM) 97 | 98 | curses.curs_set(1) 99 | cur_y = vcy - self.scroll_y + 3 100 | cur_x = 7 + (self.cx - visual_lines[vcy][2]) 101 | if 0 <= cur_y < h and 0 <= cur_x < w: 102 | self.scr.move(cur_y, cur_x) 103 | self.scr.refresh() 104 | 105 | def _get_visual_lines(self, width): 106 | visual_lines = [] 107 | for l_idx, line in enumerate(self.lines): 108 | if not line: 109 | visual_lines.append(('', l_idx, 0)) 110 | continue 111 | i = 0 112 | while i < len(line): 113 | chunk = line[i:i + width] 114 | if len(chunk) < width: 115 | visual_lines.append((chunk, l_idx, i)) 116 | i += len(chunk) 117 | else: 118 | last_space = chunk.rfind(' ') 119 | if last_space != -1: 120 | visual_lines.append((chunk[:last_space], l_idx, i)) 121 | i += last_space + 1 122 | else: 123 | visual_lines.append((chunk, l_idx, i)) 124 | i += width 125 | return visual_lines 126 | 127 | def run(self): 128 | TUI.disable_flow_control() 129 | self.refresh() 130 | while True: 131 | if not TUI.check_terminal_size(self.scr): 132 | return None 133 | k = self.scr.getch() 134 | 135 | handled, res = handle_key_event(k, self.keymap, self) 136 | if handled: 137 | if res == 'EXIT_WITH_CONTENT': 138 | return '\n'.join(self.lines) if not self.filepath else None 139 | elif 32 <= k <= 126: 140 | self.handle_char(k) 141 | 142 | self.refresh() 143 | 144 | def handle_char(self, k): 145 | self.lines[self.cy] = self.lines[self.cy][:self.cx] + chr(k) + self.lines[self.cy][self.cx:] 146 | self.cx += 1 147 | self.modified = True 148 | 149 | def handle_tab(self, ctx): 150 | self.lines[self.cy] = self.lines[self.cy][:self.cx] + ' ' + self.lines[self.cy][self.cx:] 151 | self.cx += 4 152 | self.modified = True 153 | 154 | def handle_enter(self, ctx): 155 | self.lines.insert(self.cy + 1, self.lines[self.cy][self.cx:]) 156 | self.lines[self.cy] = self.lines[self.cy][:self.cx] 157 | self.cy += 1 158 | self.cx = 0 159 | self.modified = True 160 | 161 | def handle_backspace(self, ctx): 162 | if self.cx > 0: 163 | self.lines[self.cy] = self.lines[self.cy][:self.cx - 1] + self.lines[self.cy][self.cx:] 164 | self.cx -= 1 165 | self.modified = True 166 | elif self.cy > 0: 167 | pl = len(self.lines[self.cy - 1]) 168 | self.lines[self.cy - 1] += self.lines[self.cy] 169 | del self.lines[self.cy] 170 | self.cy -= 1 171 | self.cx = pl 172 | self.modified = True 173 | 174 | def handle_delete(self, ctx): 175 | if self.cx < len(self.lines[self.cy]): 176 | self.lines[self.cy] = self.lines[self.cy][:self.cx] + self.lines[self.cy][self.cx + 1:] 177 | self.modified = True 178 | elif self.cy < len(self.lines) - 1: 179 | self.lines[self.cy] += self.lines[self.cy + 1] 180 | del self.lines[self.cy + 1] 181 | self.modified = True 182 | 183 | def _get_visual_info(self): 184 | visual_lines = self._get_visual_lines(self.scr.getmaxyx()[1] - 5) 185 | vcy = 0 186 | for i, (text, l_idx, start_idx) in enumerate(visual_lines): 187 | if l_idx == self.cy: 188 | is_last_chunk = True 189 | if i + 1 < len(visual_lines) and visual_lines[i + 1][1] == l_idx: 190 | is_last_chunk = False 191 | if self.cx >= start_idx and (self.cx < start_idx + len(text) or (self.cx == start_idx + len(text) and is_last_chunk)): 192 | vcy = i 193 | break 194 | return visual_lines, vcy 195 | 196 | def move_up(self, ctx): 197 | visual_lines, vcy = self._get_visual_info() 198 | if vcy > 0: 199 | target_vcy = vcy - 1 200 | t_text, t_lidx, t_start = visual_lines[target_vcy] 201 | curr_vx = self.cx - visual_lines[vcy][2] 202 | self.cy = t_lidx 203 | self.cx = t_start + min(curr_vx, len(t_text)) 204 | 205 | def move_down(self, ctx): 206 | visual_lines, vcy = self._get_visual_info() 207 | if vcy < len(visual_lines) - 1: 208 | target_vcy = vcy + 1 209 | t_text, t_lidx, t_start = visual_lines[target_vcy] 210 | curr_vx = self.cx - visual_lines[vcy][2] 211 | self.cy = t_lidx 212 | self.cx = t_start + min(curr_vx, len(t_text)) 213 | 214 | def move_pgup(self, ctx): 215 | h, _ = self.scr.getmaxyx() 216 | for _ in range(h - 5): self.move_up(ctx) 217 | 218 | def move_pgdn(self, ctx): 219 | h, _ = self.scr.getmaxyx() 220 | for _ in range(h - 5): self.move_down(ctx) 221 | 222 | def move_left(self, ctx): 223 | if self.cx > 0: 224 | self.cx -= 1 225 | elif self.cy > 0: 226 | self.cy -= 1 227 | self.cx = len(self.lines[self.cy]) 228 | 229 | def move_right(self, ctx): 230 | if self.cx < len(self.lines[self.cy]): 231 | self.cx += 1 232 | elif self.cy < len(self.lines) - 1: 233 | self.cy += 1 234 | self.cx = 0 235 | 236 | def move_home(self, ctx): 237 | self.cx = 0 238 | 239 | def move_end(self, ctx): 240 | self.cx = len(self.lines[self.cy]) -------------------------------------------------------------------------------- /noteworthy/tui/components/common.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import subprocess 3 | from ...config import SAD_FACE, HAPPY_FACE, HMM_FACE, OUTPUT_FILE 4 | from ...utils import register_key, handle_key_event 5 | from ..base import TUI 6 | from ..keybinds import KeyBind, ConfirmBind, NavigationBind 7 | 8 | class LineEditor: 9 | 10 | def __init__(self, scr, title='Edit', initial_value=''): 11 | self.scr = scr 12 | self.title = title 13 | self.value = initial_value 14 | self.cursor_pos = len(initial_value) 15 | 16 | self.keymap = {} 17 | register_key(self.keymap, KeyBind(27, self.action_cancel, "Cancel")) 18 | register_key(self.keymap, ConfirmBind(self.action_confirm)) 19 | register_key(self.keymap, KeyBind([curses.KEY_BACKSPACE, 127, 8], self.action_backspace, "Backspace")) 20 | register_key(self.keymap, KeyBind(curses.KEY_LEFT, self.action_left, "Left")) 21 | register_key(self.keymap, KeyBind(curses.KEY_RIGHT, self.action_right, "Right")) 22 | register_key(self.keymap, KeyBind([curses.KEY_DC, 330], self.action_delete, "Delete")) 23 | 24 | def action_cancel(self, ctx): 25 | return 'EXIT_CANCEL' 26 | 27 | def action_confirm(self, ctx): 28 | return 'EXIT_CONFIRM' 29 | 30 | def action_backspace(self, ctx): 31 | if self.cursor_pos > 0: 32 | self.value = self.value[:self.cursor_pos - 1] + self.value[self.cursor_pos:] 33 | self.cursor_pos -= 1 34 | 35 | def action_delete(self, ctx): 36 | if self.cursor_pos < len(self.value): 37 | self.value = self.value[:self.cursor_pos] + self.value[self.cursor_pos + 1:] 38 | 39 | def action_left(self, ctx): 40 | self.cursor_pos = max(0, self.cursor_pos - 1) 41 | 42 | def action_right(self, ctx): 43 | self.cursor_pos = min(len(self.value), self.cursor_pos + 1) 44 | 45 | def handle_char(self, char): 46 | self.value = self.value[:self.cursor_pos] + char + self.value[self.cursor_pos:] 47 | self.cursor_pos += 1 48 | return True 49 | 50 | def run(self): 51 | h_raw, w_raw = self.scr.getmaxyx() 52 | box_h = 7 53 | box_w = max(50, len(self.title) + 10, len(self.value) + 10) 54 | box_w = min(box_w, w_raw - 2) 55 | 56 | bh, bw = (14, min(40, w_raw - 4)) 57 | by, bx = TUI.center(self.scr, bh, bw) 58 | 59 | box_y = by 60 | box_x = bx 61 | 62 | curses.curs_set(1) 63 | 64 | scroll_off = 0 65 | 66 | while True: 67 | if not TUI.check_terminal_size(self.scr): 68 | return None 69 | 70 | TUI.draw_box(self.scr, box_y, box_x, box_h, box_w, self.title) 71 | TUI.safe_addstr(self.scr, box_y + 4, box_x + 2, 'Enter: Confirm Esc: Cancel', curses.color_pair(4) | curses.A_DIM) 72 | input_y = box_y + 2 73 | input_x = box_x + 2 74 | max_len = box_w - 4 75 | 76 | if self.cursor_pos < scroll_off: 77 | scroll_off = self.cursor_pos 78 | if self.cursor_pos >= scroll_off + max_len: 79 | scroll_off = self.cursor_pos - max_len + 1 80 | 81 | disp_val = self.value[scroll_off:scroll_off + max_len] 82 | 83 | TUI.safe_addstr(self.scr, input_y, input_x, ' ' * max_len, curses.color_pair(4)) 84 | TUI.safe_addstr(self.scr, input_y, input_x, disp_val, curses.color_pair(1) | curses.A_BOLD) 85 | 86 | real_cur_x = input_x + 1 + (self.cursor_pos - scroll_off) 87 | 88 | real_cur_x = min(real_cur_x, input_x + 1 + max_len) 89 | 90 | real_y = input_y + 1 91 | try: 92 | self.scr.move(real_y, real_cur_x) 93 | except: 94 | pass 95 | 96 | k = self.scr.getch() 97 | handled, res = handle_key_event(k, self.keymap, self) 98 | if handled: 99 | if res == 'EXIT_CANCEL': 100 | curses.curs_set(0) 101 | return None 102 | elif res == 'EXIT_CONFIRM': 103 | curses.curs_set(0) 104 | return self.value 105 | elif 32 <= k <= 126: 106 | self.handle_char(chr(k)) 107 | 108 | def copy_to_clipboard(text): 109 | try: 110 | subprocess.run(['pbcopy'], input=text.encode('utf-8'), check=True, stderr=subprocess.DEVNULL) 111 | return True 112 | except: 113 | pass 114 | try: 115 | subprocess.run(['clip'], input=text.encode('utf-16le'), check=True, stderr=subprocess.DEVNULL) 116 | return True 117 | except: 118 | pass 119 | try: 120 | subprocess.run(['wl-copy'], input=text.encode('utf-8'), check=True, stderr=subprocess.DEVNULL) 121 | return True 122 | except: 123 | pass 124 | try: 125 | subprocess.run(['xclip', '-selection', 'clipboard'], input=text.encode('utf-8'), check=True, stderr=subprocess.DEVNULL) 126 | return True 127 | except: 128 | pass 129 | try: 130 | subprocess.run(['xsel', '-b', '-i'], input=text.encode('utf-8'), check=True, stderr=subprocess.DEVNULL) 131 | return True 132 | except: 133 | pass 134 | return False 135 | 136 | class LogScreen: 137 | def __init__(self, scr, log, title_func, draw_func): 138 | self.scr = scr 139 | self.log = log 140 | self.title_func = title_func 141 | self.draw_func = draw_func 142 | self.view_log = False 143 | self.copied = False 144 | 145 | self.keymap = {} 146 | register_key(self.keymap, KeyBind(ord('v'), self.action_toggle_log, "View Log")) 147 | register_key(self.keymap, KeyBind(ord('c'), self.action_copy, "Copy Log")) 148 | register_key(self.keymap, KeyBind(27, self.action_esc, "Back/Exit")) 149 | register_key(self.keymap, KeyBind(None, self.action_any, "Exit")) 150 | 151 | def handle_key(self, k): 152 | if k == ord('v') or k == ord('c'): 153 | return handle_key_event(k, self.keymap, self) 154 | 155 | if not self.view_log: 156 | return True, 'EXIT' 157 | 158 | return handle_key_event(k, self.keymap, self) 159 | 160 | def action_toggle_log(self, ctx): 161 | self.view_log = not self.view_log 162 | self.copied = False 163 | 164 | def action_copy(self, ctx): 165 | if self.view_log: 166 | self.copied = copy_to_clipboard(self.log) 167 | 168 | def action_esc(self, ctx): 169 | if self.view_log: 170 | self.action_toggle_log(ctx) 171 | else: 172 | return 'EXIT' 173 | 174 | def action_any(self, ctx): 175 | if not self.view_log: return 'EXIT' 176 | 177 | def run(self): 178 | while True: 179 | if not TUI.check_terminal_size(self.scr): 180 | return 181 | 182 | self.scr.clear() 183 | h, w = TUI.get_dims(self.scr) 184 | 185 | if self.view_log: 186 | header = "LOG (press 'v' or 'Esc' to go back, 'c' to copy)" 187 | if self.copied: header = 'LOG (copied to clipboard!)' 188 | TUI.safe_addstr(self.scr, 0, 2, header, curses.color_pair(6) | curses.A_BOLD) 189 | for i, line in enumerate(self.log.split('\n')[:h-3]): 190 | TUI.safe_addstr(self.scr, i + 2, 2, line, curses.color_pair(4)) 191 | else: 192 | self.draw_func(self.scr, h, w) 193 | 194 | self.scr.refresh() 195 | k = self.scr.getch() 196 | if k == -1: continue 197 | handled, res = self.handle_key(k) 198 | if handled and res == 'EXIT': break 199 | 200 | def show_error_screen(scr, error): 201 | import traceback 202 | log = traceback.format_exc() 203 | if log.strip() == 'NoneType: None': log = str(error) 204 | 205 | def draw(s, h, w): 206 | face = SAD_FACE 207 | total_h = len(face) + 2 + 1 + 2 + 1 208 | 209 | cy, cx = TUI.center(s, content_h=total_h if total_h < h else h) 210 | y = cy + 1 211 | 212 | for i, line in enumerate(face): 213 | _, lx = TUI.center(s, content_w=len(line)) 214 | TUI.safe_addstr(s, y + i, lx, line, curses.color_pair(6) | curses.A_BOLD) 215 | 216 | my = y + len(face) + 2 217 | 218 | is_build_error = "Build failed" in str(error) or (isinstance(error, Exception) and getattr(error, 'is_build_error', False)) 219 | title = 'BUILD ERROR' if is_build_error else 'FATAL ERROR' 220 | _, tx = TUI.center(s, content_w=len(title)) 221 | TUI.safe_addstr(s, my, tx, title, curses.color_pair(6) | curses.A_BOLD) 222 | 223 | err = str(error)[:max(10, w - 10)] 224 | _, ex = TUI.center(s, content_w=len(err)) 225 | TUI.safe_addstr(s, my + 2, ex, err, curses.color_pair(4)) 226 | 227 | hint = "Press 'v' to view log | Esc: Exit" 228 | _, hx = TUI.center(s, content_w=len(hint)) 229 | TUI.safe_addstr(s, my + 4, hx, hint, curses.color_pair(4) | curses.A_DIM) 230 | 231 | LogScreen(scr, log, None, draw).run() 232 | 233 | def show_success_screen(scr, page_count, has_warnings=False, typst_logs=None): 234 | log = '\n'.join(typst_logs) if typst_logs else "" 235 | 236 | def draw(s, h, w): 237 | face = HMM_FACE if has_warnings else HAPPY_FACE 238 | color = curses.color_pair(3) if has_warnings else curses.color_pair(2) 239 | 240 | total_h = len(face) + 2 + 1 + 2 + 1 241 | cy, cx = TUI.center(s, content_h=total_h) 242 | y = cy + 1 243 | 244 | for i, line in enumerate(face): 245 | _, lx = TUI.center(s, content_w=len(line)) 246 | TUI.safe_addstr(s, y + i, lx, line, color | curses.A_BOLD) 247 | 248 | my = y + len(face) + 2 249 | title = 'BUILD SUCCEEDED (with warnings)' if has_warnings else 'BUILD SUCCEEDED!' 250 | _, tx = TUI.center(s, content_w=len(title)) 251 | TUI.safe_addstr(s, my, tx, title, color | curses.A_BOLD) 252 | 253 | msg = f'Created: {OUTPUT_FILE} ({page_count} pages)' 254 | _, mx = TUI.center(s, content_w=len(msg)) 255 | TUI.safe_addstr(s, my + 2, mx, msg, curses.color_pair(4)) 256 | 257 | hint = "Press 'v' to view log | Esc: Exit" if has_warnings else 'Press any key to exit...' 258 | _, hx = TUI.center(s, content_w=len(hint)) 259 | TUI.safe_addstr(s, my + 4, hx, hint, curses.color_pair(4) | curses.A_DIM) 260 | 261 | LogScreen(scr, log, None, draw).run() 262 | -------------------------------------------------------------------------------- /noteworthy/tui/editors/config.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import json 3 | from ..base import ListEditor, TUI 4 | from ..components.common import LineEditor 5 | from ...config import CONFIG_FILE, PREFACE_FILE 6 | from ...utils import load_config_safe, save_config, register_key 7 | from ..keybinds import ConfirmBind, ToggleBind, KeyBind 8 | from .schemes import extract_themes 9 | from .text import TextEditor 10 | 11 | class ConfigEditor(ListEditor): 12 | 13 | def __init__(self, scr): 14 | super().__init__(scr, 'General Settings') 15 | self.config = load_config_safe() 16 | self.filepath = CONFIG_FILE 17 | self.themes = extract_themes() 18 | self._build_items() 19 | self.box_title = 'Configuration' 20 | self.box_width = 80 21 | 22 | register_key(self.keymap, ConfirmBind(self.action_edit)) 23 | register_key(self.keymap, ToggleBind(self.action_toggle)) 24 | register_key(self.keymap, KeyBind(curses.KEY_RIGHT, self.action_next_value)) 25 | register_key(self.keymap, KeyBind(curses.KEY_LEFT, self.action_prev_value)) 26 | 27 | def _build_items(self): 28 | field_meta = { 29 | "title": ("Title", "str"), 30 | "subtitle": ("Subtitle", "str"), 31 | "authors": ("Authors", "list"), 32 | "affiliation": ("Affiliation", "str"), 33 | "font": ("Body Font", "str"), 34 | "title-font": ("Title Font", "str"), 35 | "show-solution": ("Show Solutions", "bool"), 36 | "display-cover": ("Display Cover", "bool"), 37 | "display-outline": ("Display Outline", "bool"), 38 | "display-chap-cover": ("Chapter Covers", "bool"), 39 | "chapter-name": ("Chapter Label", "str"), 40 | "subchap-name": ("Section Label", "str"), 41 | "box-margin": ("Box Margin", "str"), 42 | "box-inset": ("Box Inset", "str"), 43 | "render-sample-count": ("Render Samples", "int"), 44 | "render-implicit-count": ("Implicit Samples", "int"), 45 | "pad-chapter-id": ("Pad Chapter ID", "bool"), 46 | "pad-page-id": ("Pad Page ID", "bool"), 47 | "heading-numbering": ("Heading Numbering", "choice", ["1.1", "1.", "I.1", "A.1"]) 48 | } 49 | 50 | self.fields = [] 51 | processed_keys = set() 52 | 53 | for key, meta in field_meta.items(): 54 | if key in self.config: 55 | if len(meta) == 3: self.fields.append((key, meta[0], meta[1], meta[2])) 56 | else: self.fields.append((key, meta[0], meta[1])) 57 | processed_keys.add(key) 58 | 59 | for key, val in self.config.items(): 60 | if key not in processed_keys and key != 'display-mode': 61 | if isinstance(val, bool): ftype = "bool" 62 | elif isinstance(val, int): ftype = "int" 63 | elif isinstance(val, list): ftype = "list" 64 | else: ftype = "str" 65 | label = key.replace("-", " ").title() 66 | self.fields.append((key, label, ftype)) 67 | 68 | self.items = self.fields 69 | self.items.insert(0, ('Preface', 'Edit Preface Content...', 'action')) 70 | 71 | def save(self): 72 | try: 73 | save_config(self.config) 74 | return True 75 | except Exception as e: 76 | return False 77 | 78 | def _load(self): 79 | self.config = load_config_safe() 80 | self.themes = extract_themes() 81 | self._build_items() 82 | self.cursor = min(self.cursor, max(0, len(self.items) - 1)) 83 | 84 | def _draw_item(self, y, x, item, width, selected): 85 | key = item[0] 86 | if len(item) == 4: _, label, ftype, opts = item 87 | else: _, label, ftype = item 88 | 89 | left_w = 26 90 | 91 | if selected: 92 | TUI.safe_addstr(self.scr, y, x + 2, '>', curses.color_pair(3) | curses.A_BOLD) 93 | 94 | if key == 'Preface': 95 | TUI.safe_addstr(self.scr, y, x + 4, key, curses.color_pair(5 if selected else 4) | (curses.A_BOLD if selected else 0)) 96 | TUI.safe_addstr(self.scr, y, x + left_w, "│", curses.color_pair(4) | curses.A_DIM) 97 | TUI.safe_addstr(self.scr, y, x + left_w + 2, label, curses.color_pair(4) | (curses.A_BOLD if selected else 0)) 98 | return 99 | 100 | TUI.safe_addstr(self.scr, y, x + 4, label[:left_w - 6], curses.color_pair(5 if selected else 4) | (curses.A_BOLD if selected else 0)) 101 | 102 | TUI.safe_addstr(self.scr, y, x + left_w, "│", curses.color_pair(4) | curses.A_DIM) 103 | 104 | val = self.config.get(key) 105 | val_str = str(val) if val is not None else "(none)" 106 | 107 | color = curses.color_pair(4) 108 | if selected: color = color | curses.A_BOLD 109 | 110 | if ftype == 'bool': 111 | val_str = 'Yes' if val else 'No' 112 | color = curses.color_pair(2 if val else 6) 113 | if selected: color = color | curses.A_BOLD 114 | elif ftype == 'list': 115 | val_str = ', '.join(val) if isinstance(val, list) else str(val) 116 | elif ftype == 'choice': 117 | if selected: color = curses.color_pair(5) | curses.A_BOLD 118 | else: color = curses.color_pair(4) 119 | 120 | TUI.safe_addstr(self.scr, y, x + left_w + 2, val_str[:width - left_w - 4], color) 121 | 122 | def refresh(self): 123 | h, w = TUI.get_dims(self.scr) 124 | self.scr.clear() 125 | 126 | list_h = min(len(self.items) + 2, h - 8) 127 | total_h = 2 + list_h + 2 128 | 129 | cy, cx = TUI.center(self.scr, total_h, self.box_width) 130 | start_y = cy + 1 131 | 132 | title_str = f"{self.title}{' *' if self.modified else ''}" 133 | ty, tx = TUI.center(self.scr, content_w=len(title_str)) 134 | TUI.safe_addstr(self.scr, start_y, tx, title_str, curses.color_pair(1) | curses.A_BOLD) 135 | 136 | bw = min(self.box_width, w - 2) 137 | _, bx = TUI.center(self.scr, content_w=bw) 138 | left_w = 26 139 | 140 | TUI.draw_box(self.scr, start_y + 2, bx, list_h, bw, self.box_title) 141 | 142 | TUI.safe_addstr(self.scr, start_y + 3, bx + 4, "Setting", curses.color_pair(1) | curses.A_BOLD) 143 | TUI.safe_addstr(self.scr, start_y + 3, bx + left_w + 2, "Value", curses.color_pair(1) | curses.A_BOLD) 144 | TUI.safe_addstr(self.scr, start_y + 3, bx + left_w, "│", curses.color_pair(4) | curses.A_DIM) 145 | 146 | vis = list_h - 3 147 | 148 | if self.cursor < self.scroll: self.scroll = self.cursor 149 | elif self.cursor >= self.scroll + vis: self.scroll = self.cursor - vis + 1 150 | 151 | for i in range(vis): 152 | idx = self.scroll + i 153 | if idx >= len(self.items): break 154 | y = start_y + 4 + i 155 | self._draw_item(y, bx, self.items[idx], bw, idx == self.cursor) 156 | 157 | self._draw_footer(h, w) 158 | self.scr.refresh() 159 | 160 | def _draw_footer(self, h, w): 161 | footer = 'Enter:Edit Space:Toggle Esc:Save x:Export l:Import' 162 | TUI.safe_addstr(self.scr, h - 3, (w - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 163 | 164 | def action_edit(self, ctx): 165 | item = self.items[self.cursor] 166 | key = item[0] 167 | if len(item) == 4: _, label, ftype, opts = item 168 | else: _, label, ftype = item 169 | 170 | if key == 'Preface': 171 | editor = TextEditor(self.scr, filepath=PREFACE_FILE, title='Preface Editor') 172 | editor.run() 173 | elif ftype == 'choice': 174 | val = self.config.get(key, opts[0]) 175 | try: idx = opts.index(val) 176 | except: idx = 0 177 | idx = (idx + 1) % len(opts) 178 | self.config[key] = opts[idx] 179 | self.modified = True 180 | elif ftype == 'bool': 181 | self.config[key] = not self.config.get(key, False) 182 | self.modified = True 183 | elif ftype == 'list': 184 | val = self.config.get(key, []) 185 | curr = ', '.join(val) 186 | new_val = LineEditor(self.scr, initial_value=curr, title=f'Edit {label}').run() 187 | if new_val is not None: 188 | self.config[key] = [s.strip() for s in new_val.split(',') if s.strip()] 189 | self.modified = True 190 | else: 191 | val = self.config.get(key) 192 | init_val = str(val) if val is not None else "" 193 | 194 | new_val = LineEditor(self.scr, initial_value=init_val, title=f'Edit {label}').run() 195 | 196 | if new_val is not None: 197 | if ftype == 'int': 198 | try: 199 | if not new_val: self.config[key] = None 200 | else: self.config[key] = int(new_val) 201 | except ValueError: 202 | pass 203 | else: 204 | if not new_val: 205 | self.config[key] = None 206 | else: 207 | self.config[key] = new_val 208 | self.modified = True 209 | 210 | def action_toggle(self, ctx): 211 | item = self.items[self.cursor] 212 | key = item[0] 213 | if len(item) == 4: _, label, ftype, opts = item 214 | else: _, label, ftype = item 215 | 216 | if ftype == 'bool': 217 | self.config[key] = not self.config.get(key, False) 218 | self.modified = True 219 | elif ftype == 'choice': 220 | val = self.config.get(key, opts[0]) 221 | try: idx = opts.index(val) 222 | except: idx = 0 223 | idx = (idx + 1) % len(opts) 224 | self.config[key] = opts[idx] 225 | self.modified = True 226 | 227 | def action_next_value(self, ctx): 228 | item = self.items[self.cursor] 229 | key = item[0] 230 | if len(item) == 4: _, label, ftype, opts = item 231 | else: _, label, ftype = item 232 | 233 | if ftype == 'choice': 234 | val = self.config.get(key, opts[0]) 235 | try: idx = opts.index(val) 236 | except: idx = 0 237 | idx = (idx + 1) % len(opts) 238 | self.config[key] = opts[idx] 239 | self.modified = True 240 | 241 | def action_prev_value(self, ctx): 242 | item = self.items[self.cursor] 243 | key = item[0] 244 | if len(item) == 4: _, label, ftype, opts = item 245 | else: _, label, ftype = item 246 | 247 | if ftype == 'choice': 248 | val = self.config.get(key, opts[0]) 249 | try: idx = opts.index(val) 250 | except: idx = 0 251 | idx = (idx - 1 + len(opts)) % len(opts) 252 | self.config[key] = opts[idx] 253 | self.modified = True -------------------------------------------------------------------------------- /noteworthy/tui/editors/hierarchy.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import json 3 | from ..base import ListEditor, TUI 4 | from ..components.common import LineEditor 5 | from ..keybinds import ConfirmBind, KeyBind 6 | from ...config import HIERARCHY_FILE 7 | from ...utils import load_config_safe, register_key 8 | 9 | class HierarchyEditor(ListEditor): 10 | def __init__(self, scr): 11 | super().__init__(scr, "Chapter Structure") 12 | self.hierarchy = json.loads(HIERARCHY_FILE.read_text()) 13 | self.config = load_config_safe() 14 | self.filepath = HIERARCHY_FILE 15 | self._build_items() 16 | self.box_title = "Hierarchy" 17 | self.box_width = 75 18 | 19 | register_key(self.keymap, ConfirmBind(self.action_edit)) 20 | register_key(self.keymap, KeyBind(ord('d'), self.action_delete, "Delete Item")) 21 | 22 | def _build_items(self): 23 | self.items = [] 24 | for ci, ch in enumerate(self.hierarchy): 25 | self.items.append(("ch_title", ci, None, ch)) 26 | self.items.append(("ch_number", ci, None, ch)) 27 | self.items.append(("ch_summary", ci, None, ch)) 28 | for pi, p in enumerate(ch.get("pages", [])): 29 | self.items.append(("pg_title", ci, pi, p)) 30 | self.items.append(("pg_number", ci, pi, p)) 31 | self.items.append(("add_page", ci, None, None)) 32 | self.items.append(("add_chapter", None, None, None)) 33 | 34 | def _get_value(self, item): 35 | t, ci, pi, _ = item 36 | if t == "ch_title": return self.hierarchy[ci]["title"] 37 | elif t == "ch_number": return self.hierarchy[ci].get("number", "") 38 | elif t == "ch_summary": return self.hierarchy[ci]["summary"] 39 | elif t == "pg_title": return self.hierarchy[ci]["pages"][pi]["title"] 40 | elif t == "pg_number": return self.hierarchy[ci]["pages"][pi].get("number", "") 41 | return "" 42 | 43 | def _set_value(self, val): 44 | t, ci, pi, _ = self.items[self.cursor] 45 | val = val.strip() 46 | 47 | if t in ("ch_number", "pg_number"): 48 | if not val: 49 | if t == "ch_number": self.hierarchy[ci].pop("number", None) 50 | else: self.hierarchy[ci]["pages"][pi].pop("number", None) 51 | else: 52 | try: real_val = int(val) 53 | except: real_val = val 54 | 55 | if t == "ch_number": self.hierarchy[ci]["number"] = real_val 56 | else: self.hierarchy[ci]["pages"][pi]["number"] = real_val 57 | 58 | elif t == "ch_title": self.hierarchy[ci]["title"] = val 59 | elif t == "ch_summary": self.hierarchy[ci]["summary"] = val 60 | elif t == "pg_title": self.hierarchy[ci]["pages"][pi]["title"] = val 61 | 62 | self.modified = True; self._build_items() 63 | 64 | def _add_chapter(self): 65 | new_ch = {"title": "New Chapter", "summary": "", "pages": []} 66 | self.hierarchy.append(new_ch) 67 | self.modified = True 68 | self._build_items() 69 | for i, item in enumerate(self.items): 70 | if item[0] == "ch_title" and item[1] == len(self.hierarchy) - 1: 71 | self.cursor = i; break 72 | 73 | def _add_page(self, ci): 74 | new_page = {"title": "New Page"} 75 | self.hierarchy[ci]["pages"].append(new_page) 76 | self.modified = True 77 | self._build_items() 78 | 79 | def _delete_current(self): 80 | t, ci, pi, _ = self.items[self.cursor] 81 | if t in ("ch_title", "ch_summary", "ch_number"): 82 | if len(self.hierarchy) > 1: 83 | del self.hierarchy[ci] 84 | self.modified = True 85 | self._build_items() 86 | self.cursor = min(self.cursor, len(self.items) - 1) 87 | elif t in ("pg_title", "pg_number"): 88 | del self.hierarchy[ci]["pages"][pi] 89 | self.modified = True 90 | self._build_items() 91 | self.cursor = min(self.cursor, len(self.items) - 1) 92 | 93 | def _load(self): 94 | self.hierarchy = json.loads(HIERARCHY_FILE.read_text()) 95 | self._build_items() 96 | 97 | def save(self): 98 | try: 99 | HIERARCHY_FILE.write_text(json.dumps(self.hierarchy, indent=4)) 100 | self.modified = False 101 | 102 | from ...core.fs_sync import ensure_content_structure, cleanup_extra_files 103 | ensure_content_structure(self.hierarchy) 104 | cleanup_extra_files(self.hierarchy) 105 | 106 | return True 107 | except: return False 108 | 109 | def refresh(self): 110 | h, w = TUI.get_dims(self.scr) 111 | self.scr.clear() 112 | 113 | list_h = min(len(self.items) + 2, h - 8) 114 | total_h = 2 + list_h + 2 115 | 116 | cy, cx = TUI.center(self.scr, total_h, self.box_width) 117 | start_y = cy + 1 118 | 119 | title_str = f"{self.title}{' *' if self.modified else ''}" 120 | ty, tx = TUI.center(self.scr, content_w=len(title_str)) 121 | TUI.safe_addstr(self.scr, start_y, tx, title_str, curses.color_pair(1) | curses.A_BOLD) 122 | 123 | bw = min(self.box_width, w - 4) 124 | _, bx = TUI.center(self.scr, content_w=bw) 125 | left_w = 30 126 | 127 | TUI.draw_box(self.scr, start_y + 2, bx, list_h, bw, self.box_title) 128 | 129 | TUI.safe_addstr(self.scr, start_y + 3, bx + 4, "Item", curses.color_pair(1) | curses.A_BOLD) 130 | TUI.safe_addstr(self.scr, start_y + 3, bx + left_w + 2, "Value", curses.color_pair(1) | curses.A_BOLD) 131 | 132 | for i in range(1, list_h - 1): 133 | TUI.safe_addstr(self.scr, start_y + 2 + i, bx + left_w, "│", curses.color_pair(4) | curses.A_DIM) 134 | 135 | vis = list_h - 3 136 | if self.cursor < self.scroll: self.scroll = self.cursor 137 | elif self.cursor >= self.scroll + vis: self.scroll = self.cursor - vis + 1 138 | 139 | for i in range(vis): 140 | idx = self.scroll + i 141 | if idx >= len(self.items): break 142 | y = start_y + 4 + i 143 | self._draw_item(y, bx, self.items[idx], bw, idx == self.cursor) 144 | 145 | self._draw_footer(h, w) 146 | self.scr.refresh() 147 | 148 | def _draw_item(self, y, x, item, width, selected): 149 | t, ci, pi, _ = item 150 | left_w = 30 151 | 152 | if selected: TUI.safe_addstr(self.scr, y, x + 2, ">", curses.color_pair(3) | curses.A_BOLD) 153 | 154 | val_x = x + left_w + 2 155 | 156 | if t == "ch_title": 157 | ch_count = len(self.hierarchy) 158 | width_digits = 3 if ch_count >= 100 else 2 159 | explicit_num = self.hierarchy[ci].get("number") 160 | ch_num = str(explicit_num) if explicit_num is not None else str(ci + 1) 161 | 162 | if self.config.get("pad-chapter-id", True) and explicit_num is None: 163 | ch_num = ch_num.zfill(width_digits) 164 | 165 | label = self.config.get("chapter-name", "Chapter") 166 | label_disp = f"{label} {ch_num}" 167 | 168 | TUI.safe_addstr(self.scr, y, x + 4, label_disp[:left_w-6], curses.color_pair(5 if selected else 4) | (curses.A_BOLD if selected else 0)) 169 | 170 | val = str(self._get_value(item)) 171 | TUI.safe_addstr(self.scr, y, val_x, val[:width-left_w-6], curses.color_pair(4) | (curses.A_BOLD if selected else 0)) 172 | 173 | elif t == "ch_number": 174 | TUI.safe_addstr(self.scr, y, x + 6, "Number", curses.color_pair(5 if selected else 4) | (curses.A_BOLD if selected else 0)) 175 | val = str(self._get_value(item)) 176 | if not val: val = "(auto)" 177 | TUI.safe_addstr(self.scr, y, val_x, val[:width-left_w-6], curses.color_pair(4) | (curses.A_BOLD if selected else curses.A_DIM)) 178 | 179 | elif t == "ch_summary": 180 | TUI.safe_addstr(self.scr, y, x + 6, "Summary", curses.color_pair(5 if selected else 4) | (curses.A_BOLD if selected else 0)) 181 | val = str(self._get_value(item)) 182 | TUI.safe_addstr(self.scr, y, val_x, val[:width-left_w-6], curses.color_pair(4) | (curses.A_BOLD if selected else 0)) 183 | 184 | elif t == "pg_title": 185 | TUI.safe_addstr(self.scr, y, x + 6, "Page Title", curses.color_pair(5 if selected else 4) | (curses.A_BOLD if selected else 0)) 186 | val = str(self._get_value(item)) 187 | TUI.safe_addstr(self.scr, y, val_x, val[:width-left_w-6], curses.color_pair(4) | (curses.A_BOLD if selected else 0)) 188 | 189 | elif t == "pg_number": 190 | TUI.safe_addstr(self.scr, y, x + 8, "Page Num", curses.color_pair(5 if selected else 4) | (curses.A_BOLD if selected else 0)) 191 | val = str(self._get_value(item)) 192 | if not val: val = "(auto)" 193 | TUI.safe_addstr(self.scr, y, val_x, val[:width-left_w-6], curses.color_pair(4) | (curses.A_BOLD if selected else curses.A_DIM)) 194 | 195 | elif t == "add_page": 196 | TUI.safe_addstr(self.scr, y, x + 6, "+ Add page...", curses.color_pair(3 if selected else 4) | (curses.A_BOLD if selected else curses.A_DIM)) 197 | 198 | elif t == "add_chapter": 199 | TUI.safe_addstr(self.scr, y, x + 4, "+ Add chapter...", curses.color_pair(3 if selected else 4) | (curses.A_BOLD if selected else curses.A_DIM)) 200 | 201 | def _draw_footer(self, h, w): 202 | footer = "Enter: Edit d: Delete Esc: Save & Exit x: Export l: Import" 203 | TUI.safe_addstr(self.scr, h - 3, (w - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 204 | 205 | def action_edit(self, ctx): 206 | item = self.items[self.cursor]; t, ci, pi, _ = item 207 | if t == "add_chapter": self._add_chapter() 208 | elif t == "add_page": self._add_page(ci) 209 | else: 210 | curr_val = str(self._get_value(item)) 211 | if t == "ch_summary": 212 | from .text import TextEditor 213 | new_val = TextEditor(self.scr, initial_text=curr_val, title="Edit Summary").run() 214 | if new_val is not None: self._set_value(new_val) 215 | else: 216 | new_val = LineEditor(self.scr, initial_value=curr_val, title="Edit Value").run() 217 | if new_val is not None: self._set_value(new_val) 218 | 219 | def action_delete(self, ctx): 220 | item = self.items[self.cursor]; t = item[0] 221 | if t not in ("add_chapter", "add_page"): 222 | msg = "Delete item?" 223 | if t.startswith("ch_"): msg = "Delete ENTIRE Chapter? (y/n): " 224 | elif t.startswith("pg_"): msg = "Delete Page? (y/n): " 225 | 226 | if TUI.prompt_confirm(self.scr, msg): 227 | self._delete_current() -------------------------------------------------------------------------------- /noteworthy/tui/wizards/init.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import json 3 | from ..base import TUI 4 | from ...config import CONFIG_FILE, LOGO 5 | from ...config import CONFIG_FILE, LOGO 6 | from ...utils import load_config_safe, register_key, handle_key_event 7 | from ..editors.schemes import extract_themes 8 | from ..keybinds import KeyBind, NavigationBind, ConfirmBind 9 | 10 | class InitWizard: 11 | 12 | def __init__(self, scr): 13 | self.scr = scr 14 | themes = extract_themes() 15 | self.config = {'title': '', 'subtitle': '', 'authors': [], 'affiliation': '', 'logo': None, 'show-solution': True, 'solutions-text': 'Solutions', 'problems-text': 'Problems', 'chapter-name': 'Chapter', 'subchap-name': 'Section', 'font': 'IBM Plex Serif', 'title-font': 'Noto Sans Adlam', 'display-cover': True, 'display-outline': True, 'display-chap-cover': True, 'box-margin': '5pt', 'box-inset': '15pt', 'render-sample-count': 5000, 'render-implicit-count': 100, 'display-mode': 'rose-pine', 'pad-chapter-id': True, 'pad-page-id': True, 'heading-numbering': None} 16 | self.steps = [('title', 'Document Title', 'Enter the main title of your document:', 'str'), ('subtitle', 'Subtitle', 'Enter a subtitle (optional, press Enter to skip):', 'str'), ('authors', 'Authors', 'Enter author names (comma-separated):', 'list'), ('affiliation', 'Affiliation', 'Enter your organization/affiliation:', 'str'), ('display-mode', 'Color Theme', 'Use ←/→ to select, Enter to confirm:', 'choice', themes), ('font', 'Body Font', 'Enter body font name:', 'str'), ('title-font', 'Title Font', 'Enter title font name:', 'str'), ('chapter-name', 'Chapter Label', "What to call chapters (e.g., 'Chapter', 'Unit'):", 'str'), ('subchap-name', 'Section Label', "What to call sections (e.g., 'Section', 'Lesson'):", 'str')] 17 | self.current_step = 0 18 | self.choice_index = 0 19 | self.input_y = 0 20 | self.input_x = 0 21 | input_w = 50 22 | TUI.init_colors() 23 | 24 | self.keymap = {} 25 | 26 | register_key(self.keymap, KeyBind(27, self.action_cancel, "Cancel")) 27 | register_key(self.keymap, KeyBind([curses.KEY_BACKSPACE, 127, 8], self.action_prev, "Previous Step")) 28 | register_key(self.keymap, ConfirmBind(self.action_next)) 29 | register_key(self.keymap, NavigationBind('LEFT', self.action_choice_left)) 30 | register_key(self.keymap, NavigationBind('RIGHT', self.action_choice_right)) 31 | 32 | def action_cancel(self, ctx): 33 | return 'EXIT' 34 | 35 | def action_prev(self, ctx): 36 | if self.current_step > 0: 37 | self.current_step -= 1 38 | if self.steps[self.current_step][3] == 'choice': 39 | choices = self.steps[self.current_step][4] 40 | curr = self.config.get(self.steps[self.current_step][0], choices[0]) 41 | self.choice_index = choices.index(curr) if curr in choices else 0 42 | 43 | def action_choice_left(self, ctx): 44 | step = self.steps[self.current_step]; stype = step[3] 45 | if stype == 'choice': 46 | choices = step[4] 47 | self.choice_index = (self.choice_index - 1) % len(choices) 48 | 49 | def action_choice_right(self, ctx): 50 | step = self.steps[self.current_step]; stype = step[3] 51 | if stype == 'choice': 52 | choices = step[4] 53 | self.choice_index = (self.choice_index + 1) % len(choices) 54 | 55 | def action_next(self, ctx): 56 | step = self.steps[self.current_step] 57 | key, stype = (step[0], step[3]) 58 | 59 | if stype == 'choice': 60 | choices = step[4] 61 | self.config[key] = choices[self.choice_index] 62 | self.current_step += 1 63 | self.choice_index = 0 64 | else: 65 | value = self.get_input() 66 | if value or key != 'title': 67 | if stype == 'list': 68 | self.config[key] = [s.strip() for s in value.split(',') if s.strip()] if value else [] 69 | else: 70 | self.config[key] = value if value else self.config.get(key, '') 71 | self.current_step += 1 72 | elif not value and key == 'title': 73 | h, w = self.scr.getmaxyx() 74 | TUI.safe_addstr(self.scr, h - 2, (w - 20) // 2, 'Title is required!', curses.color_pair(6) | curses.A_BOLD) 75 | self.scr.refresh() 76 | curses.napms(1000) 77 | 78 | def refresh(self): 79 | h_raw, w_raw = self.scr.getmaxyx() 80 | h, w = (h_raw - 2, w_raw - 2) 81 | self.scr.clear() 82 | layout = 'vert' 83 | if w > 100: 84 | layout = 'horz' 85 | if layout == 'horz': 86 | lh = len(LOGO) 87 | ly = max(0, (h - lh) // 2) 88 | lx = max(0, (w - 16 - 60) // 2) 89 | for i, line in enumerate(LOGO): 90 | if ly + i < h: 91 | TUI.safe_addstr(self.scr, ly + i, lx, line, curses.color_pair(1) | curses.A_BOLD) 92 | sy = ly + lh + 2 93 | footer_y = h - 3 94 | 95 | # Use columns if list is too long for vertical space? 96 | # Or scroll the list if current step is out of view 97 | max_steps_visible = footer_y - 1 - sy 98 | if max_steps_visible < 1: max_steps_visible = 1 99 | 100 | start_step = 0 101 | if self.current_step >= max_steps_visible: 102 | start_step = self.current_step - max_steps_visible + 1 103 | 104 | for i in range(min(len(self.steps) - start_step, max_steps_visible)): 105 | step_idx = start_step + i 106 | step = self.steps[step_idx] 107 | y_pos = sy + i 108 | 109 | marker = '>' if step_idx == self.current_step else ' ' 110 | style = curses.color_pair(3) | curses.A_BOLD if step_idx == self.current_step else curses.color_pair(4) 111 | 112 | if y_pos < footer_y: 113 | TUI.safe_addstr(self.scr, y_pos, lx + 2, f'{marker} {step[1]}', style) 114 | 115 | dx = lx + 20 + 4 116 | dw = 55 117 | dy = max(0, (h - 16) // 2) 118 | TUI.draw_box(self.scr, dy, dx, 16, dw, 'Setup Wizard') 119 | step = self.steps[self.current_step] 120 | key, label, prompt, stype = (step[0], step[1], step[2], step[3]) 121 | TUI.safe_addstr(self.scr, dy + 2, dx + 2, f'Step {self.current_step + 1}/{len(self.steps)}: {label}', curses.color_pair(1) | curses.A_BOLD) 122 | TUI.safe_addstr(self.scr, dy + 4, dx + 2, prompt[:dw - 4], curses.color_pair(4)) 123 | if stype == 'choice': 124 | choices = step[4] 125 | choice_text = f'◀ {choices[self.choice_index]} ▶' 126 | TUI.safe_addstr(self.scr, dy + 7, dx + (dw - len(choice_text)) // 2, choice_text, curses.color_pair(5) | curses.A_BOLD) 127 | dots = ''.join(('●' if i == self.choice_index else '○' for i in range(len(choices)))) 128 | TUI.safe_addstr(self.scr, dy + 8, dx + (dw - len(dots)) // 2, dots, curses.color_pair(4) | curses.A_DIM) 129 | else: 130 | curr_val = self.config.get(key, '') 131 | if isinstance(curr_val, list): 132 | curr_val = ', '.join(curr_val) 133 | if curr_val: 134 | TUI.safe_addstr(self.scr, dy + 7, dx + 2, f'Default: {str(curr_val)[:dw - 12]}', curses.color_pair(4) | curses.A_DIM) 135 | self.input_y, self.input_x, self.input_w = (dy + 10, dx + 2, dw - 4) 136 | footer = 'Enter:Next Back:Prev Esc:Cancel' 137 | TUI.safe_addstr(self.scr, h - 3, (w - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 138 | else: 139 | total_h = 16 140 | start_y = max(1, (h - total_h) // 2) 141 | TUI.safe_addstr(self.scr, start_y, (w - 22) // 2, 'NOTEWORTHY SETUP WIZARD', curses.color_pair(1) | curses.A_BOLD) 142 | TUI.safe_addstr(self.scr, start_y + 1, (w - 40) // 2, "Let's set up your document configuration", curses.color_pair(4) | curses.A_DIM) 143 | prog = f'Step {self.current_step + 1} of {len(self.steps)}' 144 | TUI.safe_addstr(self.scr, start_y + 3, (w - len(prog)) // 2, prog, curses.color_pair(5)) 145 | step = self.steps[self.current_step] 146 | key, label, prompt, stype = (step[0], step[1], step[2], step[3]) 147 | bw = min(60, w - 4) 148 | bx = (w - bw) // 2 149 | TUI.draw_box(self.scr, start_y + 5, bx, 7, bw, label) 150 | TUI.safe_addstr(self.scr, start_y + 6, bx + 2, prompt[:bw - 4], curses.color_pair(4)) 151 | if stype == 'choice': 152 | choices = step[4] 153 | choice_text = f'◀ {choices[self.choice_index]} ▶' 154 | TUI.safe_addstr(self.scr, start_y + 8, (w - len(choice_text)) // 2, choice_text, curses.color_pair(5) | curses.A_BOLD) 155 | dots = ''.join(('●' if i == self.choice_index else '○' for i in range(len(choices)))) 156 | TUI.safe_addstr(self.scr, start_y + 9, (w - len(dots)) // 2, dots, curses.color_pair(4) | curses.A_DIM) 157 | footer = '←→:Select Enter:Confirm Backspace:Back Esc:Cancel' 158 | else: 159 | curr_val = self.config.get(key, '') 160 | if isinstance(curr_val, list): 161 | curr_val = ', '.join(curr_val) 162 | if curr_val: 163 | TUI.safe_addstr(self.scr, start_y + 8, bx + 2, f'Default: {str(curr_val)[:bw - 12]}', curses.color_pair(4) | curses.A_DIM) 164 | footer = 'Enter:Input Backspace:Back Esc:Cancel' 165 | self.input_y, self.input_x, self.input_w = (start_y + 10, bx + 2, bw - 4) 166 | TUI.safe_addstr(self.scr, h - 3, (w - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 167 | self.scr.refresh() 168 | 169 | def get_input(self): 170 | curses.echo() 171 | curses.curs_set(1) 172 | y, x = (self.input_y, self.input_x) 173 | try: 174 | real_y = y + 1 175 | real_x = x + 1 176 | TUI.safe_addstr(self.scr, y, x, ' ' * self.input_w) 177 | TUI.safe_addstr(self.scr, y, x, '> ', curses.color_pair(3) | curses.A_BOLD) 178 | self.scr.refresh() 179 | value = self.scr.getstr(real_y, real_x + 2, self.input_w - 6).decode('utf-8').strip() 180 | except: 181 | value = '' 182 | curses.noecho() 183 | curses.curs_set(0) 184 | return value 185 | 186 | def run(self): 187 | while True: 188 | if not TUI.check_terminal_size(self.scr): 189 | return None 190 | h, w = self.scr.getmaxyx() 191 | self.scr.clear() 192 | bh, bw = (8, min(60, w - 4)) 193 | bx, by = ((w - bw) // 2, (h - bh) // 2) 194 | TUI.draw_box(self.scr, by, bx, bh, bw, ' Welcome ') 195 | TUI.safe_addstr(self.scr, by + 2, bx + 2, 'welcome to noteworthy!', curses.color_pair(1) | curses.A_BOLD) 196 | TUI.safe_addstr(self.scr, by + 3, bx + 2, 'We will guide you to initializing your project.', curses.color_pair(4)) 197 | footer = 'Press Enter to begin...' 198 | TUI.safe_addstr(self.scr, by + 6, bx + (bw - len(footer)) // 2, footer, curses.color_pair(4) | curses.A_DIM) 199 | self.scr.refresh() 200 | k = self.scr.getch() 201 | if k == 27: 202 | return None 203 | if k in (ord('\n'), 10, curses.KEY_ENTER): 204 | break 205 | 206 | while self.current_step < len(self.steps): 207 | if not TUI.check_terminal_size(self.scr): 208 | return None 209 | self.refresh() 210 | k = self.scr.getch() 211 | 212 | handled, res = handle_key_event(k, self.keymap, self) 213 | if handled and res == 'EXIT': 214 | return None 215 | 216 | try: 217 | CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) 218 | CONFIG_FILE.write_text(json.dumps(self.config, indent=4)) 219 | return True 220 | except: 221 | return None --------------------------------------------------------------------------------