diff --git a/edits/cuts.go b/edits/cuts.go new file mode 100644 index 0000000..12e3f92 --- /dev/null +++ b/edits/cuts.go @@ -0,0 +1,79 @@ +package edits + +import ( + "strings" + "time" + + ps "github.com/xanni/jotty/permascroll" +) + +const ( + layout = time.DateTime + minCut = 5 +) + +// Select previous cut. +func PrevCut() { + if ps.Cuts() > 0 { + Mode = Cuts + currentCut-- + if currentCut < 1 { + currentCut = ps.Cuts() + } + } +} + +// Select next cut. +func NextCut() { + if ps.Cuts() > 0 { + Mode = Cuts + currentCut++ + if currentCut > ps.Cuts() { + currentCut = 1 + } + } +} + +func drawCut(current bool, text string, ts time.Time) (s string) { + maxLen := ex - len(layout) - 2 + if maxLen < minCut { + maxLen = ex - 1 + } else { + switch { + case ts.IsZero(): + s = strings.Repeat(" ", len(layout)+1) + case current: + s = cutCurStyle(ts.Format(layout)) + " " + default: + s = cutTimeStyle(ts.Format(layout)) + " " + } + } + + if current { + s += truncate(maxLen, text) + } else { + s += cutStyle(truncate(maxLen, text)) + } + + return s +} + +// The preceding, current and following cuts. +func cutsWindow() (w []string) { + w = []string{cutWinStyle(strings.Repeat("—", ex))} + + if currentCut > 1 { + text, ts := ps.GetCut(currentCut - 1) + w = append(w, drawCut(false, text, ts)) + } + + text, ts := ps.GetCut(currentCut) + w = append(w, drawCut(true, text, ts)) + + if currentCut < ps.Cuts() { + text, ts := ps.GetCut(currentCut + 1) + w = append(w, drawCut(false, text, ts)) + } + + return w +} diff --git a/edits/cuts_test.go b/edits/cuts_test.go new file mode 100644 index 0000000..95fc23b --- /dev/null +++ b/edits/cuts_test.go @@ -0,0 +1,76 @@ +package edits + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + ps "github.com/xanni/jotty/permascroll" +) + +func TestDrawCut(t *testing.T) { + assert := assert.New(t) + ResizeScreen(4, 2) + assert.Equal("T…t", drawCut(false, "Test", time.Time{})) + + ResizeScreen(5, 2) + assert.Equal("Test", drawCut(false, "Test", time.Time{})) + + ResizeScreen(26, 2) + assert.Equal(" Test", drawCut(false, "Test", time.Time{})) + ts := time.Date(2020, time.January, 2, 3, 4, 5, 6, time.UTC) + expect := "2020-01-02 03:04:05 Test" + assert.Equal(expect, drawCut(false, "Test", ts), "unselected") + assert.Equal(expect, drawCut(true, "Test", ts), "selected") +} + +func TestCutsWindow(t *testing.T) { + assert := assert.New(t) + setupTest() + ResizeScreen(5, 2) + ps.Init("I1,0:Test\nC1,0+4\n") + currentCut = 1 + assert.Equal([]string{"—————", "Test"}, cutsWindow()) + + currentCut = ps.CopyText(1, 0, 1) + ps.CopyText(1, 2, 4) + assert.Equal([]string{"—————", "Test", "T", "st"}, cutsWindow()) +} + +func TestPrevCut(t *testing.T) { + assert := assert.New(t) + setupTest() + + PrevCut() + assert.Equal(None, Mode) + assert.Equal(0, currentCut) + + ps.Init("I1,0:Test\nC1,0+4\n") + PrevCut() + assert.Equal(Cuts, Mode) + assert.Equal(1, currentCut) + + currentCut = ps.CopyText(1, 1, 3) + PrevCut() + assert.Equal(Cuts, Mode) + assert.Equal(1, currentCut) +} + +func TestNextCut(t *testing.T) { + assert := assert.New(t) + setupTest() + + NextCut() + assert.Equal(None, Mode) + assert.Equal(0, currentCut) + + ps.Init("I1,0:Test\nC1,0+4\n") + NextCut() + assert.Equal(Cuts, Mode) + assert.Equal(1, currentCut) + + currentCut = ps.CopyText(1, 1, 3) + NextCut() + assert.Equal(Cuts, Mode) + assert.Equal(1, currentCut) +} diff --git a/edits/edits.go b/edits/edits.go index bce30fa..b8cd92d 100644 --- a/edits/edits.go +++ b/edits/edits.go @@ -55,6 +55,7 @@ type ModeType int const ( None ModeType = iota + Cuts Error Help Quit @@ -603,7 +604,7 @@ func truncate(maxLen int, s string) string { return s } - half := maxLen / 2 + half := (maxLen - 1) / 2 return s[:half] + string(moreChar) + s[len(s)-half:] } @@ -642,8 +643,9 @@ func Screen() string { } switch Mode { - case Quit: - t = append(t, confirmStyle(message)) + case Cuts: + window := cutsWindow() + t = append(t[:len(t)-len(window)+1], window...) case Error: t = append(t, errorString()+" "+errorStyle(truncate(ex-(i18n.TextWidth["error"]+1), message))) case Help: @@ -651,6 +653,8 @@ func Screen() string { t = slices.Delete(t, 0, len(window)) t = slices.Insert(t, 0, window...) t = append(t, statusLine()) + case Quit: + t = append(t, confirmStyle(message)) default: t = append(t, statusLine()) } diff --git a/edits/edits_test.go b/edits/edits_test.go index 3a0695e..0d4982a 100644 --- a/edits/edits_test.go +++ b/edits/edits_test.go @@ -460,6 +460,10 @@ func TestScreen(t *testing.T) { cursor[Para] = 3 assert.Equal("1 2 \n3 4\n\n_\n@14/14", Screen()) + currentCut = ps.CopyText(1, 2, 7) + Mode = Cuts + assert.Equal("1 2 \n3 4\n\n——————————\nB C D", Screen()) + i18n.HelpText, i18n.HelpWidth = []string{"Test"}, 4 Mode = Help assert.Equal(" Test\n——————————\n\n_\n@14/14", Screen()) diff --git a/edits/styles.go b/edits/styles.go index 2bb4717..6ac63e2 100644 --- a/edits/styles.go +++ b/edits/styles.go @@ -50,6 +50,21 @@ func cutStyle(s string) string { return output.String(s).CrossOut().Foreground(output.Color(cutColor)).String() } +// Currently selected cut. +func cutCurStyle(s string) string { + return output.String(s).Reverse().String() +} + +// Timestamp of unselected cut. +func cutTimeStyle(s string) string { + return output.String(s).Reverse().Foreground(output.Color(cutColor)).String() +} + +// Cut window. +func cutWinStyle(s string) string { + return output.String(s).Foreground(output.Color(cutColor)).String() +} + func errorStyle(s string) string { return output.String(s).Foreground(output.Color(errorColor)).String() } diff --git a/i18n/help.de b/i18n/help.de index ee39998..bb1601b 100644 --- a/i18n/help.de +++ b/i18n/help.de @@ -9,8 +9,9 @@ Bei ausgewähltem Text tauscht die Leertaste die Auswahlen aus, Mit den folgenden Tasten lassen sich spezielle Aktionen ausführen: "↑" Bereich vergrößern, "↓" Bereich verkleinern, "←" nach links bewegen, "→" nach rechts bewegen, "Rücktaste"/"Strg-H" vorhergehenden Text löschen, "Eingabetaste"/"Strg-M" neuer Absatz, +"Tab"/"Strg-I" Markierung setzen, "Umschalt-Tab" alle Markierungen abbrechen, "Strg-C" Text kopieren, "Strg-J" benachbarte Sätze oder Absätze verbinden, "Einfügen"/"Strg-V" ausgeschnittenen oder kopierten Text einfügen, "Entf"/"Strg-X" Text ausschneiden, "Pos1"/"Strg-U" zum Anfang bewegen, "Ende"/"Strg-D" zum Ende bewegen, -"Tab"/"Strg-I" Markierung setzen, "Umschalt-Tab" alle Markierungen abbrechen, -"Strg-Q"/"Strg-W" beenden, "Strg-E" exportieren, "Strg-Z" rückgängig machen, "Strg-Y" wiederherstellen +"Bild auf"/"Strg-P" wählt den vorherigen Schnitt, "Bild ab"/"Strg-N" wählt den nächsten Schnitt, +"Strg-Q"/"Strg-W" beenden, "Strg-E" exportieren, "Strg-Z" rückgängig machen, "Strg-Y" wiederherstellen. diff --git a/i18n/help.en b/i18n/help.en index 821a2cb..af479d7 100644 --- a/i18n/help.en +++ b/i18n/help.en @@ -9,8 +9,9 @@ While text is selected "Space" will exchange the selections, The following keys perform special actions: "↑" increase scope, "↓" decrease scope, "←" move left, "→" move right, "Backspace"/"Ctrl-H" erase preceding text, "Enter"/"Ctrl-M" new paragraph, +"Tab"/"Ctrl-I" set mark, "Shift-Tab" clear all marks, "Ctrl-C" copy text, "Ctrl-J" join adjacent sentences or paragraphs, "Insert"/"Ctrl-V" insert cut or copied text, "Delete"/"Ctrl-X" cut text, "Home"/"Ctrl-U" move to beginning, "End"/"Ctrl-D" move to end, -"Tab"/"Ctrl-I" set mark, "Shift-Tab" clear all marks, -"Ctrl-Q"/"Ctrl-W" quit, "Ctrl-E" export, "Ctrl-Z" undo, "Ctrl-Y" redo +"PageUp"/"Ctrl-P" select previous cut, "PageDown"/"Ctrl-N" select next cut, +"Ctrl-Q"/"Ctrl-W" quit, "Ctrl-E" export, "Ctrl-Z" undo, "Ctrl-Y" redo. diff --git a/i18n/help.jp b/i18n/help.jp index f18d693..fbfb88f 100644 --- a/i18n/help.jp +++ b/i18n/help.jp @@ -9,9 +9,10 @@ 以下のキーは特別な操作を実行します: "↑" 範囲を広げる、"↓" 範囲を狭める、"←" 左に移動、"→" 右に移動、 "Backspace"/"Ctrl-H" で前のテキストを消去、"Enter"/"Ctrl-M" 新しい段落、 +"Tab"/"Ctrl-I" でマークを設定、"Shift-Tab" で全てのマークをクリア、 "Ctrl-C" テキストをコピー、"Ctrl-J" 隣接する文や段落を結合、 "Insert"/"Ctrl-V" で切り取ったまたはコピーしたテキストを挿入、 "Delete"/"Ctrl-X" でテキストを切り取り、"Ctrl-E" でエクスポート、 "Home"/"Ctrl-U" で先頭に移動、"End"/"Ctrl-D" で末尾に移動、 -"Tab"/"Ctrl-I" でマークを設定、"Shift-Tab" で全てのマークをクリア、 -"Ctrl-Q"/"Ctrl-W" で終了、"Ctrl-Z" で元に戻す、"Ctrl-Y" でやり直し +"PageUp"/"Ctrl-P" は前の切り取りを選択し、"PageDown"/"Ctrl-N" は次の切り取りを選択し、 +"Ctrl-Q"/"Ctrl-W" で終了、"Ctrl-Z" で元に戻す、"Ctrl-Y" でやり直し。 diff --git a/i18n/i18n.go b/i18n/i18n.go index 33ba2f4..4d04928 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -47,7 +47,7 @@ func init() { s := strings.Split(string(b), "\n") for _, t := range s { k, v, _ := strings.Cut(t, "|") - v = strings.Replace(v, `\n`, "\n", -1) + v = strings.ReplaceAll(v, `\n`, "\n") Text[k] = v TextWidth[k] = uniseg.StringWidth(v) } diff --git a/jotty.go b/jotty.go index edf8ec2..cc0a979 100644 --- a/jotty.go +++ b/jotty.go @@ -10,10 +10,10 @@ import ( "strings" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/xanni/jotty/edits" "github.com/xanni/jotty/i18n" ps "github.com/xanni/jotty/permascroll" - tea "github.com/charmbracelet/bubbletea" ) //go:generate sh -c "printf %s $(git describe --always --tags) > version.txt" @@ -36,6 +36,8 @@ var dispatch = map[tea.KeyType]func(){ tea.KeyTab: edits.Mark, tea.KeyShiftTab: edits.ClearMarks, tea.KeyCtrlJ: edits.Join, tea.KeyEnter: edits.Enter, tea.KeySpace: edits.Space, + tea.KeyPgDown: edits.NextCut, tea.KeyCtrlN: edits.NextCut, + tea.KeyPgUp: edits.PrevCut, tea.KeyCtrlP: edits.PrevCut, tea.KeyCtrlQ: confirmExit, tea.KeyCtrlW: confirmExit, tea.KeyHome: edits.Home, tea.KeyCtrlU: edits.Home, tea.KeyInsert: edits.InsertCut, tea.KeyCtrlV: edits.InsertCut, @@ -55,9 +57,7 @@ func export() { edits.Export(exportPath) } func help() { edits.SetMode(edits.Help, "") } // True if the window is sufficiently large. -func isSizeOK() bool { - return sx > 5 && sy > 2 -} +func isSizeOK() bool { return sx > 5 && sy > 2 } func (m model) Init() tea.Cmd { edits.ID = "Jotty " + version @@ -65,13 +65,31 @@ func (m model) Init() tea.Cmd { return nil } -func acceptKey(m *model, msg tea.KeyMsg) { - if isSizeOK() { - m.timer.Reset(syncDelay) - if f, ok := dispatch[msg.Type]; ok { - f() - } else if msg.Type == tea.KeyRunes && !msg.Alt { - edits.InsertRunes(msg.Runes) +func (m model) acceptKey(msg tea.KeyMsg) { + m.timer.Reset(syncDelay) + if f, ok := dispatch[msg.Type]; ok { + f() + } else if msg.Type == tea.KeyRunes && !msg.Alt { + edits.InsertRunes(msg.Runes) + } +} + +func (m model) cutsKey(key tea.KeyMsg) { + switch key.Type { + case tea.KeyEsc: + edits.ClearMode() + case tea.KeyPgDown, tea.KeyCtrlN: + edits.NextCut() + case tea.KeyPgUp, tea.KeyCtrlP: + edits.PrevCut() + case tea.KeySpace, tea.KeyEnter, tea.KeyInsert, tea.KeyCtrlV: + edits.ClearMode() + edits.InsertCut() + case tea.KeyRunes: + if !key.Alt { + m.timer.Reset(syncDelay) + edits.ClearMode() + edits.InsertRunes(key.Runes) } } } @@ -82,7 +100,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { sx, sy = msg.Width, msg.Height edits.ResizeScreen(msg.Width, msg.Height) case tea.KeyMsg: + if !isSizeOK() { + break + } + switch edits.Mode { + case edits.Cuts: + m.cutsKey(msg) case edits.Error: if msg.Type == tea.KeySpace || msg.Type == tea.KeyEnter || msg.Type == tea.KeyEsc { edits.ClearMode() @@ -99,7 +123,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } default: - acceptKey(&m, msg) + m.acceptKey(msg) } }