diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..be1ec61 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +all: build + +build: + go build -o levi ./cmd/levi + +clean: + rm -f levi + +run: + go run ./cmd/levi + +fmt: + go fmt ./... + +test: + go test ./... diff --git a/cmd/levi/main.go b/cmd/levi/main.go new file mode 100644 index 0000000..edb20df --- /dev/null +++ b/cmd/levi/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "tea.kareha.org/lab/levi/internal/console" + "tea.kareha.org/lab/levi/internal/editor" +) + +func main() { + // init + console.Raw() + defer func() { + console.Cooked() + console.ShowCursor() + }() + + // main + editor.New().Main() + + // cleanup + console.Clear() + console.HomeCursor() +} diff --git a/internal/console/console.go b/internal/console/console.go new file mode 100644 index 0000000..f3e0026 --- /dev/null +++ b/internal/console/console.go @@ -0,0 +1,107 @@ +package console + +import ( + "fmt" + "io" + "os" + "unicode/utf8" + + "golang.org/x/term" +) + +var state *term.State + +func Raw() { + if state != nil { + term.Restore(int(os.Stdin.Fd()), state) + state = nil + } + s, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + panic(err) + } + state = s +} + +func Cooked() { + if state == nil { + panic("state is nil") + } + term.Restore(int(os.Stdin.Fd()), state) +} + +func Clear() { + fmt.Print("\x1b[2J") +} + +func HomeCursor() { + fmt.Print("\x1b[H") +} + +func MoveCursor(x, y int) { + fmt.Printf("\x1b[%d;%dH", y, x) +} + +func HideCursor() { + fmt.Print("\x1b[?25l") +} + +func ShowCursor() { + fmt.Print("\x1b[?25h") +} + +func Size() (int, int) { + w, h, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + panic(err) + } + return w, h +} + +func runeSize(b byte) int { + switch { + case b & 0x80 == 0: + return 1 + case b & 0xe0 == 0xc0: + return 2 + case b & 0xf0 == 0xe0: + return 3 + case b & 0xf8 == 0xf0: + return 4 + default: + return -1 // invalid + } +} + +func ReadRune() rune { + buf := make([]byte, 1) + _, err := io.ReadFull(os.Stdin, buf) + if err != nil { + panic(err) + } + expected := runeSize(buf[0]) + if expected == -1 { + panic("Invalid UTF-8 head") + } + full := make([]byte, expected) + full[0] = buf[0] + if expected > 1 { + _, err := io.ReadFull(os.Stdin, full[1:]) + if err != nil { + panic(err) + } + } + r, size := utf8.DecodeRune(full) + if r == utf8.RuneError && size == 1 { + panic("Invalid UTF-8 body") + } + return r +} + +func Print(s string) { + fmt.Print(s) +} + +func Printf(format string, a ...any) (n int, err error) { + return fmt.Printf(format, a...) +} diff --git a/internal/editor/editor.go b/internal/editor/editor.go new file mode 100644 index 0000000..0177c7e --- /dev/null +++ b/internal/editor/editor.go @@ -0,0 +1,55 @@ +package editor + +import ( + "strings" + + "tea.kareha.org/lab/levi/internal/console" +) + +type Editor struct { + scr *Screen + kb *Keyboard + x, y int + line *strings.Builder +} + +func New() *Editor { + scr := NewScreen() + kb := NewKeyboard() + + return &Editor{ + scr: &scr, + kb: &kb, + x: 0, + y: 0, + line: new(strings.Builder), + } +} + +func (ed *Editor) addRune(r rune) { + ed.line.WriteRune(r) +} + +func (ed *Editor) draw() { + console.Clear() + console.HomeCursor() + + console.Print("Hit Esc to Exit") + + console.MoveCursor(ed.x, ed.y) + console.Print(ed.line.String()) +} + +func (ed *Editor) Main() { + for { + console.HideCursor() + ed.draw() + console.ShowCursor() + + r := ed.kb.ReadRune() + if r == Esc { + break + } + ed.addRune(r) + } +} diff --git a/internal/editor/keyboard.go b/internal/editor/keyboard.go new file mode 100644 index 0000000..323576c --- /dev/null +++ b/internal/editor/keyboard.go @@ -0,0 +1,17 @@ +package editor + +import ( + "tea.kareha.org/lab/levi/internal/console" +) + +const Esc rune = 0x1b + +type Keyboard struct {} + +func NewKeyboard() Keyboard { + return Keyboard{} +} + +func (kb *Keyboard) ReadRune() rune { + return console.ReadRune() +} diff --git a/internal/editor/screen.go b/internal/editor/screen.go new file mode 100644 index 0000000..d59080f --- /dev/null +++ b/internal/editor/screen.go @@ -0,0 +1,21 @@ +package editor + +import ( + "tea.kareha.org/lab/levi/internal/console" +) + +type Screen struct { + w, h int +} + +func NewScreen() Screen { + w, h := console.Size() + return Screen{ + w: w, + h: h, + } +} + +func (scr *Screen) Size() (int, int) { + return scr.w, scr.h +} diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..97cd773 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,49 @@ +package util + +import ( + "unicode" +) + +func isWide(r rune) bool { + return r >= 0x1100 && ( + r <= 0x115f || // Hangul Jamo + r == 0x2329 || r == 0x232a || + (r >= 0x2e80 && r <= 0xa4cf) || + (r >= 0xac00 && r <= 0xd7a3) || + (r >= 0xf900 && r <= 0xfaff) || + (r >= 0xfe10 && r <= 0xfe19) || + (r >= 0xfe30 && r <= 0xfe6f) || + (r >= 0xff00 && r <= 0xff60) || + (r >= 0xffe0 && r <= 0xffe6)) +} + +func isEmoji(r rune) bool { + return r >= 0x1f300 && r <= 0x1faff +} + +func RuneWidth(r rune) int { + // control code + if r == 0 { + return 0 + } + if r < 32 || (r >= 0x7f && r < 0xa0) { + return 0 + } + + // combining mark + if unicode.Is(unicode.Mn, r) { + return 0 + } + + // wide (loose CJK) + if isWide(r) { + return 2 + } + + // emoji + if isEmoji(r) { + return 2 + } + + return 1 +} diff --git a/main.go b/main.go deleted file mode 100644 index f7fd2bd..0000000 --- a/main.go +++ /dev/null @@ -1,182 +0,0 @@ -package main - -import ( - "fmt" - "io" - "os" - "strings" - "unicode/utf8" - - "golang.org/x/term" -) - -func enableRawMode() (*term.State, error) { - return term.MakeRaw(int(os.Stdin.Fd())) -} - -func disableRawMode(state *term.State) { - term.Restore(int(os.Stdin.Fd()), state) -} - -func clearScreen() { - fmt.Print("\x1b[2J") -} - -func goHome() { - fmt.Print("\x1b[H") -} - -func moveCursor(x, y int) { - fmt.Printf("\x1b[%d;%dH", y, x) -} - -func hideCursor() { - fmt.Print("\x1b[?25l") -} - -func showCursor() { - fmt.Print("\x1b[?25h") -} - -func getScreenSize() (int, int) { - w, h, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - panic(err) - } - return w, h -} - -type Screen struct { - w, h int -} - -func NewScreen() Screen { - w, h := getScreenSize() - return Screen{ - w: w, - h: h, - } -} - -func (scr *Screen) Size() (int, int) { - return scr.w, scr.h -} - -const Esc rune = 0x1b - -func runeSize(b byte) int { - switch { - case b & 0x80 == 0: - return 1 - case b & 0xe0 == 0xc0: - return 2 - case b & 0xf0 == 0xe0: - return 3 - case b & 0xf8 == 0xf0: - return 4 - default: - return -1 // invalid - } -} - -func readRune() rune { - buf := make([]byte, 1) - _, err := io.ReadFull(os.Stdin, buf) - if err != nil { - panic(err) - } - size := runeSize(buf[0]) - if size == -1 { - panic("Invalid UTF-8") - } - full := make([]byte, size) - full[0] = buf[0] - if size > 1 { - _, err := io.ReadFull(os.Stdin, full[1:]) - if err != nil { - panic(err) - } - } - r, size := utf8.DecodeRune(full) - if r == utf8.RuneError && size == 1 { - panic("Invalid UTF-8") - } - return r -} - -type Keyboard struct {} - -func NewKeyboard() Keyboard { - return Keyboard{} -} - -func (kb *Keyboard) ReadRune() rune { - return readRune() -} - -type Editor struct { - scr *Screen - kb *Keyboard - x, y int - line *strings.Builder -} - -func NewEditor(scr *Screen, kb *Keyboard) Editor { - _, h := scr.Size() - return Editor{ - scr: scr, - x: 0, - y: h / 2, - line: new(strings.Builder), - } -} - -func (ed *Editor) Screen() *Screen { - return ed.scr -} - -func (ed *Editor) AddRune(r rune) { - ed.line.WriteRune(r) -} - -func draw(ed *Editor) { - clearScreen() - goHome() - - fmt.Print("Hit Esc to Exit") - - moveCursor(ed.x, ed.y) - fmt.Print(ed.line.String()) -} - -func main() { - // init - oldState, err := enableRawMode() - if err != nil { - panic(err) - } - defer func() { - disableRawMode(oldState) - showCursor() - }() - - // main - scr := NewScreen() - kb := NewKeyboard() - ed := NewEditor(&scr, &kb) - for { - hideCursor() - draw(&ed) - showCursor() - - r := kb.ReadRune() - if r == Esc { - break - } - ed.AddRune(r) - } - - // cleanup - clearScreen() - goHome() -}