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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Settings › Font lists your installed fonts on Windows.** The picker read the
font list from fontconfig, which Windows does not have, so it offered nothing
beyond the bundled default and whatever was already selected. It now reads the
fonts Windows itself records, folding every weight of a family onto one entry.
- **Links open in your browser on Windows.** Clicking an external link — a pull
request, a release page — called the Linux opener and did nothing at all, with
only a line in `lich.log` to show for it. Each platform now uses its own
opener.
- **Open in editor works on a Windows path with a space in it.** The command
handed to the session was quoted for a Unix shell, which cmd.exe does not
understand, so a file under `C:\Users\First Last\` opened as a path cut at the
space — and a file whose own name held one never opened at all. The quoting is
now the session shell's own.
- **A file cannot open something else on Windows.** Opening a file with no
`$EDITOR` set passed its path through a shell that reads `&` as the end of a
command, so a file named `a&calc.txt` — which any branch you check out is free
to carry — ran `calc` when you opened it. The path is now handed over as a
plain argument, with no shell in between. A path cmd.exe cannot express at all
opens with the default handler rather than being run.
- **lich no longer vanishes mid-session on Windows.** Tracking a session's
working directory reads it out of the child process's own memory, and a length
that memory reported odd — which a 32-bit child, or one exiting mid-read, does
Expand Down
76 changes: 63 additions & 13 deletions internal/fonts/fonts.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
// Package fonts enumerates the operating system's installed font families so the
// frontend can offer them as terminal fonts. Chromium resolves fonts through
// the same fontconfig database on Linux, so any family listed here is
// renderable by name in the terminal without bundling it.
// frontend can offer them as terminal fonts. Chromium resolves fonts through the
// same database this package reads — fontconfig on Linux and macOS, the Fonts
// registry key on Windows — so any family listed here is renderable by name in
// the terminal without bundling it.
package fonts

import (
"fmt"
"os/exec"
"sort"
"strings"
)
Expand All @@ -19,15 +18,10 @@ func New() *Service {
return &Service{}
}

// List returns the sorted, de-duplicated set of installed font families. It
// enumerates via fontconfig (fc-list), covering Linux/macOS; Windows would need
// a registry / DirectWrite path.
// List returns the sorted, de-duplicated set of installed font families,
// enumerated the way this OS records them (see the list_* files).
func (s *Service) List() ([]string, error) {
out, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
return nil, fmt.Errorf("fc-list failed: %w", err)
}
return parseFamilies(string(out)), nil
return listFamilies()
}

// parseFamilies extracts the sorted, de-duplicated family names from fc-list's
Expand All @@ -43,7 +37,63 @@ func parseFamilies(out string) []string {
}
seen[name] = struct{}{}
}
return sorted(seen)
}

// parseRegistryFamilies extracts the sorted, de-duplicated family names from the
// value names under the Windows Fonts registry key. A name reads
// "<family>[ <face>] (<format>)" — "Arial Bold Italic (TrueType)" — and one
// entry may register several families joined by " & ".
func parseRegistryFamilies(names []string) []string {
seen := make(map[string]struct{})
for _, name := range names {
for _, part := range strings.Split(stripFontFormat(name), "&") {
family := trimFontFaces(strings.TrimSpace(part))
if family == "" {
continue
}
seen[family] = struct{}{}
}
}
return sorted(seen)
}

// stripFontFormat drops the trailing format parenthetical every Fonts registry
// value carries ("(TrueType)", "(OpenType)", "(VGA res)"). A name that opens with
// one keeps it: the parenthetical is a suffix, never the whole name.
func stripFontFormat(name string) string {
if i := strings.LastIndex(name, "("); i > 0 {
return name[:i]
}
return name
}

// fontFaceWords are the trailing words a registry entry appends to name one
// installed face, so every weight of Arial collapses onto "Arial" in the picker.
// The list is short on purpose: a word belongs here only when no real family ends
// in it, which is why "Black", "Light" and "Semibold" are absent — "Arial Black"
// and "Segoe UI Light" are families in their own right.
//
// Ceiling: a family shipping no regular face is offered under its trimmed name,
// which Chromium may then fail to resolve; the terminal falls back to the
// default font when that happens.
var fontFaceWords = map[string]bool{
"bold": true, "italic": true, "oblique": true, "regular": true,
}

// trimFontFaces drops the face words trailing a registry family name. The last
// word is never dropped: a name that is nothing but a face word is a family
// called that.
func trimFontFaces(name string) string {
fields := strings.Fields(name)
for len(fields) > 1 && fontFaceWords[strings.ToLower(fields[len(fields)-1])] {
fields = fields[:len(fields)-1]
}
return strings.Join(fields, " ")
}

// sorted returns the set's members in order.
func sorted(seen map[string]struct{}) []string {
families := make([]string, 0, len(seen))
for family := range seen {
families = append(families, family)
Expand Down
49 changes: 47 additions & 2 deletions internal/fonts/fonts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ package fonts
import (
"os/exec"
"reflect"
"runtime"
"testing"
)

func TestListSmoke(t *testing.T) {
if _, err := exec.LookPath("fc-list"); err != nil {
t.Skip("fc-list not installed")
// Windows reads the registry, which is always there; everywhere else the
// enumeration is fc-list, and a machine without fontconfig has nothing to say.
if runtime.GOOS != "windows" {
if _, err := exec.LookPath("fc-list"); err != nil {
t.Skip("fc-list not installed")
}
}
got, err := New().List()
if err != nil {
Expand All @@ -27,3 +32,43 @@ func TestParseFamilies(t *testing.T) {
t.Errorf("parseFamilies = %v, want %v", got, want)
}
}

// TestParseRegistryFamilies proves the Windows Fonts key's value names collapse
// onto the family list the picker offers: the format parenthetical goes, faces
// of one family fold together, a multi-family entry splits, and a family whose
// name merely ends in a face-like word survives whole.
func TestParseRegistryFamilies(t *testing.T) {
names := []string{
"Arial (TrueType)",
"Arial Bold (TrueType)",
"Arial Bold Italic (TrueType)",
"Arial Black (TrueType)",
"Cascadia Mono (TrueType)",
"Segoe UI Light (TrueType)",
"MS Gothic & MS UI Gothic & MS PGothic (TrueType)",
"Courier 10,12,15 (VGA res)",
" ",
"",
}
want := []string{
"Arial",
"Arial Black",
"Cascadia Mono",
"Courier 10,12,15",
"MS Gothic",
"MS PGothic",
"MS UI Gothic",
"Segoe UI Light",
}
if got := parseRegistryFamilies(names); !reflect.DeepEqual(got, want) {
t.Errorf("parseRegistryFamilies =\n%v\nwant\n%v", got, want)
}
}

// TestTrimFontFacesKeepsALoneFaceWord proves the trim never empties a name: a
// family called "Bold" is a family, not a face of nothing.
func TestTrimFontFacesKeepsALoneFaceWord(t *testing.T) {
if got := trimFontFaces("Bold"); got != "Bold" {
t.Errorf("trimFontFaces(\"Bold\") = %q, want %q", got, "Bold")
}
}
18 changes: 18 additions & 0 deletions internal/fonts/list_fontconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build !windows

package fonts

import (
"fmt"
"os/exec"
)

// listFamilies enumerates installed fonts through fontconfig, the database
// Chromium itself resolves against on Linux and macOS.
func listFamilies() ([]string, error) {
out, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
return nil, fmt.Errorf("fc-list failed: %w", err)
}
return parseFamilies(string(out)), nil
}
44 changes: 44 additions & 0 deletions internal/fonts/list_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package fonts

import (
"errors"
"fmt"

"golang.org/x/sys/windows/registry"
)

// fontsKeyPath is where Windows records every installed font. The value name
// carries the family and face ("Arial Bold (TrueType)"), the data the file.
const fontsKeyPath = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`

// listFamilies enumerates installed fonts from the registry — Windows has no
// fontconfig. Machine-wide and per-user fonts sit under the same path in
// different hives, and both feed the DirectWrite database Chromium resolves
// against.
func listFamilies() ([]string, error) {
machine, err := fontValueNames(registry.LOCAL_MACHINE)
if err != nil {
return nil, fmt.Errorf("read installed fonts: %w", err)
}
user, err := fontValueNames(registry.CURRENT_USER)
if err != nil {
return nil, fmt.Errorf("read per-user fonts: %w", err)
}
return parseRegistryFamilies(append(machine, user...)), nil
}

// fontValueNames returns every value name under hive's Fonts key. A user who
// never installed a font for themselves has no key at all, which is an empty
// list rather than a failure.
func fontValueNames(hive registry.Key) ([]string, error) {
key, err := registry.OpenKey(hive, fontsKeyPath, registry.QUERY_VALUE)
if err != nil {
if errors.Is(err, registry.ErrNotExist) {
return nil, nil
}
return nil, err
}
defer key.Close()
// A negative count asks for every value; the key holds one per installed face.
return key.ReadValueNames(-1)
}
22 changes: 22 additions & 0 deletions internal/system/open_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package system

import "github.com/omartelo/lich/internal/shquote"

// openDefault opens the file in the default text editor when no $VISUAL/$EDITOR
// is set. `open -t` targets the editor bound to the Default Editor, not the
// file type's app, so a source file lands in an editor rather than a viewer.
func (s *Service) openDefault(full string) error {
return s.run("open", "-t", full)
}

// openURL hands an external URL to the default browser. No -t here: that flag
// forces the text editor, which is right for a source file and wrong for a link.
func (s *Service) openURL(rawURL string) error {
return s.run("open", rawURL)
}

// quoteForShell quotes a path for the POSIX shell a macOS session runs. Every
// path can be expressed, so this never refuses.
func (s *Service) quoteForShell(full string) (string, bool) {
return shquote.Quote(full), true
}
21 changes: 21 additions & 0 deletions internal/system/open_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package system

import "github.com/omartelo/lich/internal/shquote"

// openDefault hands the file to the desktop's default handler when no
// $VISUAL/$EDITOR is set. xdg-open resolves the user's chosen application.
func (s *Service) openDefault(full string) error {
return s.run("xdg-open", full)
}

// openURL hands an external URL to the desktop's default browser — the same
// resolver, and the reason a link opens outside the app window.
func (s *Service) openURL(rawURL string) error {
return s.run("xdg-open", rawURL)
}

// quoteForShell quotes a path for the POSIX shell a Linux session runs. Every
// path can be expressed, so this never refuses.
func (s *Service) quoteForShell(full string) (string, bool) {
return shquote.Quote(full), true
}
24 changes: 24 additions & 0 deletions internal/system/open_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package system

// openDefault hands the file to its shell association when no $VISUAL/$EDITOR
// is set. explorer takes the path as a plain argument, which is the point: the
// `cmd /c start` this replaced handed it to a shell that reads & | < > as
// command separators, and Go quotes an argument only when it holds a space — so
// a file named "a&calc.txt", which a checked-out branch is free to carry, ran
// calc. explorer exits 1 even when it worked; nothing waits on it.
func (s *Service) openDefault(full string) error {
return s.run("explorer", full)
}

// quoteForShell quotes a path for cmd.exe, the shell a Windows session runs.
func (s *Service) quoteForShell(full string) (string, bool) {
return quoteCmdPath(full)
}

// openURL hands an external URL to the default browser. Deliberately not the
// `cmd /c start` above: cmd parses metacharacters out of its command line before
// start ever sees them, so a `&` in a query string would end the command and run
// what follows it. rundll32 is launched directly, with no shell in between.
func (s *Service) openURL(rawURL string) error {
return s.run("rundll32", "url.dll,FileProtocolHandler", rawURL)
}
8 changes: 0 additions & 8 deletions internal/system/opendefault_darwin.go

This file was deleted.

7 changes: 0 additions & 7 deletions internal/system/opendefault_linux.go

This file was deleted.

9 changes: 0 additions & 9 deletions internal/system/opendefault_windows.go

This file was deleted.

Loading