Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,8 +623,25 @@ sheet of your own, or `zen` for the built-in renderer).
cards; Enter opens them.
- **`![[Note]]`** renders the note inline.
- **Mermaid** blocks draw as text through a pure-Go renderer, or as
pictures when `mmdc` is installed. **`$$` math** renders through `typst`
when it is installed.
pictures when `mmdc` is installed.
- **`[terminal] math`** picks the engine for display math. `typst`, the
default, renders `$$` blocks when `typst` is installed. `latex` also renders
`\[ \]`, `tikzpicture` and the amsmath display environments (`equation`,
`align`, `gather`, `multline` and the rest, starred or not), and needs
`pdftex` and `pdftocairo` (poppler) on PATH; `zn doctor` names a missing
one. A basic TeX Live is enough: add the packages zn uses with
`tlmgr install standalone varwidth preview xcolor amsmath amsfonts mhchem pgf`
(or your distribution's TeX packages), and others later as your preamble
needs them. Packages come from `preamble.tex` next to
`config.toml`, written on the first render with `amsmath`, `amssymb`,
`mhchem` and `tikz` (`arrows.meta`, `calc`); zn never rewrites it. Pictures
are cached in the user cache directory under `zn/tex`, which zn does not
prune. TeX can read files by absolute path, so under `latex` a note can show
in the preview the contents of any file you can read. Leave latex off for
vaults you do not trust.
- **`render_timeout`** (seconds, default 10) bounds one diagram or math
render; **`render_concurrency`** (default 2) caps how many run at once and
takes effect after a restart.

### Vaults and servers inside the app

Expand Down Expand Up @@ -719,7 +736,7 @@ layout choices do not change these terminal layouts.
| `[view]` | `tasks_view_mode`, `kanban_group_by`, `kanban_statuses`, `note_sort_order` |
| `[appearance]` | `theme_family`, `theme_mode`, `theme_id` |
| `[tweaks]` | `accent = "#ff3b30"`: the desktop's Quick tweaks colors |
| `[terminal]` | `mouse`, `preview_style`, `images`, `theme` (terminal only) |
| `[terminal]` | `mouse`, `preview_style`, `images`, `theme`, `math`, `render_timeout`, `render_concurrency` (terminal only) |
| `[keymaps]` | `"action.id" = "keys"`, or `""` to unbind; `:keymaps` lists the ids |
| `[kanban_column_titles]` | `review = "In Review"` |
| `[saved_filters]` | `Important = "!high"` |
Expand Down
13 changes: 13 additions & 0 deletions internal/cli/commands_diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ func cmdDoctor(ctx context.Context, args Args) error {
checks = append(checks, diagnosticCheck{Name: name, OK: err == nil, Message: message})
}
add("configuration", config.ConfigTomlPath(), config.ValidateConfig())
if prefs, _, err := config.LoadPrefs(); err == nil && prefs.TerminalMath == "latex" {
missing := []string{}
for _, tool := range []struct{ name, from string }{{"pdftex", "TeX Live or MacTeX"}, {"pdftocairo", "poppler"}} {
if _, err := exec.LookPath(tool.name); err != nil {
missing = append(missing, tool.name+" (from "+tool.from+")")
}
}
var err error
if len(missing) > 0 {
err = fmt.Errorf("math = \"latex\" needs %s on PATH", strings.Join(missing, " and "))
}
add("latex tools", "pdftex and pdftocairo are available", err)
}
ws, wsErr := config.LoadWorkspacesFile()
add("saved vaults", fmt.Sprintf("%d vaults and %d servers in %s", len(ws.Vaults), len(ws.Servers), config.WorkspacesPath()), wsErr)
if installation, err := selfupdate.Current(); err == nil {
Expand Down
93 changes: 93 additions & 0 deletions internal/cli/commands_diagnostics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cli

import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/ZenNotes/tui/internal/config"
)

// doctorWithTools runs `zn doctor --json` with config.toml holding terminal
// under [terminal] (none when empty) and PATH holding only stubs of tools.
func doctorWithTools(t *testing.T, terminal string, tools ...string) (string, []diagnosticCheck) {
t.Helper()
isolatedCLI(t)
if terminal != "" {
if err := os.MkdirAll(filepath.Dir(config.ConfigTomlPath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.ConfigTomlPath(), []byte("[terminal]\n"+terminal+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
bin := t.TempDir()
for _, tool := range tools {
name := tool
if runtime.GOOS == "windows" {
name += ".exe"
}
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin)
out := captureOutput(t, func() { _ = Main([]string{"doctor", "--json"}) })
var report struct {
Checks []diagnosticCheck `json:"checks"`
}
if err := json.Unmarshal([]byte(strings.SplitN(out, "\n{\"error\"", 2)[0]), &report); err != nil {
t.Fatalf("doctor json: %v %s", err, out)
}
return out, report.Checks
}

func latexToolsCheck(t *testing.T, checks []diagnosticCheck, out string) diagnosticCheck {
t.Helper()
for _, c := range checks {
if c.Name == "latex tools" {
return c
}
}
t.Fatalf("no \"latex tools\" check under math = \"latex\":\n%s", out)
return diagnosticCheck{}
}

// AC75: under latex, a missing pdftex is a failing check that names it.
func TestDoctorLatexReportsMissingPdftex(t *testing.T) {
out, checks := doctorWithTools(t, `math = "latex"`, "pdftocairo")
c := latexToolsCheck(t, checks, out)
if c.OK || !strings.Contains(c.Message, "pdftex") {
t.Fatalf("AC75: latex tools check %+v", c)
}
if strings.Contains(c.Message, "pdftocairo") {
t.Fatalf("AC75: pdftocairo is on PATH but reported missing: %+v", c)
}
}

// AC76: under latex, a missing pdftocairo is a failing check that names it.
func TestDoctorLatexReportsMissingPdftocairo(t *testing.T) {
out, checks := doctorWithTools(t, `math = "latex"`, "pdftex")
c := latexToolsCheck(t, checks, out)
if c.OK || !strings.Contains(c.Message, "pdftocairo") {
t.Fatalf("AC76: latex tools check %+v", c)
}
if strings.Contains(strings.ReplaceAll(c.Message, "pdftocairo", ""), "pdftex") {
t.Fatalf("AC76: pdftex is on PATH but reported missing: %+v", c)
}
}

// AC77: under typst, or with no math key, doctor never mentions the TeX tools.
func TestDoctorTypstNeverMentionsTexTools(t *testing.T) {
for _, terminal := range []string{`math = "typst"`, ``} {
out, _ := doctorWithTools(t, terminal)
for _, tool := range []string{"pdftex", "pdftocairo"} {
if strings.Contains(out, tool) {
t.Errorf("AC77 %q: doctor output mentions %s:\n%s", terminal, tool, out)
}
}
}
}
5 changes: 5 additions & 0 deletions internal/config/dirs.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ func ConfigTomlPath() string {
return filepath.Join(PortableConfigDir(), "config.toml")
}

// PreamblePath is the user-owned LaTeX preamble for math = "latex".
func PreamblePath() string {
return filepath.Join(PortableConfigDir(), "preamble.tex")
}

// CustomThemesDir is where the desktop app keeps user-authored themes, one
// folder each, next to config.toml.
func CustomThemesDir() string {
Expand Down
33 changes: 32 additions & 1 deletion internal/config/prefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ type Prefs struct {
// TerminalTheme names a theme for zn alone: a family, a theme id or a
// custom theme. Empty follows the desktop's [appearance] selection.
TerminalTheme string
// TerminalMath is typst or latex: the engine for display math. Anything
// else loads as typst; zn doctor reports it.
TerminalMath string
// RenderTimeout (seconds) bounds one external render; RenderConcurrency
// caps how many run at once. Non-positive values load as the defaults.
RenderTimeout int
RenderConcurrency int
}

// DefaultPrefs are the shipped defaults.
Expand Down Expand Up @@ -107,6 +114,9 @@ func DefaultPrefs() Prefs {
ThemeTweaks: map[string]string{},
PreviewStyle: "auto",
TerminalMouse: true,
TerminalMath: "typst",
RenderTimeout: 10,
RenderConcurrency: 2,
UnifiedSidebar: true,
NoteSortOrder: "none",
GroupByKind: true,
Expand Down Expand Up @@ -141,7 +151,7 @@ func LoadPrefs() (Prefs, string, error) {
if _, err := toml.Decode(string(raw), &doc); err != nil {
return prefs, path, err
}
if err := validateKnownValues(doc); err != nil {
if err := validateKnownValues(doc, false); err != nil {
return prefs, path, err
}
applyPrefs(&prefs, doc)
Expand Down Expand Up @@ -225,9 +235,23 @@ func applyPrefs(p *Prefs, doc map[string]any) {
setString(terminal, "preview_style", &p.PreviewStyle)
setString(terminal, "images", &p.TerminalImages)
setString(terminal, "theme", &p.TerminalTheme)
setString(terminal, "math", &p.TerminalMath)
setInt(terminal, "render_timeout", &p.RenderTimeout)
setInt(terminal, "render_concurrency", &p.RenderConcurrency)
if strings.TrimSpace(p.PreviewStyle) == "" {
p.PreviewStyle = "auto"
}
if strings.EqualFold(strings.TrimSpace(p.TerminalMath), "latex") {
p.TerminalMath = "latex"
} else {
p.TerminalMath = "typst"
}
if p.RenderTimeout <= 0 {
p.RenderTimeout = 10
}
if p.RenderConcurrency <= 0 {
p.RenderConcurrency = 2
}

p.KeymapOverrides = mergeStringTable(p.KeymapOverrides, section("keymaps"))
p.SystemFolderLabels = mergeStringTable(p.SystemFolderLabels, section("folder_labels"))
Expand Down Expand Up @@ -343,6 +367,13 @@ mouse = true
# A theme for zn alone: a family, a theme id or a custom theme. Empty follows
# [appearance], so the terminal matches the desktop app.
theme = ""
# math = typst | latex: the engine for display math in the preview. latex
# needs pdftex (TeX Live or MacTeX) and pdftocairo (poppler) on PATH, and
# reads its packages from preamble.tex next to this file.
math = "typst"
# Seconds one diagram or math render may take, and how many run at once.
render_timeout = 10
render_concurrency = 2

[keymaps]
# "action.id" = "keys" (an empty string unbinds an action)
Expand Down
Loading
Loading