terminal_calendar 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 99295fbd7c1284f599d3c5d27f8dab36af0fc2e008043ec74c5bbeb863db29c4
4
+ data.tar.gz: 0ed40e47d4b06ed40068542aed1d51502fdbf7b4651db9db4345606d4460cfad
5
+ SHA512:
6
+ metadata.gz: 75270fedc4e87641e91ae3b675b15a305a2abaf6e8ae675be96f9a62a3aec534876389e0650cb8c5b5521c5b709ce20a982ebd16c2ed3dfa3d8c0369f31468ab
7
+ data.tar.gz: 63dfa4c82044092d8bfa360a91d789dc8fb4a47f52fb532fd93095d10bbb7a3d2787efabd6081c7341869cfacdec86b0aa4b84a4fad83cd17b895af773df7684
checksums.yaml.gz.sig ADDED
Binary file
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.0.3
data/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2023 Michael Cordell (https://mikecordell.com)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # TerminalCalendar
2
+
3
+ [![CircleCI](https://dl.circleci.com/status-badge/img/gh/mcordell/terminal_calendar/tree/master.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/mcordell/terminal_calendar/tree/master)
4
+ [![Coverage Status](https://coveralls.io/repos/github/mcordell/terminal_calendar/badge.svg)](https://coveralls.io/github/mcordell/terminal_calendar)
5
+ [![Gem Version](https://badge.fury.io/rb/terminal_calendar.svg)](https://badge.fury.io/rb/terminal_calendar)
6
+
7
+ > A ruby calendar and day picker for the terminal
8
+
9
+ ## Installation
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ $ bundle add terminal_calendar
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ $ gem install terminal_calendar
18
+
19
+ ## Usage
20
+
21
+ `terminal_calendar` can be used to render a calendar page similar to the linux tool `cal`:
22
+
23
+ ```ruby
24
+ require 'terminal_calendar'
25
+ # this will render a calendar for the current month to the comma(nd line
26
+ puts TerminalCalendar.cal
27
+ ```
28
+
29
+ ![cal screenshot](./_doc/cal-screenshot.png)
30
+
31
+ `terminal_calendar` can also be used as a date picker to select days from a calendar:
32
+
33
+ ![picker gif](./_doc/date-picker.gif)
34
+
35
+ In the above GIF, the following code allows dates to be picked:
36
+
37
+ ```ruby
38
+ require 'terminal_calendar'
39
+
40
+ dates = TerminalCalendar.date_picker
41
+
42
+ # where dates is an Array of Date objects
43
+ ```
44
+
45
+ When the calendar pops up the cursor is navigated with standard error keys, `Tab` toggles a day as selected, and `Enter` will end the picking session
46
+
47
+ ## Development
48
+
49
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
50
+
51
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
52
+
53
+ ## Contributing
54
+
55
+ Bug reports and pull requests are welcome on GitHub at https://github.com/mcordell/terminal_calendar.
56
+
57
+ ## License
58
+
59
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/license/mit/).
60
+
61
+ ## Copyright
62
+
63
+ Copyright (c) 2023 Michael Cordell. See LICENSE for further details.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
Binary file
Binary file
@@ -0,0 +1,26 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIEeDCCAuCgAwIBAgIBATANBgkqhkiG9w0BAQsFADBBMQ0wCwYDVQQDDARtaWtl
3
+ MRswGQYKCZImiZPyLGQBGRYLbWlrZWNvcmRlbGwxEzARBgoJkiaJk/IsZAEZFgNj
4
+ b20wHhcNMjMwODI2MjMzMjA0WhcNMjQwODI1MjMzMjA0WjBBMQ0wCwYDVQQDDARt
5
+ aWtlMRswGQYKCZImiZPyLGQBGRYLbWlrZWNvcmRlbGwxEzARBgoJkiaJk/IsZAEZ
6
+ FgNjb20wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQC+VmaK9CnkHyuD
7
+ AaIbhJyccOe6ANaQFeKrI6BZ4ZpWogi96KIAhPqnjt1GZ+DJIg9tDJbSDdRThG6O
8
+ Il5jEijiBO5yQs16aVMaOlVvZLouwhCCd+6hhJFBFGzfs6jmdutBb17VFaO0jaGP
9
+ cWyoChe/cUg2B0gGmyigjexQhmHPR6nGRbJ19P/bXNkAES8UptBLR9NNyWVataaw
10
+ P8B+v+hBa8k6jDX+sRHm2wUOhotAHHmIS4bOKSGL6VX2yLwID47svyws4f0JAuqc
11
+ V9aWJgZx0NNP/mUYfbSIV9dAJeLUyPqZbB1Ba+XDyYhVYqGKcanEmDn5dFxHfLRA
12
+ cfAU/Lt8APm/i8x7y0NWoPBL0+OiRbOxCv/Nu5P32O1JUo4/MCB6Dd0y9uzbcqye
13
+ DBYNeVPYfitBm4JrJChtG9iQ6uGhx48rE8TkJ/iDgAXPIL75dJnQvsMQOKshrcs9
14
+ FpHC52pmL8lgqgBWNyNca5jyGhH1MqD9iKwiOHFJ2DHlzqFqM/0CAwEAAaN7MHkw
15
+ CQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0OBBYEFAuiXkk79ZpM4+khmt7l
16
+ WEP+htJ1MB8GA1UdEQQYMBaBFG1pa2VAbWlrZWNvcmRlbGwuY29tMB8GA1UdEgQY
17
+ MBaBFG1pa2VAbWlrZWNvcmRlbGwuY29tMA0GCSqGSIb3DQEBCwUAA4IBgQBIUKXJ
18
+ t8raDTY43e69HgHETL6V5AY1ExQcLGAqtqV1UqhseekAAiO804LrrSsp4FcZyDL3
19
+ EL16Emfe0/80rfQIA3fQAoazUbYm2ndjeSMza5CWDMZ8hSvSqQAu1a10wInim5ya
20
+ Q3F8I8i8fLVZk5JE0zBqJUJgtCuE7dI6v9eNcMqvA0Cubn3PMVve0vz4hArOdylr
21
+ OCoZ2UW7pZexQLyjYnokultRyAhvdGGL+stDksXaoJtcCzwfleqMsIBT35QYIc/r
22
+ Hp06A67kCqMavH70GYYej3HdahEu8CjuwGMykA/jww0F+p60QvoGZOcg3hkQRUdT
23
+ oD3LQJLiK+oNexJ0i/rh1pbCMAq1sjGFGyv2ACBn+KEmmLzkDgldJfDyyOtOgomB
24
+ sHCLzDW9qhsvbL8U+XYT0o7iwdtzH6ql3vTd5mxENXSOC9rVMI2amNAJkuwpJvCA
25
+ ZDG8uvZdWY9MZGDjweK0/9rt/PItld9RW4EQJxwgyu/yU1giVs/0wZib+Uw=
26
+ -----END CERTIFICATE-----
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+ require 'date'
3
+
4
+ # Useful extensions copied from ActiveSupport::CoreExt
5
+ module DateExtensions
6
+ COMMON_YEAR_DAYS_IN_MONTH = [nil, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
7
+
8
+ module ClassMethods
9
+ def days_in_month(month, year)
10
+ if month == 2 && ::Date.gregorian_leap?(year)
11
+ 29
12
+ else
13
+ COMMON_YEAR_DAYS_IN_MONTH[month]
14
+ end
15
+ end
16
+ end
17
+
18
+ def self.included(base)
19
+ base.extend(ClassMethods)
20
+ end
21
+
22
+ def beginning_of_month
23
+ self.class.new(
24
+ year,
25
+ month,
26
+ 1
27
+ )
28
+ end
29
+
30
+ def end_of_month
31
+ self.class.new(
32
+ year,
33
+ month,
34
+ self.class.days_in_month(month, year)
35
+ )
36
+ end
37
+ end
38
+
39
+ Date.include(DateExtensions)
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TerminalCalendar
4
+ class DatePicker
5
+ extend Forwardable
6
+
7
+ def self.pick(month: TerminalCalendar::Month.this_month)
8
+ new(month).pick
9
+ end
10
+
11
+ attr_reader :month, :reader, :cursor
12
+
13
+ # @return [TerminalCalendar::Selection::Selector]
14
+ # @api private
15
+ attr_reader :selector
16
+
17
+ def initialize(month, input: $stdin, output: $stdout, env: ENV, interrupt: :error,
18
+ track_history: true)
19
+ @current_page = Selection::MonthPage.build(month)
20
+ @month_pages = [[month, @current_page]].to_h
21
+ @reader = TTY::Reader.new(
22
+ input: input,
23
+ output: output,
24
+ interrupt: interrupt,
25
+ track_history: track_history,
26
+ env: env
27
+ )
28
+ @output = output
29
+ @cursor = TTY::Cursor
30
+ @pastel = Pastel.new
31
+ end
32
+
33
+ def pick
34
+ @output.print(@cursor.hide)
35
+ render
36
+
37
+ selection_loop
38
+
39
+ month_pages.values.flat_map { |p| p.selection_grid.selected_cells.map(&:date) }
40
+ ensure
41
+ @output.print(@cursor.show)
42
+ end
43
+
44
+ def render
45
+ @output.print(current_page.render)
46
+ end
47
+
48
+ private
49
+
50
+ # rubocop:disable Metrics/MethodLength
51
+ def selection_loop
52
+ loop do
53
+ press = reader.read_keypress
54
+ kp = TTY::Reader::Keys.keys.fetch(press) { press }
55
+
56
+ case kp
57
+ when :up, :down, :left, :right
58
+ move(kp)
59
+ redraw
60
+ when :tab
61
+ toggle!
62
+ when :return
63
+ unless selector&.on_header?
64
+ break
65
+ end
66
+
67
+ select_month
68
+ end
69
+ end
70
+ end
71
+ # rubocop:enable Metrics/MethodLength
72
+
73
+ def toggle!
74
+ return if selector&.on_header?
75
+
76
+ selector&.toggle_selected!
77
+ redraw
78
+ end
79
+
80
+ def select_month
81
+ clear_page_lines
82
+ new_date = TerminalCalendar::Selection::MonthYearDialog.new(
83
+ output: @output,
84
+ start_at: current_page.month.start_of_month
85
+ ).select
86
+ new_month = TerminalCalendar::Month.new(new_date.month, new_date.year)
87
+ set_new_page(new_month)
88
+ clear_selection_dialog
89
+ initialize_selector(:bottom)
90
+ render
91
+ @output.print(@cursor.hide)
92
+ redraw
93
+ end
94
+
95
+ def clear_full_page!
96
+ @output.print(refresh(current_page.line_count))
97
+ end
98
+
99
+ def clear_page_lines
100
+ @output.print(refresh(current_page.redraw_lines.length))
101
+ end
102
+
103
+ def clear_selection_dialog
104
+ @output.print(refresh(2))
105
+ end
106
+
107
+ attr_reader :current_page, :month_pages
108
+
109
+ def_delegators(:current_page, :selection_grid)
110
+
111
+ def redraw
112
+ lines = current_page.redraw_lines
113
+ @output.print(refresh(lines.length) + lines.join("\n"))
114
+ end
115
+
116
+ def refresh(lines)
117
+ @cursor.clear_lines(lines)
118
+ end
119
+
120
+ # Moves the selector in the specified direction.
121
+ # @param direction [Symbol] The direction to move the selector in.
122
+ # Valid values are :up, :down, :left, and :right.
123
+ #
124
+ # @return [TerminalCalendar::Selection::Selector]
125
+ def move(direction)
126
+ return initialize_selector(direction) unless selector
127
+
128
+ case selector.move(direction)
129
+ when :off_left
130
+ new_month = @current_page.month.previous_month
131
+ set_new_page(new_month)
132
+ when :off_right
133
+ new_month = @current_page.month.next_month
134
+ set_new_page(new_month)
135
+ end
136
+ end
137
+
138
+ def set_new_page(new_month)
139
+ clear_full_page!
140
+ @current_page = month_pages.fetch(new_month) do
141
+ month_pages[new_month] = Selection::MonthPage.build(new_month)
142
+ end
143
+ initialize_selector(:bottom)
144
+ render
145
+ end
146
+
147
+ # Initializes the selector based on the given direction.
148
+ #
149
+ # @param direction [Symbol] The direction to initialize the selector.
150
+ # Must be one of :up, :left, :down, or :right.
151
+ #
152
+ # @return [TerminalCalendar::Selection::Selector]
153
+ #
154
+ # @api private
155
+ def initialize_selector(direction)
156
+ position = %i(up left).include?(direction) ? :bottom : :top
157
+ @selector = TerminalCalendar::Selection::Selector.build(current_page, position)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+ class TerminalCalendar
3
+ class Month
4
+ class CalendarDay
5
+ # @return [Pastel] The pastel object to use for decorating text
6
+ attr_reader :pastel
7
+
8
+ # @return [Date] The date for this calendar day
9
+ attr_reader :date
10
+
11
+ # @param date [Date] The date to be assigned to this calendar day
12
+ # @param pastel [Pastel] The pastel object to use for decorating text
13
+ def initialize(date, pastel=Pastel.new)
14
+ @date = date
15
+ @pastel = pastel
16
+ end
17
+
18
+ # Renders the day as a string.
19
+ # @return [String] the day as a string
20
+ def render
21
+ as_string = day.to_s
22
+ as_string = " #{as_string}" if as_string.length == 1
23
+ as_string = pastel.red(as_string) if today?
24
+ as_string
25
+ end
26
+
27
+ alias to_s render
28
+
29
+ # Determines if the given date is today.
30
+ #
31
+ # @return [Boolean] Returns true if the date is today, false otherwise.
32
+ def today?
33
+ date == Date.today
34
+ end
35
+
36
+ # Returns whether the object is null or not.
37
+ #
38
+ # @return [false] Returns false.
39
+ def null?
40
+ false
41
+ end
42
+
43
+ # Returns the day of the date.
44
+ #
45
+ # @return [Integer] The day of the date.
46
+ def day
47
+ date.day
48
+ end
49
+ end
50
+
51
+ class NullDay < CalendarDay
52
+ def initialize
53
+ super(nil)
54
+ end
55
+
56
+ def render
57
+ ' '
58
+ end
59
+ alias to_s render
60
+
61
+ # Returns whether the object is null or not.
62
+ #
63
+ # @return [true] Returns true.
64
+ def null?
65
+ true
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+ require 'terminal_calendar/month/calendar_day'
3
+
4
+ class TerminalCalendar
5
+ class Month
6
+ # Returns an array of rows representing the data.
7
+ # @return [Array<Array>] an array of arrays, where each inner array represents a row of data
8
+ attr_reader :as_rows
9
+
10
+ attr_reader :month, :year, :start_of_month, :end_of_month
11
+
12
+ DAYS_IN_THE_WEEK = 7
13
+
14
+ def self.this_month
15
+ new(Date.today.month, Date.today.year)
16
+ end
17
+
18
+ def self.new(*arguments, &block)
19
+ month = arguments[0].to_i
20
+ fail ArgumentError.new('Month number must be 1-12') unless month >= 1 && month <= 12
21
+
22
+ year = arguments[1].to_i
23
+ key = [year, month]
24
+
25
+ TerminalCalendar.all_months.fetch(key) do
26
+ instance = allocate
27
+ instance.send(:initialize, *arguments, &block)
28
+ TerminalCalendar.all_months[key] = instance.freeze
29
+ end
30
+ end
31
+
32
+ def initialize(month, year)
33
+ @month = month.to_i
34
+ @year = year.to_i
35
+ @start_of_month = Date.new(year, month, 1)
36
+ @end_of_month = @start_of_month.end_of_month
37
+ @as_rows ||= build_rows
38
+ end
39
+
40
+ def next_month
41
+ new_month = month == 12 ? 1 : month + 1
42
+ new_year = new_month == 1 ? year + 1 : year
43
+ self.class.new(new_month, new_year)
44
+ end
45
+
46
+ def previous_month
47
+ new_month = month == 1 ? 12 : month - 1
48
+ new_year = new_month == 12 ? year - 1 : year
49
+ self.class.new(new_month, new_year)
50
+ end
51
+
52
+ def ==(other)
53
+ eql?(other)
54
+ end
55
+
56
+ def eql?(other)
57
+ other.month == month && other.year == year
58
+ end
59
+
60
+ def hash
61
+ [month, year].hash
62
+ end
63
+
64
+ def render
65
+ TerminalCalendar::Selection::MonthPage.new(self).render
66
+ end
67
+
68
+ private
69
+
70
+ # rubocop:disable Metrics/AbcSize
71
+ def build_rows
72
+ current_row = empty_week
73
+ [].tap do |rows|
74
+ (start_of_month..end_of_month).each do |d|
75
+ if d.wday.zero? && !current_row.empty?
76
+ rows.push(current_row)
77
+ current_row = empty_week
78
+ end
79
+ current_row[d.wday] = CalendarDay.new(d, pastel).freeze
80
+ end
81
+ rows.push(current_row) unless current_row.empty?
82
+ end
83
+ end
84
+ # rubocop:enable Metrics/AbcSize
85
+
86
+ def null_date
87
+ @null_date ||= NullDay.new.freeze
88
+ end
89
+
90
+ def empty_week
91
+ Array.new(DAYS_IN_THE_WEEK) { null_date }
92
+ end
93
+
94
+ def pastel
95
+ @pastel ||= Pastel.new(enabled: true)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ class TerminalCalendar
3
+ module Selection
4
+ DEFAULT_SELECTED_STYLE = 'XX'
5
+
6
+ class Cell
7
+ # @return [Object] the object that this cell wraps
8
+ attr_reader(:underlying_object)
9
+ # @return [Boolean] whether this cell is currently selected
10
+ attr_reader(:selected)
11
+ # @return [String] the rendered style of a cell that is selected
12
+ attr_reader(:selected_style)
13
+
14
+ # Builds a new cell object based on the given object. If the
15
+ # underlying object is Null, a NullCell will be returned
16
+ # @param obj [Object] The object to build the cell from.
17
+ # @return [Cell] The newly built cell object.
18
+ def self.build(obj)
19
+ obj.null? ? NullCell.new(obj) : new(obj)
20
+ end
21
+
22
+ def initialize(underlying_object, selected: false, selected_style: DEFAULT_SELECTED_STYLE)
23
+ @underlying_object = underlying_object
24
+ @selected = selected
25
+ @selected_style = selected_style
26
+ end
27
+
28
+ # Renders the selected_style string if it is selected, otherwise
29
+ # returns the result of rendering the underlying object.
30
+ #
31
+ # @return [String] The cell's rendered content
32
+ def render
33
+ return underlying_object.render unless selected?
34
+
35
+ selected_style
36
+ end
37
+
38
+ # Checks if the object is null.
39
+ #
40
+ # @return [Boolean] Returns false.
41
+ def null?
42
+ false
43
+ end
44
+
45
+ # Toggles the selected state of the object.
46
+ # @return [Boolean] the new selected state of the object
47
+ def toggle_selected!
48
+ @selected = !@selected
49
+ end
50
+
51
+ alias_method :selected?, :selected
52
+
53
+ # Calls the missing method on the underlying object.
54
+ #
55
+ # @param method [Symbol] the name of the missing method
56
+ # @param args [Array] the arguments passed to the missing method
57
+ # @param block [Proc] the block passed to the missing method
58
+ # @return [Object] the result of calling the missing method on the underlying object
59
+ # @raise [NoMethodError] if the underlying object does not respond to the missing method
60
+ def method_missing(method, *args, &block)
61
+ underlying_object.send(method, *args, &block)
62
+ end
63
+
64
+ # Checks if the underlying object responds to the missing method.
65
+ #
66
+ # @param method [Symbol] the name of the missing method
67
+ # @param include_all [Boolean] whether to include private methods in the check
68
+ # @return [Boolean] true if the underlying object responds to the missing method, super otherwise
69
+ def respond_to_missing?(method, include_all)
70
+ underlying_object.respond_to?(method) || super
71
+ end
72
+ end
73
+ end
74
+ end