This commit is contained in:
2026-03-25 08:39:31 +09:00
parent fb8f4eaee2
commit 030215b55b
8 changed files with 287 additions and 182 deletions

16
Makefile Normal file
View File

@@ -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 ./...

22
cmd/levi/main.go Normal file
View File

@@ -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()
}

107
internal/console/console.go Normal file
View File

@@ -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...)
}

55
internal/editor/editor.go Normal file
View File

@@ -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)
}
}

View File

@@ -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()
}

21
internal/editor/screen.go Normal file
View File

@@ -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
}

49
internal/util/util.go Normal file
View File

@@ -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
}

182
main.go
View File

@@ -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()
}