scryglass 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,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ ## Hexes takes care of some console/view/IO work for Scryglass
4
+ module Hexes
5
+ using ClipStringRefinement
6
+ using AnsilessStringRefinement
7
+ using ConstantDefinedStringRefinement
8
+
9
+ def self.simple_screen_slice(screen_string)
10
+ screen_height, screen_width = $stdout.winsize
11
+
12
+ split_lines = screen_string.split("\n")
13
+
14
+ ## Here we cut down the (rectangular if opacified) display array in both
15
+ ## dimensions (into a smaller rectangle), as needed, to fit the view.
16
+ sliced_lines = split_lines.map do |string|
17
+ ansi_length = string.length - string.ansiless_length
18
+ slice_length = screen_width + ansi_length
19
+ string[0, slice_length]
20
+ end
21
+ sliced_list = sliced_lines[0, screen_height]
22
+
23
+ sliced_list.join("\n")
24
+ end
25
+
26
+ def self.opacify_screen_string(screen_string)
27
+ screen_height, screen_width = $stdout.winsize
28
+
29
+ split_lines = screen_string.split("\n")
30
+ rows_filled = split_lines.count
31
+
32
+ blank_rows_at_bottom = [screen_height - rows_filled, 0].max
33
+
34
+ # This takes all the unfilled spaces left after a newline, and makes them
35
+ # real spaces, so they'll overwrite whatever was there a second ago. Thus
36
+ # I don't have to worry about clearing the screen all the time, which was
37
+ # seemingly causing console chaff and some flickering.
38
+ side_filled_string = split_lines.map do |line|
39
+ margin_to_fill = screen_width - line.ansiless.length
40
+ line + (' ' * margin_to_fill)
41
+ end.join("\e[00m\n") # Also turns off ANSI text formatting at the end of
42
+ # each line, in case a formatted string had its "turn off formatting" code
43
+ # cut off from the end. (Reducing the need to end with one at all).
44
+
45
+ blank_line = "\n" + (' ' * screen_width)
46
+
47
+ side_filled_string + (blank_line * blank_rows_at_bottom)
48
+ end
49
+
50
+ def self.stdout_rescue
51
+ @preserved_stdout_dup = $stdout.dup
52
+
53
+ begin
54
+ yielded_return = yield
55
+ rescue => e
56
+ # `e` is raised again in the `ensure` block after stdout is safely reset.
57
+ ensure
58
+ $stdout = @preserved_stdout_dup
59
+ raise e if e
60
+ end
61
+
62
+ yielded_return
63
+ end
64
+
65
+ def self.capture_io(char_limit: nil)
66
+ stdout_rescue do # Ensures that $stdout is reset no matter what
67
+ temporary_io_channel = StringIO.new
68
+ $stdout = temporary_io_channel
69
+ Thread.abort_on_exception = true # So threads can return error text at all
70
+
71
+ if char_limit
72
+ background_output_thread = Thread.new { yield } # It's assumed that the
73
+ # yielded block will be printing something somewhat promptly.
74
+
75
+ sleep 0.05 # Give it a head start (Sometimes makes a difference!)
76
+
77
+ while temporary_io_channel.size < char_limit
78
+ io_size = temporary_io_channel.size
79
+ sleep 0.05
80
+ new_io_size = temporary_io_channel.size
81
+ break if new_io_size == io_size
82
+ end
83
+ background_output_thread.terminate
84
+ else
85
+ yield
86
+ end
87
+
88
+ temporary_io_channel.rewind
89
+ captured_output = temporary_io_channel.read
90
+ captured_output = captured_output.clip_at(char_limit) if char_limit
91
+
92
+ captured_output
93
+ end
94
+ end
95
+
96
+ def self.overwrite_screen(screen_string)
97
+ csi = "\e["
98
+ $stdout.write "#{csi}s" # Saves terminal cursor position
99
+ $stdout.write "#{csi}1;1H" # Moves terminal cursor to top left corner
100
+
101
+ $stdout.print "\r#{screen_string}"
102
+ $stdout.write "#{csi}u" # Restores saved terminal cursor position
103
+ end
104
+
105
+ def self.hide_db_outputs
106
+ necessary_constants = ['Logger', 'ActiveRecord::Base']
107
+ necessary_constants_defined = necessary_constants.all?(&:constant_defined?)
108
+ return yield unless necessary_constants_defined
109
+
110
+ rails_logger_defined = 'Rails'.constant_defined? && Rails.try(:logger).present?
111
+
112
+ ## These are purposefully preserved as global variables so retrieval, in
113
+ ## debugging or errored usage, is as easy as possible.
114
+ $preserved_ar_base_logger = ActiveRecord::Base.logger.dup
115
+ $preserved_rails_logger = Rails.logger.dup if rails_logger_defined
116
+
117
+ begin
118
+ ## Now we create an unused dump string to serve as the output
119
+ ignored_output = StringIO.new
120
+ ignored_log = Logger.new(ignored_output)
121
+ ActiveRecord::Base.logger = ignored_log
122
+ Rails.logger = ignored_log if rails_logger_defined
123
+
124
+ yielded_return = yield
125
+ rescue => e
126
+ # `e` is raised again in the `ensure` after displays are safely reset.
127
+ ensure
128
+ ActiveRecord::Base.logger = $preserved_ar_base_logger
129
+ Rails.logger = $preserved_rails_logger if rails_logger_defined
130
+ raise e if e
131
+ end
132
+
133
+ yielded_return
134
+ end
135
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ ## Prog is a simple progress bar for tracking one or more nested or simultaneous
4
+ ## processes. Tasks fit evenly and dynamically into a Pipe, which can then
5
+ ## be displayed at a chosen width.
6
+ module Prog
7
+ class Pipe
8
+ attr_accessor :tasks
9
+ attr_accessor :highest_count
10
+
11
+ def initialize
12
+ self.tasks = []
13
+ self.highest_count = 0
14
+ end
15
+
16
+ def to_s(length: $stdout.winsize[1])
17
+ return ' ' * length if tasks.count.zero?
18
+
19
+ unused_length = length
20
+ self.highest_count = [highest_count, tasks.count].max
21
+
22
+ ## Set up the first barrier
23
+ display_string = +'|'
24
+ unused_length -= 1
25
+
26
+ ## Get a first pass at equal task length
27
+ # first_pass_task_length = unused_length/working_tasks.count
28
+ first_pass_task_length = unused_length / highest_count
29
+ if first_pass_task_length < 2
30
+ raise "Prog::Pipe length (#{length}) too small to " \
31
+ "fit all tasks (#{tasks.count})"
32
+ end
33
+ tasks.each do |task|
34
+ task.working_length = first_pass_task_length
35
+ end
36
+
37
+ ## Distribute the remaining space evenly among the first n tasks
38
+ remaining_space = unused_length - (first_pass_task_length * highest_count)
39
+ tasks[0...remaining_space].each { |task| task.working_length += 1 }
40
+
41
+ tasks.each do |task|
42
+ display_string << task.to_s
43
+ end
44
+ display_string.ljust(length, ' ')
45
+ end
46
+
47
+ def <<(task)
48
+ tasks << task
49
+ task.pipe = self
50
+ task.force_finish if task.max_count.zero?
51
+ end
52
+ end
53
+
54
+ class Task
55
+ attr_accessor :pipe, :max_count, :current_count
56
+ attr_accessor :working_length
57
+
58
+ def initialize(max_count:)
59
+ self.max_count = max_count
60
+ self.current_count = 0
61
+ self.working_length = nil # (Only set by Prog::Pipe)
62
+ end
63
+
64
+ def tick(number_of_ticks = 1)
65
+ self.current_count += number_of_ticks
66
+
67
+ force_finish if current_count >= max_count
68
+ end
69
+
70
+ def force_finish
71
+ pipe.tasks.delete(self)
72
+ pipe.highest_count = 0 if pipe.tasks.empty?
73
+ end
74
+
75
+ def to_s
76
+ progress_ratio = current_count / max_count.to_f
77
+
78
+ unused_length = working_length
79
+ display_string = +'|' # (not frozen)
80
+ unused_length -= 1
81
+
82
+ filled_cells = (unused_length * progress_ratio).floor
83
+ fill_bar = ('=' * filled_cells).ljust(unused_length, ' ')
84
+ display_string.prepend(fill_bar)
85
+
86
+ display_string
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,11 @@
1
+ module AnsilessStringRefinement
2
+ refine String do
3
+ def ansiless
4
+ gsub(/\e\[[\d\;]*m/, '')
5
+ end
6
+
7
+ def ansiless_length
8
+ ansiless.length
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,67 @@
1
+ module ArrayFitToRefinement
2
+ refine Array do
3
+ using ClipStringRefinement
4
+ using AnsilessStringRefinement
5
+ # Warning: Still not going to work nicely if a string ends in an ansi code!
6
+ def fit_to(string_length_goal, fill: ' ', ignore_ansi_codes: true)
7
+ string_array = self.map(&:to_s) # This also acts to dup
8
+ length_method = ignore_ansi_codes ? :ansiless_length : :length
9
+ length_result = string_array.join('').send(length_method)
10
+
11
+
12
+ if length_result > string_length_goal
13
+ string_array.compress_to(string_length_goal, ignore_ansi_codes: ignore_ansi_codes)
14
+ elsif length_result < string_length_goal
15
+ string_array.expand_to(string_length_goal, ignore_ansi_codes: ignore_ansi_codes, fill: fill)
16
+ else # If it joins to the right length already, we still want to return the expected number of strings.
17
+ spacers = [''] * (string_array.count - 1)
18
+ string_array.zip(spacers).flatten.compact
19
+ end
20
+ end
21
+
22
+ # Warning: Still not going to work nicely if a string ends in an ansi code!
23
+ def compress_to(string_length_goal, ignore_ansi_codes:)
24
+ working_array = self.map(&:to_s)
25
+ original_string_count = self.count
26
+ spacers = [''] * (original_string_count - 1)
27
+ length_method = ignore_ansi_codes ? :ansiless_length : :length
28
+
29
+ ## Ensure the strings are short enough to fit:
30
+ slider = 0
31
+ while working_array.join('').send(length_method) > string_length_goal
32
+ longest_string_length = working_array.map { |s| s.send(length_method) }.max
33
+ slider_index = slider % working_array.count
34
+ if working_array[slider_index].send(length_method) >= longest_string_length
35
+ working_array[slider_index] =
36
+ working_array[slider_index].clip_at(working_array[slider_index].send(length_method) - 1,
37
+ ignore_ansi_codes: ignore_ansi_codes)
38
+ end
39
+ slider += 1
40
+ end
41
+
42
+ working_array.zip(spacers).flatten.compact
43
+ end
44
+
45
+ def expand_to(string_length_goal, ignore_ansi_codes:, fill:)
46
+ original_string_count = self.count
47
+ working_array = self.map(&:to_s)
48
+ spacers = [''] * (original_string_count - 1)
49
+ length_method = ignore_ansi_codes ? :ansiless_length : :length
50
+
51
+ ## Ensure the spacers are large enough to fill out to string_length_goal
52
+ space_to_fill = string_length_goal - working_array.join('').send(length_method)
53
+ first_pass_spacer_length = space_to_fill / spacers.count
54
+ spacers.map! { fill * first_pass_spacer_length }
55
+
56
+ ## Distribute the remaining space evenly among the last n spacers
57
+ remaining_space = space_to_fill - spacers.join('').send(length_method)
58
+ if remaining_space.positive?
59
+ spacers =
60
+ spacers[0...-remaining_space] +
61
+ spacers[-remaining_space..-1].map! { |spacer| spacer + ' ' } #each { |task| task.working_length += 1 }
62
+ end
63
+
64
+ working_array.zip(spacers).flatten.compact
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,27 @@
1
+ module ClipStringRefinement
2
+ refine String do
3
+ using AnsilessStringRefinement
4
+
5
+ # Warning: Still not going to work nicely if a string ends in an ansi code!
6
+ def clip_at(clip_length, ignore_ansi_codes: false)
7
+ length_method = ignore_ansi_codes ? :ansiless_length : :length
8
+ original_length = send(length_method)
9
+ ansi_length = ignore_ansi_codes ? length - ansiless_length : 0
10
+ slice_length = clip_length + ansi_length
11
+ clipped_string = self[0...slice_length]
12
+ if clipped_string.send(length_method) < original_length
13
+ clipped_string = clipped_string.mark_as_abbreviated
14
+ end
15
+
16
+ clipped_string
17
+ end
18
+
19
+ # Warning: Still not going to work nicely if a string ends in an ansi code!
20
+ def mark_as_abbreviated
21
+ self_dup = dup
22
+ self_dup[-1] = '…' if self_dup[-1]
23
+ self_dup[-2] = '…' if self_dup[-2]
24
+ self_dup
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ module ConstantDefinedStringRefinement
2
+ refine String do
3
+ def constant_defined?
4
+ begin
5
+ !!Object.const_get(self)
6
+ rescue
7
+ false # Swallow expected error if not defined
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ ## Bookkeeping and external tools:
5
+ require "scryglass/version"
6
+ require 'active_support/core_ext/object/blank' # This gives us `.present?` and `.blank?` # https://stackoverflow.com/questions/4648684/how-to-use-present-in-ruby-projects
7
+ require 'io/console'
8
+ require 'pp'
9
+
10
+ ## Refinements and sub-tools:
11
+ require 'refinements/ansiless_string_refinement'
12
+ require 'refinements/clip_string_refinement'
13
+ require 'refinements/constant_defined_string_refinement'
14
+ require 'refinements/array_fit_to_refinement'
15
+ require 'hexes'
16
+ require 'prog'
17
+
18
+ ## Core gem components:
19
+ require 'scryglass/config'
20
+ require 'scryglass/ro'
21
+ require 'scryglass/ro_builder'
22
+ require 'scryglass/session'
23
+ require 'scryglass/view_wrapper'
24
+ require 'scryglass/view_panel'
25
+ require 'scryglass/tree_panel'
26
+ require 'scryglass/lens_panel'
27
+
28
+ ## Testing and Demoing:
29
+ require 'example_material.rb'
30
+
31
+ module Scryglass
32
+ HELP_SCREEN = <<~'HELPSCREENPAGE'
33
+ q : Quit Scry ? : Cycle help panels (1/2)
34
+
35
+ BASIC NAVIGATION: · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·
36
+ · ·
37
+ · UP / DOWN : Navigate (You can type a number first) ·
38
+ · RIGHT : Expand current or selected row(s) ·
39
+ · LEFT : Collapse current or selected row(s) ·
40
+ · ·
41
+ · ENTER : Close Scry, returning current or selected object(s) (Key or Value) ·
42
+ · ·
43
+ · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·
44
+
45
+ INSPECTING WITH LENS VIEW: · · · · · · · · · · · · · ·
46
+ · ·
47
+ · SPACEBAR : Toggle Lens View ·
48
+ · l : Cycle through lens types ·
49
+ · L : Toggle subject (Key/Value of row) ·
50
+ · ·
51
+ · · · · · · · · · · · · · · · · · · · · · · · · · · ·
52
+
53
+ MORE NAVIGATION: · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·
54
+ · ·
55
+ · [w] : Move view window 0 : Reset view location ·
56
+ · [a][s][d] (ALT increases speed) (Press again: reset cursor) ·
57
+ · ·
58
+ · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·
59
+ HELPSCREENPAGE
60
+
61
+ HELP_SCREEN_ADVANCED = <<~'HELPSCREENADVANCEDPAGE'
62
+ q : Quit Scry ? : Cycle help panels (2/2)
63
+
64
+ ADVANCED: · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·
65
+ · DIGGING DEEPER: ·
66
+ · For current or selected row(s)... ·
67
+ · @ : Build instance variable sub-rows ·
68
+ · . : Build ActiveRecord association sub-rows ·
69
+ · ( : Attempt to smart-build sub-rows, if Enumerable. Usually '@' is preferable. ·
70
+ · ·
71
+ · SELECTING ROWS: ·
72
+ · * : Select/Deselect ALL rows ·
73
+ · | : Select/Deselect every sibling row under the same parent row ·
74
+ · - : Select/Deselect current row ·
75
+ · ·
76
+ · TEXT SEARCH: ·
77
+ · / : Begin a text search (in tree view) ·
78
+ · n : Move to next search result ·
79
+ · ·
80
+ · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ·
81
+ HELPSCREENADVANCEDPAGE
82
+
83
+ def self.config
84
+ @config ||= Config.new
85
+ end
86
+
87
+ def self.reset_config
88
+ @config = Config.new
89
+ end
90
+
91
+ def self.configure
92
+ yield(config)
93
+ end
94
+
95
+ def self.load_silently
96
+ begin
97
+ add_kernel_methods
98
+ { success: true, error: nil }
99
+ rescue => e
100
+ { success: false, error: e }
101
+ end
102
+ end
103
+
104
+ def self.load
105
+ caller_path = caller_locations.first.path
106
+
107
+ silent_load_result = Scryglass.load_silently
108
+
109
+ if silent_load_result[:success]
110
+ puts "(Scryglass is loaded, from `#{caller_path}`. Use `Scryglass.help` for help getting started)"
111
+ else
112
+ puts "(Scryglass failed to load, from `#{caller_path}` " \
113
+ "getting `#{silent_load_result[:error].message}`)"
114
+ end
115
+
116
+ silent_load_result
117
+ end
118
+
119
+ def self.help
120
+ console_help = <<~"CONSOLE_HELP" # Bolded with \e[1m
121
+ \e[1m
122
+ | To prep Scryglass, call `Scryglass.load`
123
+ | (Or add it to .irbrc & .pryrc)
124
+ |
125
+ | To start a Scry Session, call:
126
+ | > scry my_object OR
127
+ | > my_object.scry
128
+ |
129
+ | To resume the previous session: (in same console session)
130
+ | > scry OR
131
+ | > scry_resume (if you're in a breakpoint pry)
132
+ \e[0m
133
+ CONSOLE_HELP
134
+
135
+ puts console_help
136
+ end
137
+
138
+ private
139
+
140
+ def self.add_kernel_methods
141
+ Kernel.module_eval do
142
+ def scry(arg = nil, actions = nil) # `actions` can't be a keyword arg due
143
+ # to this ruby issue: https://bugs.ruby-lang.org/issues/8316
144
+
145
+ receiver = self unless to_s == 'main'
146
+ # As in: `receiver.scry`,
147
+ # and no receiver means scry was called on 'main', (unless self is
148
+ # different in the because you've pry'd into something!)
149
+
150
+ seed_object = arg || receiver
151
+
152
+ $scry_session = Scryglass::Session.new(seed_object) if seed_object
153
+ # If it's been given an arg or receiver, create new session!
154
+ # The global variable is purposeful, and not accessible outside of
155
+ # the one particular console instance.
156
+
157
+ scry_resume(actions) # Pick up the new or previous session
158
+ end
159
+
160
+ # For the user, this is mainly just for pry sessions where `self` isn't `main`
161
+ def scry_resume(actions = nil)
162
+ Scryglass.config.validate!
163
+
164
+ no_previous_session = $scry_session.nil?
165
+ if no_previous_session
166
+ raise ArgumentError,
167
+ '`scry` requires either an argument, a receiver, or a past' \
168
+ 'session to reopen. try `Scryglass.help`'
169
+ end
170
+
171
+ Hexes.stdout_rescue do
172
+ $scry_session.run_scry_ui(actions: actions)
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end