scryglass 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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