terminal_calendar 0.1.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.
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+ # rubocop:disable Naming::MethodParameterName
3
+ class TerminalCalendar
4
+ module Selection
5
+ class Grid
6
+ # @return [Array<Array>]
7
+ # @api private
8
+ attr_reader :grid
9
+
10
+ attr_reader :highlighted_position
11
+
12
+ # @return [Integer]
13
+ # @api private
14
+ attr_accessor :redraw_at
15
+
16
+ # Builds a new grid from a array of arrays of objects
17
+ #
18
+ # @param [Array<Array>] objects The objects to build the grid from.
19
+ # @param [Hash] opts
20
+ #
21
+ # @return [Grid]
22
+ # @api public
23
+ def self.build_from_objects(objects, _opts={})
24
+ new(objects.first.length, objects.length).tap do |new_grid|
25
+ new_grid.populate_from_objects(objects)
26
+ end
27
+ end
28
+
29
+ # Initializes a new grid with the specified width and height.
30
+
31
+ # @param width [Integer] the width of the grid
32
+ # @param height [Integer] the height of the grid
33
+ # @param pastel [Pastel] The pastel object to used for decorating text
34
+ def initialize(width, height, pastel: Pastel.new)
35
+ @grid = Array.new(height) do
36
+ Array.new(width) { NullCell.new }
37
+ end
38
+ @pastel = pastel
39
+ end
40
+
41
+ # Builds a the grid from an array of arrays of objects
42
+ #
43
+ # @param [Array<Array>] objects the objects to populate the grid with
44
+ # @return [Grid] self
45
+ #
46
+ # @api public
47
+ def populate_from_objects(objects)
48
+ objects.each_with_index do |object_row, y|
49
+ object_row.each_with_index do |obj, x|
50
+ populate_position(x, y, obj)
51
+ end
52
+ end
53
+ self
54
+ end
55
+
56
+ # Populates a position on the grid with the given object.
57
+
58
+ # @param x [Integer] the x-coordinate of the position
59
+ # @param y [Integer] the y-coordinate of the position
60
+ # @param object [Object] the object to wrap in the cell
61
+ # @return [Cell] the created cell
62
+ def populate_position(x, y, object)
63
+ return grid[y][x] if object.null?
64
+
65
+ grid[y][x] = Cell.new(object)
66
+ end
67
+
68
+ # Returns the cell at the given coordinates.
69
+ #
70
+ # @param [Integer] x the x coordinate
71
+ # @param [Integer] y the y coordinate
72
+ # @return [Cell] the cell at the given coordinates
73
+ #
74
+ # @api public
75
+ def cell(x, y)
76
+ grid[y][x]
77
+ end
78
+
79
+ # Renders specified number of lines from the bottom of the grid as printable strings
80
+ # @param count [Integer, Symbol] The number of lines to render. If set to :all, all lines will be rendered.
81
+ # @return [Array<String>] An array of strings representing the rendered lines.
82
+ def render_lines(count=:all)
83
+ start_at = render_start(count)
84
+
85
+ (start_at..bottom_of_grid).map do |i|
86
+ render_row(i)
87
+ end
88
+ end
89
+
90
+ # Returns the y value of the bottom of the grid
91
+ #
92
+ # @return [Integer] the bottom of the grid
93
+ #
94
+ # @api public
95
+ def bottom_of_grid
96
+ grid.length - 1
97
+ end
98
+
99
+ # Returns the y value of the top of the grid
100
+ #
101
+ # @return [Integer] the top of the grid
102
+ #
103
+ # @api public
104
+ def top_of_grid
105
+ 0
106
+ end
107
+
108
+ # Returns the bottom row of the grid
109
+ #
110
+ # @return [Array<TerminalCalendar::Selection::Cell>]
111
+ #
112
+ # @api public
113
+ def bottom_row
114
+ grid[bottom_of_grid]
115
+ end
116
+
117
+ # Returns the top row of the grid
118
+ #
119
+ # @return [Array<TerminalCalendar::Selection::Cell>]
120
+ #
121
+ # @api public
122
+ def top_row
123
+ grid[top_of_grid]
124
+ end
125
+
126
+ # Returns the first cell in the grid that is not null working from right to left, bottom to top.
127
+ #
128
+ # @return [Array<Integer>] the bottom right live cell position in the grid format x,y
129
+ #
130
+ # @example
131
+ # bottom_right_live_cell_position #=> [3, 3]
132
+ #
133
+ # @api public
134
+ def bottom_right_live_cell_position
135
+ bottom_of_grid.downto(top_of_grid).each do |y|
136
+ row = grid[y]
137
+ (row.length - 1).downto(0).each do |x|
138
+ return [x, y] unless row[x].null?
139
+ end
140
+ end
141
+ end
142
+
143
+ # Returns the first cell in the grid that is not null working from left to right, top to bottom.
144
+ #
145
+ # @return [Array<Integer>] the top left live cell position in the grid format x, y
146
+ #
147
+ # @example
148
+ # top_left_live_cell_position #=> [2, 0]
149
+ #
150
+ # @api public
151
+ def top_left_live_cell_position
152
+ (top_of_grid..bottom_of_grid).each do |y|
153
+ grid[y].each_with_index do |cell, x|
154
+ return [x, y] unless cell.null?
155
+ end
156
+ end
157
+ end
158
+
159
+ # Returns all selected cells in the grid
160
+ # @return [Array<Cell>] Selected cells
161
+ def selected_cells
162
+ grid.flatten.select(&:selected?)
163
+ end
164
+
165
+ def redraw_lines
166
+ render_lines(redraw_at.nil? || redraw_at <= 0 ? :all : (grid.length - redraw_at))
167
+ end
168
+
169
+ def row_end
170
+ grid.first.length - 1
171
+ end
172
+
173
+ def highlighted_position=(pos)
174
+ @highlighted_position = pos
175
+ end
176
+
177
+ def clear_highlight!
178
+ @highlighted_position = nil
179
+ end
180
+
181
+ def highlighted?
182
+ @highlighted_position.nil?
183
+ end
184
+
185
+ private
186
+
187
+ # @api private
188
+ def render_row(i)
189
+ row = grid[i]
190
+ highlighted_x, highlighted_y = highlighted_position
191
+ return row.map(&:render).join(' ') unless highlighted_y == i
192
+
193
+ (0..row_end).map do |x|
194
+ rendered = row[x].render
195
+ next rendered unless x == highlighted_x
196
+
197
+ @pastel.inverse(rendered)
198
+ end.join(' ')
199
+ end
200
+
201
+ # @api private
202
+ def render_start(count)
203
+ return 0 if count == :all || count > bottom_of_grid
204
+
205
+ bottom_of_grid - count
206
+ end
207
+ end
208
+ end
209
+ end
210
+ # rubocop:enable Naming::MethodParameterName
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ class TerminalCalendar
3
+ module Selection
4
+ class MonthPage
5
+ WEEK_ROW = %w(Su Mo Tu We Th Fr Sa).freeze
6
+
7
+ attr_reader :selection_grid, :month
8
+
9
+ def self.build(month)
10
+ new(month)
11
+ end
12
+
13
+ def initialize(month, pastel=Pastel.new)
14
+ @selection_grid = Grid.build_from_objects(month.as_rows)
15
+ @month = month
16
+ @pastel = pastel
17
+ end
18
+
19
+ # Renders the calendar as a string.
20
+ # @return [String] the rendered calendar as a string.
21
+ def render
22
+ render_rows.join("\n")
23
+ end
24
+
25
+ def redraw_lines
26
+ if selection_grid.redraw_at.nil? || selection_grid.redraw_at.negative?
27
+ calendar_header(selected: selection_grid&.redraw_at == -1).concat(selection_grid.redraw_lines)
28
+ else
29
+ selection_grid.redraw_lines
30
+ end
31
+ end
32
+
33
+ def line_count
34
+ @line_count ||= render_rows.count
35
+ end
36
+
37
+ def selection_grid_lines
38
+ selection_grid.render_lines
39
+ end
40
+
41
+ private
42
+
43
+ def render_rows
44
+ calendar_header.concat(selection_grid_lines)
45
+ end
46
+
47
+ def refresh(lines)
48
+ TTY::Cursor.clear_lines(lines)
49
+ end
50
+
51
+ def calendar_header(selected: false)
52
+ week_row = WEEK_ROW.join(' ')
53
+ month_row = month_header
54
+ pad_size = (week_row.length - month_row.length) / 2
55
+ month_row = @pastel.inverse(month_row) if selected
56
+ month_row = (' ' * pad_size).concat(month_row)
57
+ [
58
+ month_row,
59
+ week_row
60
+ ]
61
+ end
62
+
63
+ def month_header
64
+ Date::MONTHNAMES[@month.month] + " #{@month.year}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+ class TerminalCalendar
3
+ module Selection
4
+ class MonthYearDialog
5
+ extend Forwardable
6
+
7
+ # Initializes a new MonthYearDialog instance.
8
+ #
9
+ # @param start_at [Date] the starting date for the dialog (default: Date.today)
10
+ # @param input [IO] the input stream to read user input from (default: $stdin)
11
+ # @param output [IO] the output stream to write messages to (default: $stdout)
12
+ # @param env [Hash] the environment variables (default: ENV)
13
+ # @param interrupt [Symbol] the behavior when an interrupt signal is received (:error or :exit) (default: :error)
14
+ # @param track_history [Boolean] whether to track user input history (default: true)
15
+ # @return [MonthYearDialog] the initialized MonthYearDialog instance
16
+ def initialize(input: $stdin, output: $stdout, env: ENV, interrupt: :error, track_history: true,
17
+ start_at: Date.today)
18
+ @output = output
19
+ @reader = TTY::Reader.new(
20
+ input: input,
21
+ output: output,
22
+ interrupt: interrupt,
23
+ track_history: track_history,
24
+ env: env
25
+ )
26
+ initialize_carousels(start_at)
27
+ @cursor = TTY::Cursor
28
+ end
29
+
30
+ # Renders the dialog for month and year to the output
31
+ def render
32
+ month_car.render
33
+ @output.puts
34
+ year_car.render
35
+ end
36
+
37
+ def select
38
+ cursor.invisible do
39
+ render
40
+
41
+ key_capture
42
+ end
43
+ end
44
+
45
+ def redraw(amt=2)
46
+ @output.print(cursor.clear_lines(amt))
47
+ amt == 2 ? render : year_car.render
48
+ end
49
+
50
+ def_delegator :@month_car, :selected_option, :selected_month
51
+ def_delegator :@year_car, :selected_option, :selected_year
52
+
53
+ private
54
+
55
+ def key_capture
56
+ loop do
57
+ case get_key_press
58
+ when :up, :down
59
+ toggle_selected
60
+ redraw(2)
61
+ when :left
62
+ selected_car.move_left
63
+ redraw(@selected == :year ? 1 : 2)
64
+ when :right
65
+ selected_car.move_right
66
+ redraw(@selected == :year ? 1 : 2)
67
+ when :return
68
+ return Date.new(selected_year.to_i, Date::MONTHNAMES.find_index(selected_month), 1)
69
+ end
70
+ end
71
+ end
72
+
73
+ def get_key_press
74
+ press = reader.read_keypress
75
+ TTY::Reader::Keys.keys.fetch(press) { press }
76
+ end
77
+
78
+ def initialize_carousels(start_at)
79
+ @selected = :month
80
+ @month_car = TTY::Prompt::Carousel.new(Date::MONTHNAMES.compact, start_at: start_at.month - 1,
81
+ option_style: :inverse, output: output)
82
+ @year_car = TTY::Prompt::Carousel.new((0..3000).map(&:to_s), start_at: start_at.year, output: output,
83
+ margin: 0, padding: 4)
84
+ end
85
+
86
+ def selected_car
87
+ @selected == :month ? month_car : year_car
88
+ end
89
+
90
+ attr_reader(:output, :month_car, :year_car, :cursor, :reader)
91
+
92
+ def toggle_selected
93
+ @selected = @selected == :month ? :year : :month
94
+ month_car.option_style = @selected == :month ? :inverse : nil
95
+ year_car.option_style = @selected == :year ? :inverse : nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,32 @@
1
+
2
+
3
+ # frozen_string_literal: true
4
+ class TerminalCalendar
5
+ module Selection
6
+ class NullCell < Cell
7
+ def initialize
8
+ super(nil)
9
+ end
10
+
11
+ def render
12
+ ' '
13
+ end
14
+
15
+ # Checks if the object is null.
16
+ #
17
+ # @return [true] Returns true.
18
+ def null?
19
+ true
20
+ end
21
+
22
+ # @return [false] Returns false.
23
+ def selected
24
+ false
25
+ end
26
+
27
+ def toggle_selected!
28
+ false
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+ # rubocop:disable Naming::MethodParameterName
3
+ class TerminalCalendar
4
+ module Selection
5
+ class Selector
6
+ extend Forwardable
7
+
8
+ DIRECTIONS = %i(up down left right).freeze
9
+
10
+ attr_reader(:x, :y, :selection_grid)
11
+
12
+ def_delegator :@page, :selection_grid
13
+ def_delegators :selection_grid, :bottom_of_grid, :top_of_grid, :row_end
14
+
15
+ def self.build(page, initial_spot)
16
+ if initial_spot == :bottom
17
+ x, y = page.selection_grid.bottom_right_live_cell_position
18
+ else
19
+ x, y = page.selection_grid.top_left_live_cell_position
20
+ end
21
+
22
+ new(x, y, page)
23
+ end
24
+
25
+ # Initializes a new selector
26
+ #
27
+ # @param x [Integer] the x-coordinate of the selector
28
+ # @param y [Integer] the y-coordinate of the selector
29
+ # @param selection_grid [Array<Array<TerminalCalendar::Selection::Cell>>] the selection grid
30
+ # @param wrap [Symbol] (optional) the wrap direction, defaults to :all
31
+ def initialize(x, y, page, wrap: [])
32
+ @x = x
33
+ @page = page
34
+ @top_of_grid = 0
35
+ @y = y
36
+ @wrap_directions = wrap == :all ? DIRECTIONS : wrap
37
+ post_move
38
+ end
39
+
40
+ def on_header?
41
+ y == -1
42
+ end
43
+
44
+ # Toggles the selected state of the cell at the current position on the grid.
45
+ #
46
+ # @return [void]
47
+ def toggle_selected!
48
+ return unless on_grid?
49
+
50
+ selection_grid.cell(@x, @y).toggle_selected!
51
+ end
52
+
53
+ # Determines if the selector is within the selection grid.
54
+ #
55
+ # @return [Boolean] Returns true if the point is within the selection grid, false otherwise.
56
+ def on_grid?
57
+ x >= leftmost_gridsquare && x <= selection_grid.row_end &&
58
+ y >= selection_grid.top_of_grid && y <= selection_grid.bottom_of_grid
59
+ end
60
+
61
+ # Returns the leftmost grid square.
62
+ #
63
+ # @return [Integer] the leftmost grid square
64
+ def leftmost_gridsquare
65
+ 0
66
+ end
67
+
68
+ # Moves the selector in the specified direction.
69
+ #
70
+ # @param direction [Symbol] The direction to move in.
71
+ # Valid directions are: :up, :down, :left, :right.
72
+ # @raise [ArgumentError] if the specified direction is not valid.
73
+ # @return [void]
74
+ def move(direction)
75
+ fail ArgumentError.new("Unknown direction #{direction}") unless DIRECTIONS.include?(direction)
76
+
77
+ pre_move
78
+
79
+ result = send("move_#{direction}")
80
+
81
+ post_move
82
+
83
+ result
84
+ end
85
+
86
+ private
87
+
88
+ attr_writer(:x, :y)
89
+
90
+ def pre_move
91
+ selection_grid.redraw_at = nil
92
+ selection_grid.clear_highlight!
93
+ end
94
+
95
+ def post_move
96
+ unless selection_grid.redraw_at
97
+ selection_grid.redraw_at = y
98
+ end
99
+ return unless on_grid?
100
+
101
+ selection_grid.highlighted_position = [x, y]
102
+ end
103
+
104
+ def wrap(direction)
105
+ case direction
106
+ when :up
107
+ selection_grid.redraw_at = 0
108
+ self.y = bottom_of_grid
109
+ when :down
110
+ self.y = top_of_grid
111
+ when :left
112
+ self.x = row_end
113
+ when :right
114
+ self.x = leftmost_gridsquare
115
+ end
116
+ end
117
+
118
+ def move_up
119
+ if y == top_of_grid
120
+ wrap(:up) if @wrap_directions.include? :up
121
+ self.y -= 1
122
+ elsif on_header?
123
+ selection_grid.redraw_at = -2
124
+ self.y = bottom_of_grid
125
+ else
126
+ self.y -= 1
127
+ end
128
+ end
129
+
130
+ def move_down
131
+ if y == bottom_of_grid
132
+ wrap(:down)
133
+ elsif on_header?
134
+ selection_grid.redraw_at = y == -1 ? -2 : y
135
+ self.y += 1
136
+ else
137
+ self.y += 1
138
+ end
139
+ end
140
+
141
+ def move_left
142
+ if x == leftmost_gridsquare
143
+ return wrap(:left) if @wrap_directions.include? :left
144
+
145
+ :off_left
146
+ else
147
+ self.x -= 1
148
+ end
149
+ end
150
+
151
+ def move_right
152
+ if x == row_end
153
+ return wrap(:right) if @wrap_directions.include? :right
154
+
155
+ :off_right
156
+ else
157
+ self.x += 1
158
+ end
159
+ end
160
+
161
+ def redraw_header!
162
+ selection_grid.redraw_at = -2
163
+ end
164
+ end
165
+ end
166
+ end
167
+ # rubocop:enable Naming::MethodParameterName
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TerminalCalendar
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require_relative 'tty/prompt/carousel'
5
+ require_relative 'terminal_calendar/version'
6
+ require_relative 'terminal_calendar/month'
7
+ require_relative 'terminal_calendar/selection/cell'
8
+ require_relative 'terminal_calendar/selection/null_cell'
9
+ require_relative 'terminal_calendar/selection/selector'
10
+ require_relative 'terminal_calendar/selection/grid'
11
+ require_relative 'terminal_calendar/selection/month_page'
12
+ require_relative 'terminal_calendar/selection/month_year_dialog'
13
+ require_relative 'terminal_calendar/date_picker'
14
+ require_relative 'date_extensions'
15
+ require 'pastel'
16
+ require 'tty-cursor'
17
+ require 'tty-reader'
18
+
19
+ class TerminalCalendar
20
+ class Error < StandardError; end
21
+
22
+ # This method allows the user to select one or more days starting from the current month calendar.
23
+ #
24
+ # @return [Array<Date>] The selected days.
25
+ def self.date_picker
26
+ TerminalCalendar::DatePicker.pick
27
+ end
28
+
29
+ # @param month [Month] month to render, defaults to the current month
30
+ # @return [String] the month page as a string
31
+ def self.cal(month=Month.this_month())
32
+ Selection::MonthPage.build(month).render
33
+ end
34
+
35
+ # Stores a cache of month objects
36
+ # @api private
37
+ def self.all_months
38
+ @all_months ||= {}
39
+ end
40
+ end