shopify-cli 0.9.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.
Files changed (273) hide show
  1. checksums.yaml +7 -0
  2. data/.github/CODEOWNERS +1 -0
  3. data/.github/CODE_OF_CONDUCT.md +73 -0
  4. data/.github/CONTRIBUTING.md +51 -0
  5. data/.github/DESIGN.md +153 -0
  6. data/.github/ISSUE_TEMPLATE.md +38 -0
  7. data/.github/PULL_REQUEST_TEMPLATE.md +22 -0
  8. data/.github/probots.yml +3 -0
  9. data/.gitignore +19 -0
  10. data/.rubocop.yml +47 -0
  11. data/.ruby-version +1 -0
  12. data/.travis.yml +12 -0
  13. data/Gemfile +22 -0
  14. data/Gemfile.lock +77 -0
  15. data/LICENSE.md +7 -0
  16. data/README.md +13 -0
  17. data/Rakefile +101 -0
  18. data/SECURITY.md +59 -0
  19. data/Vagrantfile +17 -0
  20. data/bin/load_shopify.rb +20 -0
  21. data/bin/shopify +32 -0
  22. data/dev.yml +17 -0
  23. data/docs/Gemfile +5 -0
  24. data/docs/Gemfile.lock +248 -0
  25. data/docs/_config.yml +16 -0
  26. data/docs/_data/nav.yml +26 -0
  27. data/docs/_includes/footer.html +15 -0
  28. data/docs/_includes/head.html +19 -0
  29. data/docs/_includes/sidebar_nav.html +22 -0
  30. data/docs/_includes/toc.html +112 -0
  31. data/docs/_layouts/default.html +79 -0
  32. data/docs/app/node/commands/index.md +82 -0
  33. data/docs/app/node/index.md +35 -0
  34. data/docs/app/rails/commands/index.md +80 -0
  35. data/docs/app/rails/index.md +36 -0
  36. data/docs/core/index.md +70 -0
  37. data/docs/css/docs.css +157 -0
  38. data/docs/getting-started/index.md +61 -0
  39. data/docs/help/start-app/index.md +6 -0
  40. data/docs/images/header.png +0 -0
  41. data/docs/index.md +27 -0
  42. data/docs/installing-ruby.md +28 -0
  43. data/ext/shopify-cli/extconf.rb +27 -0
  44. data/install.sh +7 -0
  45. data/lib/docgen/class_template.md.erb +81 -0
  46. data/lib/docgen/index_template.md.erb +5 -0
  47. data/lib/docgen/markdown.rb +101 -0
  48. data/lib/graphql/admin_introspection.graphql +87 -0
  49. data/lib/graphql/all_organizations.graphql +19 -0
  50. data/lib/graphql/all_orgs_with_apps.graphql +30 -0
  51. data/lib/graphql/api_versions.graphql +6 -0
  52. data/lib/graphql/convert_dev_to_test_store.graphql +10 -0
  53. data/lib/graphql/create_app.graphql +20 -0
  54. data/lib/graphql/create_customer.graphql +9 -0
  55. data/lib/graphql/create_draft_order.graphql +8 -0
  56. data/lib/graphql/create_product.graphql +9 -0
  57. data/lib/graphql/extension_create.graphql +21 -0
  58. data/lib/graphql/extension_update_draft.graphql +18 -0
  59. data/lib/graphql/find_organization.graphql +17 -0
  60. data/lib/graphql/get_app_urls.graphql +6 -0
  61. data/lib/graphql/update_dashboard_urls.graphql +8 -0
  62. data/lib/project_types/extension/cli.rb +71 -0
  63. data/lib/project_types/extension/commands/build.rb +29 -0
  64. data/lib/project_types/extension/commands/create.rb +49 -0
  65. data/lib/project_types/extension/commands/extension_command.rb +22 -0
  66. data/lib/project_types/extension/commands/push.rb +69 -0
  67. data/lib/project_types/extension/commands/register.rb +78 -0
  68. data/lib/project_types/extension/commands/serve.rb +24 -0
  69. data/lib/project_types/extension/commands/tunnel.rb +69 -0
  70. data/lib/project_types/extension/extension_project.rb +85 -0
  71. data/lib/project_types/extension/extension_project_keys.rb +10 -0
  72. data/lib/project_types/extension/features/argo.rb +48 -0
  73. data/lib/project_types/extension/features/argo_dependencies.rb +28 -0
  74. data/lib/project_types/extension/features/argo_setup.rb +54 -0
  75. data/lib/project_types/extension/features/argo_setup_step.rb +31 -0
  76. data/lib/project_types/extension/features/argo_setup_steps.rb +53 -0
  77. data/lib/project_types/extension/features/tunnel_url.rb +20 -0
  78. data/lib/project_types/extension/forms/create.rb +52 -0
  79. data/lib/project_types/extension/forms/register.rb +48 -0
  80. data/lib/project_types/extension/messages/message_loading.rb +37 -0
  81. data/lib/project_types/extension/messages/messages.rb +126 -0
  82. data/lib/project_types/extension/models/app.rb +14 -0
  83. data/lib/project_types/extension/models/registration.rb +19 -0
  84. data/lib/project_types/extension/models/type.rb +76 -0
  85. data/lib/project_types/extension/models/types/checkout_post_purchase.rb +20 -0
  86. data/lib/project_types/extension/models/types/subscription_management.rb +20 -0
  87. data/lib/project_types/extension/models/validation_error.rb +17 -0
  88. data/lib/project_types/extension/models/version.rb +15 -0
  89. data/lib/project_types/extension/tasks/converters/registration_converter.rb +26 -0
  90. data/lib/project_types/extension/tasks/converters/validation_error_converter.rb +25 -0
  91. data/lib/project_types/extension/tasks/converters/version_converter.rb +28 -0
  92. data/lib/project_types/extension/tasks/create_extension.rb +31 -0
  93. data/lib/project_types/extension/tasks/get_apps.rb +34 -0
  94. data/lib/project_types/extension/tasks/update_draft.rb +29 -0
  95. data/lib/project_types/extension/tasks/user_errors.rb +45 -0
  96. data/lib/project_types/node/cli.rb +37 -0
  97. data/lib/project_types/node/commands/create.rb +117 -0
  98. data/lib/project_types/node/commands/deploy.rb +22 -0
  99. data/lib/project_types/node/commands/deploy/heroku.rb +91 -0
  100. data/lib/project_types/node/commands/generate.rb +51 -0
  101. data/lib/project_types/node/commands/generate/billing.rb +37 -0
  102. data/lib/project_types/node/commands/generate/page.rb +55 -0
  103. data/lib/project_types/node/commands/generate/webhook.rb +33 -0
  104. data/lib/project_types/node/commands/open.rb +16 -0
  105. data/lib/project_types/node/commands/populate.rb +23 -0
  106. data/lib/project_types/node/commands/populate/customer.rb +31 -0
  107. data/lib/project_types/node/commands/populate/draft_order.rb +28 -0
  108. data/lib/project_types/node/commands/populate/product.rb +30 -0
  109. data/lib/project_types/node/commands/serve.rb +45 -0
  110. data/lib/project_types/node/commands/tunnel.rb +39 -0
  111. data/lib/project_types/node/forms/create.rb +87 -0
  112. data/lib/project_types/node/messages/messages.rb +260 -0
  113. data/lib/project_types/rails/cli.rb +41 -0
  114. data/lib/project_types/rails/commands/create.rb +126 -0
  115. data/lib/project_types/rails/commands/deploy.rb +22 -0
  116. data/lib/project_types/rails/commands/deploy/heroku.rb +113 -0
  117. data/lib/project_types/rails/commands/generate.rb +49 -0
  118. data/lib/project_types/rails/commands/generate/webhook.rb +39 -0
  119. data/lib/project_types/rails/commands/open.rb +16 -0
  120. data/lib/project_types/rails/commands/populate.rb +23 -0
  121. data/lib/project_types/rails/commands/populate/customer.rb +31 -0
  122. data/lib/project_types/rails/commands/populate/draft_order.rb +28 -0
  123. data/lib/project_types/rails/commands/populate/product.rb +30 -0
  124. data/lib/project_types/rails/commands/serve.rb +47 -0
  125. data/lib/project_types/rails/commands/tunnel.rb +39 -0
  126. data/lib/project_types/rails/forms/create.rb +116 -0
  127. data/lib/project_types/rails/gem.rb +56 -0
  128. data/lib/project_types/rails/messages/messages.rb +283 -0
  129. data/lib/project_types/rails/ruby.rb +17 -0
  130. data/lib/project_types/script/cli.rb +76 -0
  131. data/lib/project_types/script/commands/create.rb +45 -0
  132. data/lib/project_types/script/commands/disable.rb +36 -0
  133. data/lib/project_types/script/commands/enable.rb +46 -0
  134. data/lib/project_types/script/commands/push.rb +39 -0
  135. data/lib/project_types/script/config/extension_points.yml +18 -0
  136. data/lib/project_types/script/errors.rb +16 -0
  137. data/lib/project_types/script/forms/create.rb +29 -0
  138. data/lib/project_types/script/forms/enable.rb +24 -0
  139. data/lib/project_types/script/forms/push.rb +19 -0
  140. data/lib/project_types/script/forms/script_form.rb +66 -0
  141. data/lib/project_types/script/graphql/app_script_update_or_create.graphql +27 -0
  142. data/lib/project_types/script/graphql/script_service_proxy.graphql +8 -0
  143. data/lib/project_types/script/graphql/shop_script_delete.graphql +14 -0
  144. data/lib/project_types/script/graphql/shop_script_update_or_create.graphql +28 -0
  145. data/lib/project_types/script/layers/application/build_script.rb +43 -0
  146. data/lib/project_types/script/layers/application/create_script.rb +47 -0
  147. data/lib/project_types/script/layers/application/disable_script.rb +19 -0
  148. data/lib/project_types/script/layers/application/enable_script.rb +21 -0
  149. data/lib/project_types/script/layers/application/extension_points.rb +17 -0
  150. data/lib/project_types/script/layers/application/project_dependencies.rb +34 -0
  151. data/lib/project_types/script/layers/application/push_script.rb +30 -0
  152. data/lib/project_types/script/layers/domain/errors.rb +25 -0
  153. data/lib/project_types/script/layers/domain/extension_point.rb +29 -0
  154. data/lib/project_types/script/layers/domain/push_package.rb +29 -0
  155. data/lib/project_types/script/layers/domain/script.rb +18 -0
  156. data/lib/project_types/script/layers/infrastructure/assemblyscript_dependency_manager.rb +73 -0
  157. data/lib/project_types/script/layers/infrastructure/assemblyscript_tsconfig.rb +38 -0
  158. data/lib/project_types/script/layers/infrastructure/assemblyscript_wasm_builder.rb +39 -0
  159. data/lib/project_types/script/layers/infrastructure/dependency_manager.rb +36 -0
  160. data/lib/project_types/script/layers/infrastructure/errors.rb +38 -0
  161. data/lib/project_types/script/layers/infrastructure/extension_point_repository.rb +31 -0
  162. data/lib/project_types/script/layers/infrastructure/push_package_repository.rb +47 -0
  163. data/lib/project_types/script/layers/infrastructure/script_builder.rb +34 -0
  164. data/lib/project_types/script/layers/infrastructure/script_repository.rb +89 -0
  165. data/lib/project_types/script/layers/infrastructure/script_service.rb +165 -0
  166. data/lib/project_types/script/layers/infrastructure/test_suite_repository.rb +59 -0
  167. data/lib/project_types/script/messages/messages.rb +204 -0
  168. data/lib/project_types/script/script_project.rb +37 -0
  169. data/lib/project_types/script/templates/ts/as-pect.config.js +21 -0
  170. data/lib/project_types/script/ui/error_handler.rb +136 -0
  171. data/lib/project_types/script/ui/strict_spinner.rb +22 -0
  172. data/lib/rubygems_plugin.rb +18 -0
  173. data/lib/shopify-cli/admin_api.rb +99 -0
  174. data/lib/shopify-cli/admin_api/populate_resource_command.rb +165 -0
  175. data/lib/shopify-cli/admin_api/schema.rb +32 -0
  176. data/lib/shopify-cli/api.rb +104 -0
  177. data/lib/shopify-cli/command.rb +67 -0
  178. data/lib/shopify-cli/commands.rb +28 -0
  179. data/lib/shopify-cli/commands/connect.rb +108 -0
  180. data/lib/shopify-cli/commands/create.rb +50 -0
  181. data/lib/shopify-cli/commands/help.rb +79 -0
  182. data/lib/shopify-cli/commands/logout.rb +23 -0
  183. data/lib/shopify-cli/commands/system.rb +135 -0
  184. data/lib/shopify-cli/commands/version.rb +15 -0
  185. data/lib/shopify-cli/context.rb +372 -0
  186. data/lib/shopify-cli/core.rb +9 -0
  187. data/lib/shopify-cli/core/entry_point.rb +40 -0
  188. data/lib/shopify-cli/core/executor.rb +21 -0
  189. data/lib/shopify-cli/core/help_resolver.rb +20 -0
  190. data/lib/shopify-cli/core/monorail.rb +118 -0
  191. data/lib/shopify-cli/db.rb +114 -0
  192. data/lib/shopify-cli/form.rb +40 -0
  193. data/lib/shopify-cli/git.rb +141 -0
  194. data/lib/shopify-cli/helpers.rb +5 -0
  195. data/lib/shopify-cli/helpers/haikunator.rb +92 -0
  196. data/lib/shopify-cli/heroku.rb +97 -0
  197. data/lib/shopify-cli/js_deps.rb +110 -0
  198. data/lib/shopify-cli/js_system.rb +98 -0
  199. data/lib/shopify-cli/messages/messages.rb +287 -0
  200. data/lib/shopify-cli/oauth.rb +192 -0
  201. data/lib/shopify-cli/oauth/servlet.rb +61 -0
  202. data/lib/shopify-cli/options.rb +40 -0
  203. data/lib/shopify-cli/packager.rb +116 -0
  204. data/lib/shopify-cli/partners_api.rb +114 -0
  205. data/lib/shopify-cli/partners_api/organizations.rb +32 -0
  206. data/lib/shopify-cli/process_supervision.rb +187 -0
  207. data/lib/shopify-cli/project.rb +191 -0
  208. data/lib/shopify-cli/project_type.rb +83 -0
  209. data/lib/shopify-cli/resources.rb +5 -0
  210. data/lib/shopify-cli/resources/env_file.rb +96 -0
  211. data/lib/shopify-cli/sub_command.rb +15 -0
  212. data/lib/shopify-cli/task.rb +10 -0
  213. data/lib/shopify-cli/tasks.rb +32 -0
  214. data/lib/shopify-cli/tasks/create_api_client.rb +29 -0
  215. data/lib/shopify-cli/tasks/ensure_dev_store.rb +41 -0
  216. data/lib/shopify-cli/tasks/ensure_env.rb +31 -0
  217. data/lib/shopify-cli/tasks/ensure_loopback_url.rb +20 -0
  218. data/lib/shopify-cli/tasks/update_dashboard_urls.rb +44 -0
  219. data/lib/shopify-cli/tunnel.rb +154 -0
  220. data/lib/shopify-cli/version.rb +3 -0
  221. data/lib/shopify_cli.rb +132 -0
  222. data/shopify-cli.gemspec +40 -0
  223. data/shopify.fish +12 -0
  224. data/shopify.sh +11 -0
  225. data/vendor/deps/cli-kit/REVISION +1 -0
  226. data/vendor/deps/cli-kit/lib/cli/kit.rb +60 -0
  227. data/vendor/deps/cli-kit/lib/cli/kit/autocall.rb +21 -0
  228. data/vendor/deps/cli-kit/lib/cli/kit/base_command.rb +49 -0
  229. data/vendor/deps/cli-kit/lib/cli/kit/command_registry.rb +94 -0
  230. data/vendor/deps/cli-kit/lib/cli/kit/config.rb +133 -0
  231. data/vendor/deps/cli-kit/lib/cli/kit/error_handler.rb +115 -0
  232. data/vendor/deps/cli-kit/lib/cli/kit/executor.rb +81 -0
  233. data/vendor/deps/cli-kit/lib/cli/kit/ini.rb +102 -0
  234. data/vendor/deps/cli-kit/lib/cli/kit/levenshtein.rb +82 -0
  235. data/vendor/deps/cli-kit/lib/cli/kit/logger.rb +76 -0
  236. data/vendor/deps/cli-kit/lib/cli/kit/resolver.rb +60 -0
  237. data/vendor/deps/cli-kit/lib/cli/kit/ruby_backports/enumerable.rb +6 -0
  238. data/vendor/deps/cli-kit/lib/cli/kit/support.rb +9 -0
  239. data/vendor/deps/cli-kit/lib/cli/kit/support/test_helper.rb +244 -0
  240. data/vendor/deps/cli-kit/lib/cli/kit/system.rb +207 -0
  241. data/vendor/deps/cli-kit/lib/cli/kit/util.rb +189 -0
  242. data/vendor/deps/cli-kit/lib/cli/kit/version.rb +5 -0
  243. data/vendor/deps/cli-ui/REVISION +1 -0
  244. data/vendor/deps/cli-ui/lib/cli/ui.rb +187 -0
  245. data/vendor/deps/cli-ui/lib/cli/ui/ansi.rb +153 -0
  246. data/vendor/deps/cli-ui/lib/cli/ui/box.rb +15 -0
  247. data/vendor/deps/cli-ui/lib/cli/ui/color.rb +79 -0
  248. data/vendor/deps/cli-ui/lib/cli/ui/formatter.rb +179 -0
  249. data/vendor/deps/cli-ui/lib/cli/ui/frame.rb +310 -0
  250. data/vendor/deps/cli-ui/lib/cli/ui/glyph.rb +78 -0
  251. data/vendor/deps/cli-ui/lib/cli/ui/progress.rb +88 -0
  252. data/vendor/deps/cli-ui/lib/cli/ui/prompt.rb +248 -0
  253. data/vendor/deps/cli-ui/lib/cli/ui/prompt/interactive_options.rb +472 -0
  254. data/vendor/deps/cli-ui/lib/cli/ui/prompt/options_handler.rb +24 -0
  255. data/vendor/deps/cli-ui/lib/cli/ui/spinner.rb +48 -0
  256. data/vendor/deps/cli-ui/lib/cli/ui/spinner/async.rb +40 -0
  257. data/vendor/deps/cli-ui/lib/cli/ui/spinner/spin_group.rb +241 -0
  258. data/vendor/deps/cli-ui/lib/cli/ui/stdout_router.rb +227 -0
  259. data/vendor/deps/cli-ui/lib/cli/ui/terminal.rb +36 -0
  260. data/vendor/deps/cli-ui/lib/cli/ui/truncater.rb +102 -0
  261. data/vendor/deps/cli-ui/lib/cli/ui/version.rb +5 -0
  262. data/vendor/deps/smart_properties/REVISION +1 -0
  263. data/vendor/deps/smart_properties/lib/smart_properties.rb +174 -0
  264. data/vendor/deps/smart_properties/lib/smart_properties/errors.rb +114 -0
  265. data/vendor/deps/smart_properties/lib/smart_properties/property.rb +162 -0
  266. data/vendor/deps/smart_properties/lib/smart_properties/property_collection.rb +83 -0
  267. data/vendor/deps/smart_properties/lib/smart_properties/validations.rb +8 -0
  268. data/vendor/deps/smart_properties/lib/smart_properties/validations/ancestor.rb +27 -0
  269. data/vendor/deps/smart_properties/lib/smart_properties/version.rb +3 -0
  270. data/vendor/lib/semantic/LICENSE +20 -0
  271. data/vendor/lib/semantic/semantic.rb +4 -0
  272. data/vendor/lib/semantic/version.rb +180 -0
  273. metadata +374 -0
@@ -0,0 +1,78 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ class Glyph
6
+ class InvalidGlyphHandle < ArgumentError
7
+ def initialize(handle)
8
+ @handle = handle
9
+ end
10
+
11
+ def message
12
+ keys = Glyph.available.join(',')
13
+ "invalid glyph handle: #{@handle} " \
14
+ "-- must be one of CLI::UI::Glyph.available (#{keys})"
15
+ end
16
+ end
17
+
18
+ attr_reader :handle, :codepoint, :color, :char, :to_s, :fmt
19
+
20
+ # Creates a new glyph
21
+ #
22
+ # ==== Attributes
23
+ #
24
+ # * +handle+ - The handle in the +MAP+ constant
25
+ # * +codepoint+ - The codepoint used to create the glyph (e.g. +0x2717+ for a ballot X)
26
+ # * +color+ - What color to output the glyph. Check +CLI::UI::Color+ for options.
27
+ #
28
+ def initialize(handle, codepoint, color)
29
+ @handle = handle
30
+ @codepoint = codepoint
31
+ @color = color
32
+ @char = [codepoint].pack('U')
33
+ @to_s = color.code + char + Color::RESET.code
34
+ @fmt = "{{#{color.name}:#{char}}}"
35
+
36
+ MAP[handle] = self
37
+ end
38
+
39
+ # Mapping of glyphs to terminal output
40
+ MAP = {}
41
+ # YELLOw SMALL STAR (⭑)
42
+ STAR = new('*', 0x2b51, Color::YELLOW)
43
+ # BLUE MATHEMATICAL SCRIPT SMALL i (𝒾)
44
+ INFO = new('i', 0x1d4be, Color::BLUE)
45
+ # BLUE QUESTION MARK (?)
46
+ QUESTION = new('?', 0x003f, Color::BLUE)
47
+ # GREEN CHECK MARK (✔︎)
48
+ CHECK = new('v', 0x2713, Color::GREEN)
49
+ # RED BALLOT X (✗)
50
+ X = new('x', 0x2717, Color::RED)
51
+ # Bug emoji (🐛)
52
+ BUG = new('b', 0x1f41b, Color::WHITE)
53
+ # RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (»)
54
+ CHEVRON = new('>', 0xbb, Color::YELLOW)
55
+
56
+ # Looks up a glyph by name
57
+ #
58
+ # ==== Raises
59
+ # Raises a InvalidGlyphHandle if the glyph is not available
60
+ # You likely need to create it with +.new+ or you made a typo
61
+ #
62
+ # ==== Returns
63
+ # Returns a terminal output-capable string
64
+ #
65
+ def self.lookup(name)
66
+ MAP.fetch(name.to_s)
67
+ rescue KeyError
68
+ raise InvalidGlyphHandle, name
69
+ end
70
+
71
+ # All available glyphs by name
72
+ #
73
+ def self.available
74
+ MAP.keys
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,88 @@
1
+ require 'cli/ui'
2
+
3
+ module CLI
4
+ module UI
5
+ class Progress
6
+ # A Cyan filled block
7
+ FILLED_BAR = "\e[46m"
8
+ # A bright white block
9
+ UNFILLED_BAR = "\e[1;47m"
10
+
11
+ # Add a progress bar to the terminal output
12
+ #
13
+ # https://user-images.githubusercontent.com/3074765/33799794-cc4c940e-dd00-11e7-9bdc-90f77ec9167c.gif
14
+ #
15
+ # ==== Example Usage:
16
+ #
17
+ # Set the percent to X
18
+ # CLI::UI::Progress.progress do |bar|
19
+ # bar.tick(set_percent: percent)
20
+ # end
21
+ #
22
+ # Increase the percent by 1 percent
23
+ # CLI::UI::Progress.progress do |bar|
24
+ # bar.tick
25
+ # end
26
+ #
27
+ # Increase the percent by X
28
+ # CLI::UI::Progress.progress do |bar|
29
+ # bar.tick(percent: 5)
30
+ # end
31
+ def self.progress(width: Terminal.width)
32
+ bar = Progress.new(width: width)
33
+ print CLI::UI::ANSI.hide_cursor
34
+ yield(bar)
35
+ ensure
36
+ puts bar.to_s
37
+ CLI::UI.raw do
38
+ print(ANSI.show_cursor)
39
+ end
40
+ end
41
+
42
+ # Initialize a progress bar. Typically used in a +Progress.progress+ block
43
+ #
44
+ # ==== Options
45
+ # One of the follow can be used, but not both together
46
+ #
47
+ # * +:width+ - The width of the terminal
48
+ #
49
+ def initialize(width: Terminal.width)
50
+ @percent_done = 0
51
+ @max_width = width
52
+ end
53
+
54
+ # Set the progress of the bar. Typically used in a +Progress.progress+ block
55
+ #
56
+ # ==== Options
57
+ # One of the follow can be used, but not both together
58
+ #
59
+ # * +:percent+ - Increment progress by a specific percent amount
60
+ # * +:set_percent+ - Set progress to a specific percent
61
+ #
62
+ def tick(percent: 0.01, set_percent: nil)
63
+ raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
64
+ @percent_done += percent
65
+ @percent_done = set_percent if set_percent
66
+ @percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
67
+
68
+ print to_s
69
+ print CLI::UI::ANSI.previous_line + "\n"
70
+ end
71
+
72
+ # Format the progress bar to be printed to terminal
73
+ #
74
+ def to_s
75
+ suffix = " #{(@percent_done * 100).floor}%".ljust(5)
76
+ workable_width = @max_width - Frame.prefix_width - suffix.size
77
+ filled = [(@percent_done * workable_width.to_f).ceil, 0].max
78
+ unfilled = [workable_width - filled, 0].max
79
+
80
+ CLI::UI.resolve_text [
81
+ FILLED_BAR + ' ' * filled,
82
+ UNFILLED_BAR + ' ' * unfilled,
83
+ CLI::UI::Color::RESET.code + suffix
84
+ ].join
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,248 @@
1
+ # coding: utf-8
2
+ require 'cli/ui'
3
+ require 'readline'
4
+
5
+ module CLI
6
+ module UI
7
+ module Prompt
8
+ autoload :InteractiveOptions, 'cli/ui/prompt/interactive_options'
9
+ autoload :OptionsHandler, 'cli/ui/prompt/options_handler'
10
+ private_constant :InteractiveOptions, :OptionsHandler
11
+
12
+ class << self
13
+ # Ask a user a question with either free form answer or a set of answers (multiple choice)
14
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control multiple choice selection
15
+ # Do not use this method for yes/no questions. Use +confirm+
16
+ #
17
+ # * Handles free form answers (options are nil)
18
+ # * Handles default answers for free form text
19
+ # * Handles file auto completion for file input
20
+ # * Handles interactively choosing answers using +InteractiveOptions+
21
+ #
22
+ # https://user-images.githubusercontent.com/3074765/33799822-47f23302-dd01-11e7-82f3-9072a5a5f611.png
23
+ #
24
+ # ==== Attributes
25
+ #
26
+ # * +question+ - (required) The question to ask the user
27
+ #
28
+ # ==== Options
29
+ #
30
+ # * +:options+ - Options that the user may select from. Will use +InteractiveOptions+ to do so.
31
+ # * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
32
+ # * +:is_file+ - Tells the input to use file auto-completion (tab completion)
33
+ # * +:allow_empty+ - Allows the answer to be empty
34
+ # * +:multiple+ - Allow multiple options to be selected
35
+ # * +:filter_ui+ - Enable option filtering (default: true)
36
+ # * +:select_ui+ - Enable long-form option selection (default: true)
37
+ #
38
+ # Note:
39
+ # * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+, you cannot set options with either of these keywords
40
+ # * +:default+ conflicts with +:allow_empty:, you cannot set these together
41
+ # * +:options+ conflicts with providing a +Block+ , you may only set one
42
+ # * +:multiple+ can only be used with +:options+ or a +Block+; it is ignored, otherwise.
43
+ #
44
+ # ==== Block (optional)
45
+ #
46
+ # * A Proc that provides a +OptionsHandler+ and uses the public +:option+ method to add options and their
47
+ # respective handlers
48
+ #
49
+ # ==== Return Value
50
+ #
51
+ # * If a +Block+ was not provided, the selected option or response to the free form question will be returned
52
+ # * If a +Block+ was provided, the evaluted value of the +Block+ will be returned
53
+ #
54
+ # ==== Example Usage:
55
+ #
56
+ # Free form question
57
+ # CLI::UI::Prompt.ask('What color is the sky?')
58
+ #
59
+ # Free form question with a file answer
60
+ # CLI::UI::Prompt.ask('Where is your Gemfile located?', is_file: true)
61
+ #
62
+ # Free form question with a default answer
63
+ # CLI::UI::Prompt.ask('What color is the sky?', default: 'blue')
64
+ #
65
+ # Free form question when the answer can be empty
66
+ # CLI::UI::Prompt.ask('What is your opinion on this question?', allow_empty: true)
67
+ #
68
+ # Interactive (multiple choice) question
69
+ # CLI::UI::Prompt.ask('What kind of project is this?', options: %w(rails go ruby python))
70
+ #
71
+ # Interactive (multiple choice) question with defined handlers
72
+ # CLI::UI::Prompt.ask('What kind of project is this?') do |handler|
73
+ # handler.option('rails') { |selection| selection }
74
+ # handler.option('go') { |selection| selection }
75
+ # handler.option('ruby') { |selection| selection }
76
+ # handler.option('python') { |selection| selection }
77
+ # end
78
+ #
79
+ def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true, multiple: false, filter_ui: true, select_ui: true, &options_proc)
80
+ if ((options || block_given?) && (default || is_file))
81
+ raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
82
+ end
83
+
84
+ if options || block_given?
85
+ ask_interactive(question, options, multiple: multiple, filter_ui: filter_ui, select_ui: select_ui, &options_proc)
86
+ else
87
+ ask_free_form(question, default, is_file, allow_empty)
88
+ end
89
+ end
90
+
91
+ # Asks the user for a single-line answer, without displaying the characters while typing.
92
+ # Typically used for password prompts
93
+ #
94
+ # ==== Return Value
95
+ #
96
+ # The password, without a trailing newline.
97
+ # If the user simply presses "Enter" without typing any password, this will return an empty string.
98
+ def ask_password(question)
99
+ require 'io/console'
100
+
101
+ CLI::UI.with_frame_color(:blue) do
102
+ STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
103
+
104
+ # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
105
+ # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
106
+ password = STDIN.noecho do
107
+ # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
108
+ # " 123 \n".chomp => " 123 "
109
+ STDIN.gets.chomp
110
+ end
111
+
112
+ STDOUT.puts # Complete the line
113
+
114
+ password
115
+ end
116
+ end
117
+
118
+ # Asks the user a yes/no question.
119
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
120
+ #
121
+ # ==== Example Usage:
122
+ #
123
+ # Confirmation question
124
+ # CLI::UI::Prompt.confirm('Is the sky blue?')
125
+ #
126
+ # CLI::UI::Prompt.confirm('Do a dangerous thing?', default: false)
127
+ #
128
+ def confirm(question, default: true)
129
+ ask_interactive(question, default ? %w(yes no) : %w(no yes), filter_ui: false) == 'yes'
130
+ end
131
+
132
+ private
133
+
134
+ def ask_free_form(question, default, is_file, allow_empty)
135
+ raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false') if (default && !allow_empty)
136
+
137
+ if default
138
+ puts_question("#{question} (empty = #{default})")
139
+ else
140
+ puts_question(question)
141
+ end
142
+
143
+ # Ask a free form question
144
+ loop do
145
+ line = readline(is_file: is_file)
146
+
147
+ if line.empty? && default
148
+ write_default_over_empty_input(default)
149
+ return default
150
+ end
151
+
152
+ if !line.empty? || allow_empty
153
+ return line
154
+ end
155
+ end
156
+ end
157
+
158
+ def ask_interactive(question, options = nil, multiple: false, filter_ui: true, select_ui: true)
159
+ raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
160
+
161
+ options ||= if block_given?
162
+ handler = OptionsHandler.new
163
+ yield handler
164
+ handler.options
165
+ end
166
+
167
+ raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
168
+ instructions = (multiple ? "Toggle options. " : "") + "Choose with ↑ ↓ ⏎"
169
+ instructions += ", filter with 'f'" if filter_ui
170
+ instructions += ", enter option with 'e'" if select_ui and options.size > 9
171
+ puts_question("#{question} {{yellow:(#{instructions})}}")
172
+ resp = interactive_prompt(options, multiple: multiple)
173
+
174
+ # Clear the line
175
+ print ANSI.previous_line + ANSI.clear_to_end_of_line
176
+ # Force StdoutRouter to prefix
177
+ print ANSI.previous_line + "\n"
178
+
179
+ # reset the question to include the answer
180
+ resp_text = resp
181
+ if multiple
182
+ resp_text = case resp.size
183
+ when 0
184
+ "<nothing>"
185
+ when 1..2
186
+ resp.join(" and ")
187
+ else
188
+ "#{resp.size} items"
189
+ end
190
+ end
191
+ puts_question("#{question} (You chose: {{italic:#{resp_text}}})")
192
+
193
+ return handler.call(resp) if block_given?
194
+ resp
195
+ end
196
+
197
+ # Useful for stubbing in tests
198
+ def interactive_prompt(options, multiple: false)
199
+ InteractiveOptions.call(options, multiple: multiple)
200
+ end
201
+
202
+ def write_default_over_empty_input(default)
203
+ CLI::UI.raw do
204
+ STDERR.puts(
205
+ CLI::UI::ANSI.cursor_up(1) +
206
+ "\r" +
207
+ CLI::UI::ANSI.cursor_forward(4) + # TODO: width
208
+ default +
209
+ CLI::UI::Color::RESET.code
210
+ )
211
+ end
212
+ end
213
+
214
+ def puts_question(str)
215
+ CLI::UI.with_frame_color(:blue) do
216
+ STDOUT.puts(CLI::UI.fmt('{{?}} ' + str))
217
+ end
218
+ end
219
+
220
+ def readline(is_file: false)
221
+ if is_file
222
+ Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
223
+ Readline.completion_append_character = ""
224
+ else
225
+ Readline.completion_proc = proc { |*| nil }
226
+ Readline.completion_append_character = " "
227
+ end
228
+
229
+ # because Readline is a C library, CLI::UI's hooks into $stdout don't
230
+ # work. We could work around this by having CLI::UI use a pipe and a
231
+ # thread to manage output, but the current strategy feels like a
232
+ # better tradeoff.
233
+ prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
234
+ prompt = prefix + CLI::UI.fmt('{{blue:> }}') + CLI::UI::Color::YELLOW.code
235
+
236
+ begin
237
+ line = Readline.readline(prompt, true)
238
+ print CLI::UI::Color::RESET.code
239
+ line.to_s.chomp
240
+ rescue Interrupt
241
+ CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
242
+ raise
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,472 @@
1
+ # coding: utf-8
2
+ require 'io/console'
3
+
4
+ module CLI
5
+ module UI
6
+ module Prompt
7
+ class InteractiveOptions
8
+ DONE = "Done"
9
+ CHECKBOX_ICON = { false => "☐", true => "☑" }
10
+
11
+ # Prompts the user with options
12
+ # Uses an interactive session to allow the user to pick an answer
13
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
14
+ # For more than 9 options, hitting 'e', ':', or 'G' will enter select
15
+ # mode allowing the user to type in longer numbers
16
+ # Pressing 'f' or '/' will allow the user to filter the results
17
+ #
18
+ # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
19
+ #
20
+ # ==== Example Usage:
21
+ #
22
+ # Ask an interactive question
23
+ # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
24
+ #
25
+ def self.call(options, multiple: false)
26
+ list = new(options, multiple: multiple)
27
+ selected = list.call
28
+ if multiple
29
+ selected.map { |s| options[s - 1] }
30
+ else
31
+ options[selected - 1]
32
+ end
33
+ end
34
+
35
+ # Initializes a new +InteractiveOptions+
36
+ # Usually called from +self.call+
37
+ #
38
+ # ==== Example Usage:
39
+ #
40
+ # CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
41
+ #
42
+ def initialize(options, multiple: false)
43
+ @options = options
44
+ @active = 1
45
+ @marker = '>'
46
+ @answer = nil
47
+ @state = :root
48
+ @multiple = multiple
49
+ # Indicate that an extra line (the "metadata" line) is present and
50
+ # the terminal output should be drawn over when processing user input
51
+ @displaying_metadata = false
52
+ @filter = ''
53
+ # 0-indexed array representing if selected
54
+ # @options[0] is selected if @chosen[0]
55
+ @chosen = Array.new(@options.size) { false } if multiple
56
+ @redraw = true
57
+ @presented_options = []
58
+ end
59
+
60
+ # Calls the +InteractiveOptions+ and asks the question
61
+ # Usually used from +self.call+
62
+ #
63
+ def call
64
+ calculate_option_line_lengths
65
+ CLI::UI.raw { print(ANSI.hide_cursor) }
66
+ while @answer.nil?
67
+ render_options
68
+ process_input_until_redraw_required
69
+ reset_position
70
+ end
71
+ clear_output
72
+
73
+ @answer
74
+ ensure
75
+ CLI::UI.raw do
76
+ print(ANSI.show_cursor)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def calculate_option_line_lengths
83
+ @terminal_width_at_calculation_time = CLI::UI::Terminal.width
84
+ # options will be an array of questions but each option can be multi-line
85
+ # so to get the # of lines, you need to join then split
86
+
87
+ # since lines may be longer than the terminal is wide, we need to
88
+ # determine how many extra lines would be taken up by them
89
+ max_width = (@terminal_width_at_calculation_time -
90
+ @options.count.to_s.size - # Width of the displayed number
91
+ 5 - # Extra characters added during rendering
92
+ (@multiple ? 1 : 0) # Space for the checkbox, if rendered
93
+ ).to_f
94
+
95
+ @option_lengths = @options.map do |text|
96
+ width = 1 if text.empty?
97
+ width ||= text
98
+ .split("\n")
99
+ .reject(&:empty?)
100
+ .map { |l| (l.length / max_width).ceil }
101
+ .reduce(&:+)
102
+
103
+ width
104
+ end
105
+ end
106
+
107
+ def reset_position(number_of_lines=num_lines)
108
+ # This will put us back at the beginning of the options
109
+ # When we redraw the options, they will be overwritten
110
+ CLI::UI.raw do
111
+ number_of_lines.times { print(ANSI.previous_line) }
112
+ end
113
+ end
114
+
115
+ def clear_output(number_of_lines=num_lines)
116
+ CLI::UI.raw do
117
+ # Write over all lines with whitespace
118
+ number_of_lines.times { puts(' ' * CLI::UI::Terminal.width) }
119
+ end
120
+ reset_position number_of_lines
121
+
122
+ # Update if metadata is being displayed
123
+ # This must be done _after_ the output is cleared or it won't draw over
124
+ # the entire output
125
+ @displaying_metadata = display_metadata?
126
+ end
127
+
128
+ # Don't use this in place of +@displaying_metadata+, this updates too
129
+ # quickly to be useful when drawing to the screen.
130
+ def display_metadata?
131
+ filtering? or selecting? or has_filter?
132
+ end
133
+
134
+ def num_lines
135
+ calculate_option_line_lengths if terminal_width_changed?
136
+
137
+ option_length = presented_options.reduce(0) do |total_length, (_, option_number)|
138
+ # Handle continuation markers and "Done" option when multiple is true
139
+ next total_length + 1 if option_number.nil? or option_number.zero?
140
+ total_length + @option_lengths[option_number - 1]
141
+ end
142
+
143
+ option_length + (@displaying_metadata ? 1 : 0)
144
+ end
145
+
146
+ def terminal_width_changed?
147
+ @terminal_width_at_calculation_time != CLI::UI::Terminal.width
148
+ end
149
+
150
+ ESC = "\e"
151
+ BACKSPACE = "\u007F"
152
+ CTRL_C = "\u0003"
153
+ CTRL_D = "\u0004"
154
+
155
+ def up
156
+ active_index = @filtered_options.index { |_,num| num == @active } || 0
157
+
158
+ previous_visible = @filtered_options[active_index - 1]
159
+ previous_visible ||= @filtered_options.last
160
+
161
+ @active = previous_visible ? previous_visible.last : -1
162
+ @redraw = true
163
+ end
164
+
165
+ def down
166
+ active_index = @filtered_options.index { |_,num| num == @active } || 0
167
+
168
+ next_visible = @filtered_options[active_index + 1]
169
+ next_visible ||= @filtered_options.first
170
+
171
+ @active = next_visible ? next_visible.last : -1
172
+ @redraw = true
173
+ end
174
+
175
+ # n is 1-indexed selection
176
+ # n == 0 if "Done" was selected in @multiple mode
177
+ def select_n(n)
178
+ if @multiple
179
+ if n == 0
180
+ @answer = []
181
+ @chosen.each_with_index do |selected, i|
182
+ @answer << i + 1 if selected
183
+ end
184
+ else
185
+ @active = n
186
+ @chosen[n - 1] = !@chosen[n - 1]
187
+ end
188
+ elsif n == 0
189
+ # Ignore pressing "0" when not in multiple mode
190
+ else
191
+ @active = n
192
+ @answer = n
193
+ end
194
+ @redraw = true
195
+ end
196
+
197
+ def select_bool(char)
198
+ return unless (@options - %w(yes no)).empty?
199
+ opt = @options.detect { |o| o.start_with?(char) }
200
+ @active = @options.index(opt) + 1
201
+ @answer = @options.index(opt) + 1
202
+ @redraw = true
203
+ end
204
+
205
+ def build_selection(char)
206
+ @active = (@active.to_s + char).to_i
207
+ @redraw = true
208
+ end
209
+
210
+ def chop_selection
211
+ @active = @active.to_s.chop.to_i
212
+ @redraw = true
213
+ end
214
+
215
+ def update_search(char)
216
+ @redraw = true
217
+
218
+ # Control+D or Backspace on empty search closes search
219
+ if char == CTRL_D or (@filter.empty? and char == BACKSPACE)
220
+ @filter = ''
221
+ @state = :root
222
+ return
223
+ end
224
+
225
+ if char == BACKSPACE
226
+ @filter.chop!
227
+ else
228
+ @filter += char
229
+ end
230
+ end
231
+
232
+ def select_current
233
+ # Prevent selection of invisible options
234
+ return unless presented_options.any? { |_,num| num == @active }
235
+ select_n(@active)
236
+ end
237
+
238
+ def process_input_until_redraw_required
239
+ @redraw = false
240
+ wait_for_user_input until @redraw
241
+ end
242
+
243
+ # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
244
+ def wait_for_user_input
245
+ char = read_char
246
+ @last_char = char
247
+
248
+ case char
249
+ when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
250
+ when CTRL_C ; raise Interrupt
251
+ end
252
+
253
+ case @state
254
+ when :root
255
+ case char
256
+ when ESC ; @state = :esc
257
+ when 'k' ; up
258
+ when 'j' ; down
259
+ when 'e', ':', 'G' ; start_line_select
260
+ when 'f', '/' ; start_filter
261
+ when ('0'..@options.size.to_s) ; select_n(char.to_i)
262
+ when 'y', 'n' ; select_bool(char)
263
+ when " ", "\r", "\n" ; select_current # <enter>
264
+ end
265
+ when :filter
266
+ case char
267
+ when ESC ; @state = :esc
268
+ when "\r", "\n" ; select_current
269
+ else ; update_search(char)
270
+ end
271
+ when :line_select
272
+ case char
273
+ when ESC ; @state = :esc
274
+ when 'k' ; up ; @state = :root
275
+ when 'j' ; down ; @state = :root
276
+ when 'e',':','G','q' ; stop_line_select
277
+ when '0'..'9' ; build_selection(char)
278
+ when BACKSPACE ; chop_selection # Pop last input on backspace
279
+ when ' ', "\r", "\n" ; select_current
280
+ end
281
+ when :esc
282
+ case char
283
+ when '[' ; @state = :esc_bracket
284
+ else ; raise Interrupt # unhandled escape sequence.
285
+ end
286
+ when :esc_bracket
287
+ @state = has_filter? ? :filter : :root
288
+ case char
289
+ when 'A' ; up
290
+ when 'B' ; down
291
+ when 'C' ; # Ignore right key
292
+ when 'D' ; # Ignore left key
293
+ else ; raise Interrupt # unhandled escape sequence.
294
+ end
295
+ end
296
+ end
297
+ # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
298
+
299
+ def selecting?
300
+ @state == :line_select
301
+ end
302
+
303
+ def filtering?
304
+ @state == :filter
305
+ end
306
+
307
+ def has_filter?
308
+ !@filter.empty?
309
+ end
310
+
311
+ def start_filter
312
+ @state = :filter
313
+ @redraw = true
314
+ end
315
+
316
+ def start_line_select
317
+ @state = :line_select
318
+ @active = 0
319
+ @redraw = true
320
+ end
321
+
322
+ def stop_line_select
323
+ @state = :root
324
+ @active = 1 if @active.zero?
325
+ @redraw = true
326
+ end
327
+
328
+ def read_char
329
+ raw_tty! do
330
+ getc = $stdin.getc
331
+ getc ? getc.chr : :timeout
332
+ end
333
+ rescue IOError
334
+ "\e"
335
+ end
336
+
337
+ def raw_tty!
338
+ if ENV['TEST'] || !$stdin.tty?
339
+ yield
340
+ else
341
+ $stdin.raw { yield }
342
+ end
343
+ end
344
+
345
+ def presented_options(recalculate: false)
346
+ return @presented_options unless recalculate
347
+
348
+ @presented_options = @options.zip(1..Float::INFINITY)
349
+ if has_filter?
350
+ @presented_options.select! { |option,_| option.downcase.include?(@filter.downcase) }
351
+ end
352
+
353
+ # Used for selection purposes
354
+ @filtered_options = @presented_options.dup
355
+
356
+ @presented_options.unshift([DONE, 0]) if @multiple
357
+
358
+ ensure_visible_is_active if has_filter?
359
+
360
+ while num_lines > max_lines
361
+ # try to keep the selection centered in the window:
362
+ if distance_from_selection_to_end > distance_from_start_to_selection
363
+ # selection is closer to top than bottom, so trim a row from the bottom
364
+ ensure_last_item_is_continuation_marker
365
+ @presented_options.delete_at(-2)
366
+ else
367
+ # selection is closer to bottom than top, so trim a row from the top
368
+ ensure_first_item_is_continuation_marker
369
+ @presented_options.delete_at(1)
370
+ end
371
+ end
372
+
373
+ @presented_options
374
+ end
375
+
376
+ def ensure_visible_is_active
377
+ unless presented_options.any? { |_, num| num == @active }
378
+ @active = presented_options.first&.last.to_i
379
+ end
380
+ end
381
+
382
+ def distance_from_selection_to_end
383
+ @presented_options.count - index_of_active_option
384
+ end
385
+
386
+ def distance_from_start_to_selection
387
+ index_of_active_option
388
+ end
389
+
390
+ def index_of_active_option
391
+ @presented_options.index { |_,num| num == @active }.to_i
392
+ end
393
+
394
+ def ensure_last_item_is_continuation_marker
395
+ @presented_options.push(["...", nil]) if @presented_options.last.last
396
+ end
397
+
398
+ def ensure_first_item_is_continuation_marker
399
+ @presented_options.unshift(["...", nil]) if @presented_options.first.last
400
+ end
401
+
402
+ def max_lines
403
+ CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible
404
+ end
405
+
406
+ def render_options
407
+ previously_displayed_lines = num_lines
408
+
409
+ @displaying_metadata = display_metadata?
410
+
411
+ options = presented_options(recalculate: true)
412
+
413
+ clear_output(previously_displayed_lines) if previously_displayed_lines > num_lines
414
+
415
+ max_num_length = (@options.size + 1).to_s.length
416
+
417
+ metadata_text = if selecting?
418
+ select_text = @active
419
+ select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0
420
+ "Select: #{select_text}"
421
+ elsif filtering? or has_filter?
422
+ filter_text = @filter
423
+ filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty?
424
+ "Filter: #{filter_text}"
425
+ end
426
+
427
+ if metadata_text
428
+ CLI::UI.with_frame_color(:blue) do
429
+ puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}")
430
+ end
431
+ end
432
+
433
+ options.each do |choice, num|
434
+ is_chosen = @multiple && num && @chosen[num - 1]
435
+
436
+ padding = ' ' * (max_num_length - num.to_s.length)
437
+ message = " #{num}#{num ? '.' : ' '}#{padding}"
438
+
439
+ format = "%s"
440
+ # If multiple, bold only selected. If not multiple, bold everything
441
+ format = "{{bold:#{format}}}" if !@multiple || is_chosen
442
+ format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
443
+ format = " #{format}"
444
+
445
+ message += sprintf(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
446
+ message += format_choice(format, choice)
447
+
448
+ if num == @active
449
+
450
+ color = (filtering? or selecting?) ? 'green' : 'blue'
451
+ message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
452
+ end
453
+
454
+ CLI::UI.with_frame_color(:blue) do
455
+ puts CLI::UI.fmt(message)
456
+ end
457
+ end
458
+ end
459
+
460
+ def format_choice(format, choice)
461
+ eol = CLI::UI::ANSI.clear_to_end_of_line
462
+ lines = choice.split("\n")
463
+
464
+ return eol if lines.empty? # Handle blank options
465
+
466
+ lines.map! { |l| sprintf(format, l) + eol }
467
+ lines.join("\n")
468
+ end
469
+ end
470
+ end
471
+ end
472
+ end