letsdo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,8 @@ module Letsdo
4
4
  module Tui
5
5
  # The frame renderer: a pure function from state to text.
6
6
  #
7
- # (metrics snapshot, log lines, terminal width/height,
8
- # view offset / follow / paused) → a framed String
7
+ # (metrics snapshot, log lines, terminal size, view,
8
+ # wait interval) → a framed String
9
9
  #
10
10
  # Pure means: no IO, no terminal, no clock — the same inputs always
11
11
  # produce the same string, so every rendering path (session controller,
@@ -17,35 +17,37 @@ module Letsdo
17
17
  # done 3 · left 2 · task TASK-42 · 00:03:21
18
18
  # ├────────────────────────────────────────────────────────────┤
19
19
  # <body_height scrollable log lines, newest at the bottom>
20
- # ↑/↓ PgUp/PgDn scroll · p pause · r refresh · q quit
20
+ # ↑/↓ PgUp/PgDn scroll · p pause/resume · r refresh · q quit
21
21
  module Renderer
22
22
  HEADER_HEIGHT = 2
23
23
  FOOTER_HEIGHT = 1
24
24
  DIVIDER_HEIGHT = 1
25
- ELLIPSIS = ""
25
+ ELLIPSIS = ''
26
26
 
27
27
  # Renders the full frame.
28
28
  #
29
29
  # @param metrics [Metrics::Snapshot] header metrics snapshot
30
30
  # @param lines [Array<String>] log lines, oldest first
31
- # @param width [Integer] terminal width in columns
32
- # @param height [Integer] terminal height in rows (>= HEADER_HEIGHT +
33
- # FOOTER_HEIGHT + DIVIDER_HEIGHT + 1)
34
- # @param offset [Integer] index of the first visible log line
35
- # @param follow [Boolean] whether the view sticks to the newest line
36
- # @param paused [Boolean] display-freeze state (PAUSED in the header)
31
+ # @param size [Hash{Symbol => Integer}] :width and :height of the
32
+ # terminal (rows >= HEADER_HEIGHT + FOOTER_HEIGHT +
33
+ # DIVIDER_HEIGHT + 1)
34
+ # @param view [Hash{Symbol => Integer, Boolean}] :offset (index of
35
+ # the first visible log line) and :paused (display-freeze
36
+ # overlay)
37
37
  # @param wait_seconds [Numeric] retry interval shown in the waiting
38
38
  # state
39
39
  # @return [String] the framed screen, lines joined with "\n"
40
- def self.render(metrics:, lines:, width:, height:, offset:, follow:, paused:, wait_seconds: 10.0)
41
- body_height = body_height_for(height)
42
- frame = []
43
- frame << header_line(metrics, width)
44
- frame << state_line(metrics, width, paused, wait_seconds)
45
- frame << divider_line(width)
46
- body_lines(lines, offset, body_height, width).each { |line| frame << line }
47
- frame << footer_line(width)
48
- frame.join("\n")
40
+ def self.render(metrics:, lines:, size:, view:, wait_seconds: 10.0)
41
+ width = size.fetch(:width)
42
+ paused = view.fetch(:paused)
43
+ height = body_height_for(size.fetch(:height))
44
+ [
45
+ header_line(metrics, width),
46
+ state_line(metrics, width, paused, wait_seconds),
47
+ divider_line(width),
48
+ *body_lines(lines, view.fetch(:offset), height, width),
49
+ footer_line(width, paused)
50
+ ].join("\n")
49
51
  end
50
52
 
51
53
  # The number of body lines a terminal of the given height fits.
@@ -65,41 +67,56 @@ module Letsdo
65
67
 
66
68
  def self.header_line(metrics, width)
67
69
  title = "letsdo · #{metrics.name} (#{metrics.handle})"
68
- timer = "session #{format_duration(metrics.session_seconds)}"
69
- fit_line_with_right(title, timer, width)
70
+ timer = "session #{Text.format_duration(metrics.session_seconds)}"
71
+ Text.fit_line_with_right(title, timer, width)
70
72
  end
71
73
 
72
74
  # The state line: done/left plus either the running task with its
73
75
  # elapsed time, a waiting reason, or the PAUSED overlay.
74
76
  def self.state_line(metrics, width, paused, wait_seconds)
75
- parts = ["done #{metrics.done}", "left #{metrics.left.nil? ? "?" : metrics.left}"]
77
+ parts = ["done #{metrics.done}", "left #{left_or_question(metrics.left)}"]
78
+ parts.concat(state_overlay(metrics, paused, wait_seconds))
79
+ Text.fit_line(parts.join(' · '), width)
80
+ end
81
+
82
+ def self.left_or_question(left)
83
+ left.nil? ? '?' : left
84
+ end
85
+
86
+ # The current-state fragment after "left N ·": task + elapsed time,
87
+ # a waiting reason, or the PAUSED overlay.
88
+ def self.state_overlay(metrics, paused, wait_seconds)
76
89
  if paused
77
- parts << "PAUSED"
90
+ ['PAUSED']
78
91
  elsif metrics.current_task
79
- parts << "task #{metrics.current_task}"
80
- parts << format_duration(metrics.current_task_seconds) if metrics.current_task_seconds
92
+ overlay = ["task #{metrics.current_task}"]
93
+ overlay << Text.format_duration(metrics.current_task_seconds) if metrics.current_task_seconds
94
+ overlay
81
95
  else
82
- parts << "waiting: #{waiting_reason(metrics.left, wait_seconds)}"
96
+ ["waiting: #{waiting_reason(metrics.left, wait_seconds)}"]
83
97
  end
84
- fit_line(parts.join(" · "), width)
85
98
  end
86
99
 
87
100
  def self.body_lines(lines, offset, body_height, width)
88
101
  Array.new(body_height) do |index|
89
102
  line = lines[offset + index]
90
- line.nil? ? (" " * width) : fit_line(line, width)
103
+ line.nil? ? (' ' * width) : Text.fit_line(line, width)
91
104
  end
92
105
  end
93
106
 
94
107
  def self.divider_line(width)
95
- return "" if width < 4
108
+ return '' if width < 4
96
109
 
97
- body = "" * (width - 2)
110
+ body = '' * (width - 2)
98
111
  "├#{body}┤"
99
112
  end
100
113
 
101
- def self.footer_line(width)
102
- fit_line("↑/↓ PgUp/PgDn scroll · p pause · r refresh · q quit", width)
114
+ # The key-help footer. The 'p' hint mirrors the toggle state:
115
+ # 'p pause' when the run is active, 'p resume' when it is suspended
116
+ # (the display is frozen, PAUSED in the header).
117
+ def self.footer_line(width, paused)
118
+ hint = paused ? 'p resume' : 'p pause'
119
+ Text.fit_line("↑/↓ PgUp/PgDn scroll · #{hint} · r refresh · q quit", width)
103
120
  end
104
121
 
105
122
  # "no open tasks, retrying in 10s" style reason for the waiting state.
@@ -111,63 +128,69 @@ module Letsdo
111
128
  elsif left.zero?
112
129
  "no open tasks#{suffix}"
113
130
  else
114
- "next task"
131
+ 'next task'
115
132
  end
116
133
  end
117
134
 
118
- # HH:MM:SS (hours can exceed two digits for long sessions).
119
- def self.format_duration(seconds)
120
- total = [seconds.to_i, 0].max
121
- hours, remainder = total.divmod(3600)
122
- minutes, secs = remainder.divmod(60)
123
- format("%02d:%02d:%02d", hours, minutes, secs)
124
- end
135
+ # Text fitting helpers kept separate so the frame logic stays short.
136
+ module Text
137
+ # HH:MM:SS (hours can exceed two digits for long sessions).
138
+ def self.format_duration(seconds)
139
+ total = [seconds.to_i, 0].max
140
+ hours, remainder = total.divmod(3600)
141
+ minutes, secs = remainder.divmod(60)
142
+ format('%<hours>02d:%<minutes>02d:%<secs>02d', hours: hours, minutes: minutes, secs: secs)
143
+ end
125
144
 
126
- def self.fit_line_with_right(left, right, width)
127
- gap = [width - display_width(left) - display_width(right), 0].max
128
- fit_line("#{left}#{" " * gap}#{right}", width)
129
- end
145
+ def self.fit_line_with_right(left, right, width)
146
+ gap = [width - display_width(left) - display_width(right), 0].max
147
+ fit_line("#{left}#{' ' * gap}#{right}", width)
148
+ end
130
149
 
131
- # Fits a line to the width: display-width truncation with an
132
- # ellipsis, then whitespace padding so the whole line is rewritten
133
- # (a narrow terminal resize leaves no stale content).
134
- def self.fit_line(line, width)
135
- fitted = fit(line, width)
136
- pad = width - display_width(fitted)
137
- pad.positive? ? fitted + (" " * pad) : fitted
138
- end
150
+ # Fits a line to the width: display-width truncation with an
151
+ # ellipsis, then whitespace padding so the whole line is rewritten
152
+ # (a narrow terminal resize leaves no stale content).
153
+ def self.fit_line(line, width)
154
+ fitted = fit(line, width)
155
+ pad = width - display_width(fitted)
156
+ pad.positive? ? fitted + (' ' * pad) : fitted
157
+ end
139
158
 
140
- # Cuts the line on a character boundary by display width, adding an
141
- # ellipsis when anything was cut. Lines within the width are kept
142
- # verbatim.
143
- def self.fit(line, width)
144
- text_width = display_width(line)
145
- return line if width >= text_width
146
- return "" if width <= 1
147
-
148
- out = +""
149
- acc = 0
150
- limit = width - display_width(ELLIPSIS)
151
- line.each_char do |char|
152
- char_width = display_width(char)
153
- break if acc + char_width > limit
154
-
155
- out << char
156
- acc += char_width
159
+ # Cuts the line on a character boundary by display width, adding an
160
+ # ellipsis when anything was cut. Lines within the width are kept
161
+ # verbatim.
162
+ def self.fit(line, width)
163
+ text_width = display_width(line)
164
+ return line if width >= text_width
165
+ return '' if width <= 1
166
+
167
+ "#{truncate(line, width - display_width(ELLIPSIS))}#{ELLIPSIS}"
157
168
  end
158
- out << ELLIPSIS
159
- out
160
- end
161
169
 
162
- # Display width of a string (Unicode-aware: ✓/⚙/…/CJK are counted
163
- # correctly). Requires "unicode/display_width" lazily so the plain
164
- # (non-TTY) path needs no extra gems.
165
- def self.display_width(text)
166
- return 0 if text.nil? || text.empty?
170
+ # Characters of the line that fit into the width budget.
171
+ def self.truncate(line, budget)
172
+ out = +''
173
+ acc = 0
174
+ line.each_char do |char|
175
+ char_width = display_width(char)
176
+ break if acc + char_width > budget
177
+
178
+ out << char
179
+ acc += char_width
180
+ end
181
+ out
182
+ end
183
+
184
+ # Display width of a string (Unicode-aware: ✓/⚙/…/CJK are counted
185
+ # correctly). Requires "unicode/display_width" lazily so the plain
186
+ # (non-TTY) path needs no extra gems.
187
+ def self.display_width(text)
188
+ return 0 if text.nil? || text.empty?
167
189
 
168
- require "unicode/display_width"
169
- Unicode::DisplayWidth.of(text)
190
+ require 'unicode/display_width'
191
+ Unicode::DisplayWidth.of(text)
192
+ end
170
193
  end
171
194
  end
172
195
  end
173
- end
196
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Tui
5
+ # Keyboard actions for the TUI session input thread.
6
+ module SessionKeys
7
+ private
8
+
9
+ KEY_METHODS = {
10
+ up: :scroll_up,
11
+ page_up: :scroll_page_up,
12
+ down: :scroll_down,
13
+ page_down: :scroll_page_down,
14
+ home: :go_home,
15
+ end: :go_end,
16
+ p: :pause_key,
17
+ r: :refresh_key,
18
+ q: :quit_key,
19
+ ctrl_c: :quit_key
20
+ }.freeze
21
+
22
+ def handle_key(key)
23
+ send(KEY_METHODS.fetch(key, :ignore_key))
24
+ end
25
+
26
+ def scroll_up
27
+ @offset -= 1
28
+ @follow = false
29
+ true
30
+ end
31
+
32
+ def scroll_page_up
33
+ @offset -= page_step
34
+ @follow = false
35
+ true
36
+ end
37
+
38
+ def scroll_down
39
+ @offset += 1
40
+ true
41
+ end
42
+
43
+ def scroll_page_down
44
+ @offset += page_step
45
+ true
46
+ end
47
+
48
+ def go_home
49
+ @offset = 0
50
+ @follow = false
51
+ true
52
+ end
53
+
54
+ def go_end
55
+ @follow = true
56
+ true
57
+ end
58
+
59
+ def pause_key
60
+ toggle_pause
61
+ true
62
+ end
63
+
64
+ def refresh_key
65
+ refresh
66
+ true
67
+ end
68
+
69
+ def quit_key
70
+ quit
71
+ false
72
+ end
73
+
74
+ def ignore_key
75
+ false
76
+ end
77
+
78
+ def refresh
79
+ return unless @refresh
80
+
81
+ @metrics.provider_result(@refresh.call)
82
+ end
83
+
84
+ def toggle_pause
85
+ @paused = !@paused
86
+ send(@paused ? :engage_pause : :engage_resume)
87
+ end
88
+
89
+ def engage_pause
90
+ invoke_gate(:pause)
91
+ invoke_runner(:pause)
92
+ end
93
+
94
+ def engage_resume
95
+ invoke_gate(:resume)
96
+ invoke_runner(:resume)
97
+ end
98
+
99
+ def invoke_gate(action)
100
+ gate = @pause_gate
101
+ return unless gate
102
+
103
+ gate.public_send(action)
104
+ end
105
+
106
+ def invoke_runner(action)
107
+ runner = @runner.call if @runner
108
+ return unless runner
109
+
110
+ runner.public_send(action)
111
+ end
112
+
113
+ def quit
114
+ return if @stop
115
+
116
+ @stop = true
117
+ Thread.main.raise(Letsdo::Stopped)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Tui
5
+ # Frame painting and view-offset tracking for the TUI session.
6
+ module SessionView
7
+ private
8
+
9
+ def input_loop
10
+ with_raw_input do
11
+ repaint
12
+ @last_repaint = @clock.call
13
+ loop { break if @stop || !poll_once }
14
+ end
15
+ rescue StandardError
16
+ nil
17
+ end
18
+
19
+ def poll_once
20
+ handled = process_input
21
+ if handled || (!@paused && (tick? || new_data?))
22
+ repaint
23
+ @last_repaint = @clock.call
24
+ else
25
+ sleep(Session::IDLE_SLEEP)
26
+ end
27
+ true
28
+ end
29
+
30
+ def process_input
31
+ key = @input.next_key
32
+ handled = key ? handle_key(key) : false
33
+ take_winch ? true : handled
34
+ end
35
+
36
+ def tick?
37
+ @clock.call - @last_repaint >= Session::REPAINT_INTERVAL
38
+ end
39
+
40
+ def new_data?
41
+ @log.version != @seen_version
42
+ end
43
+
44
+ def take_winch
45
+ flag = @winch
46
+ @winch = false
47
+ flag
48
+ end
49
+
50
+ def repaint
51
+ lines, version = @log.lines
52
+ @seen_version = version
53
+ height, width = @terminal.size
54
+ @body_height = Renderer.body_height_for(height)
55
+ update_offset(lines)
56
+ @terminal.render(frame_for(lines, width, height))
57
+ end
58
+
59
+ def update_offset(lines)
60
+ max_offset = Renderer.max_offset(lines, @body_height)
61
+ if @paused
62
+ @offset = clamp_offset(max_offset)
63
+ elsif @follow
64
+ @offset = max_offset
65
+ else
66
+ @offset = clamp_offset(max_offset)
67
+ @follow = true if @offset == max_offset
68
+ end
69
+ end
70
+
71
+ def clamp_offset(max_offset)
72
+ [[@offset, max_offset].min, 0].max
73
+ end
74
+
75
+ def frame_for(lines, width, height)
76
+ Renderer.render(
77
+ metrics: @metrics.snapshot, lines: lines,
78
+ size: { width: width, height: height },
79
+ view: { offset: @offset, paused: @paused },
80
+ wait_seconds: @wait_seconds
81
+ )
82
+ end
83
+
84
+ def page_step
85
+ @body_height.positive? ? @body_height : 1
86
+ end
87
+ end
88
+ end
89
+ end