Files
feather-case/lib/feather_case_wizard/wizard.rb
T
2026-08-04 12:27:31 -07:00

347 lines
13 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# frozen_string_literal: true
module FeatherCaseWizard
class Wizard
attr_reader :ui
def initialize(ui: HighLine.new)
@ui = PromptingUI.wrap(ui)
end
def create
name = required_text("Case name")
slug = ask_slug(slugify(name))
configure(Config.create(name: name, slug: slug), new_case: true)
end
def edit(config)
ui.say("Editing #{config.name} (`#{config.slug}`)")
config.data["name"] = text("Case name", config.name)
configure(config, new_case: false)
end
def confirm_build(config)
dims = config.dimensions
ui.say(ui.color("Build summary", :bold))
ui.say(" Case: #{config.name} (#{config.slug})")
ui.say(" Profile: #{config.parameters["top_profile"]}")
ui.say(" Wings: #{config.parameters["wing_count"]}")
ui.say(" Battery: #{yes_no(config.parameters["battery_enabled"])}")
ui.say(" Tripod insert: #{yes_no(config.parameters["tripod_insert_enabled"])}")
ui.say(" Cutouts: #{config.cutouts.length}")
ui.say(" Size: #{format("%.1f × %.1f × %.1f mm", dims["length"], dims["width"],
dims["height"] + config.parameters["lid_thickness"])}")
ui.say(" Parts: #{config.printable_parts.join(", ")}")
ui.say(" Formats: #{config.outputs["formats"].join(", ")}")
ui.agree("Generate this case? (y/n) ") { |question| question.default = "yes" }
end
private
def configure(config, new_case:)
choose_formats(config)
choose_profile(config)
choose_stack(config)
choose_end_clearance(config)
choose_features(config)
choose_lid_artwork(config)
manage_cutouts(config)
choose_coupons(config)
advanced(config) if ui.agree("Adjust advanced dimensions? (y/n) ") do |question|
question.default = "no"
end
config.validate!
config
rescue ConfigError => e
ui.say(ui.color("Configuration error: #{e.message}", :red))
retry unless new_case && !ui.agree("Return to the questions? (y/n) ") { |q| q.default = "yes" }
raise
end
def choose_formats(config)
current = config.outputs["formats"].sort
label = current == %w[3mf stl] ? "both" : current.first
selected = ui.choose("Export format (current: #{label})") do |menu|
menu.choice("3MF") { ["3mf"] }
menu.choice("STL") { ["stl"] }
menu.choice("Both 3MF and STL") { %w[3mf stl] }
menu.default = (label == "both" ? 3 : (label == "stl" ? 2 : 1)).to_s
end
config.outputs["formats"] = selected
end
def choose_profile(config)
current = config.parameters["top_profile"]
config.parameters["top_profile"] = ui.choose("Top FeatherWing profile") do |menu|
menu.choice("Generic / other") { "generic" }
menu.choice("GPS") { "gps" }
menu.choice("128×64 OLED") { "oled128x64" }
menu.default = {"generic" => 1, "gps" => 2, "oled128x64" => 3}.fetch(current).to_s
end
end
def choose_stack(config)
p = config.parameters
p["wing_count"] = integer("Number of FeatherWings", p["wing_count"], 1..8)
measured = p["stack_height_override"].positive?
if ui.agree("Use a measured Feather-PCB-top to top-Wing-PCB stack height? (y/n) ") do |question|
question.default = measured ? "yes" : "no"
end
p["stack_height_override"] = number(
"PCB-top to PCB-top height in mm", measured ? p["stack_height_override"] :
p["wing_count"] * p["wing_pitch"], min: 0.1
)
else
p["stack_height_override"] = 0.0
end
end
def choose_features(config)
p = config.parameters
p["usb_opening_enabled"] = agreement(
"Include front USB-C programming opening?", p["usb_opening_enabled"]
)
p["usb_guide_enabled"] = if p["usb_opening_enabled"]
agreement("Include inward USB plug guide?",
p["usb_guide_enabled"])
else
false
end
p["battery_enabled"] = agreement("Include 18650 battery compartment and switch?",
p["battery_enabled"])
p["tripod_insert_enabled"] = agreement("Include centered 1/4-20 insert?",
p["tripod_insert_enabled"])
return unless p["top_profile"] == "oled128x64"
ui.say("OLED captive buttons")
p["button_a"] = agreement("Enable button A?", p["button_a"])
p["button_b"] = agreement("Enable button B?", p["button_b"])
p["button_c"] = agreement("Enable button C?", p["button_c"])
p["button_reset"] = agreement("Enable reset button?", p["button_reset"])
end
def choose_end_clearance(config)
p = config.parameters
defaults = Config::DEFAULT_PARAMETERS
custom = p["usb_board_wall_gap"] != defaults["usb_board_wall_gap"] ||
p["board_rear_clearance"] != defaults["board_rear_clearance"]
enabled = ui.agree("Use extra clearance beyond the Feather PCB ends? (y/n) ") do |question|
question.default = custom ? "yes" : "no"
end
unless enabled
p["usb_board_wall_gap"] = defaults["usb_board_wall_gap"]
p["board_rear_clearance"] = defaults["board_rear_clearance"]
return
end
p["usb_board_wall_gap"] = number(
"USB/front PCB-edge to inner-wall clearance (mm)",
p["usb_board_wall_gap"], min: 0.1
)
p["board_rear_clearance"] = number(
"Rear PCB-edge to inner-wall clearance (mm)",
p["board_rear_clearance"], min: 0.1
)
end
def choose_lid_artwork(config)
p = config.parameters
label_enabled = agreement("Add a two-color device title to the lid?",
!p["lid_label"].empty?)
p["lid_label"] = label_enabled ? text(
"Device title", p["lid_label"].empty? ? config.name : p["lid_label"]
) : ""
p["lid_logo_enabled"] = agreement("Add the two-color NME logo to the lid?",
p["lid_logo_enabled"])
return unless !p["lid_label"].empty? || p["lid_logo_enabled"]
current = p["lid_art_orientation"]
p["lid_art_orientation"] = ui.choose("USB position when the lid text is upright") do |menu|
Config::LID_ART_ORIENTATIONS.each do |orientation|
menu.choice(orientation.delete_prefix("usb_").capitalize) { orientation }
end
menu.default = (Config::LID_ART_ORIENTATIONS.index(current).to_i + 1).to_s
end
end
def manage_cutouts(config)
loop do
ui.say("Custom cable cutouts: #{config.cutouts.empty? ? "none" :
config.cutouts.map { |cutout| cutout["name"] }.join(", ")}")
action = ui.choose("Cable cutouts") do |menu|
menu.choice("Done") { :done }
menu.choice("Add cutout") { :add }
menu.choice("Edit cutout") { :edit } unless config.cutouts.empty?
menu.choice("Remove cutout") { :remove } unless config.cutouts.empty?
end
break if action == :done
case action
when :add
config.cutouts << ask_cutout(config)
when :edit
index = choose_cutout(config, "Edit which cutout?")
old = config.cutouts.delete_at(index)
config.cutouts.insert(index, ask_cutout(config, old))
when :remove
config.cutouts.delete_at(choose_cutout(config, "Remove which cutout?"))
end
rescue ConfigError => e
ui.say(ui.color("Cutout not accepted: #{e.message}", :red))
end
end
def ask_cutout(config, existing = nil)
existing ||= {}
dims = config.dimensions
name = text("Cutout name", existing["name"] || "cable")
face = ui.choose("Wall") do |menu|
menu.choice("Front — USB-C short wall") { "front" }
menu.choice("Rear — opposite short wall") { "rear" }
menu.choice("Left — plain/non-JST long wall") { "left" }
menu.choice("Right — JST/battery long wall") { "right" }
menu.default = (Config::FACES.index(existing["face"]).to_i + 1).to_s if existing["face"]
end
shape = ui.choose("Shape") do |menu|
Config::SHAPES.each { |item| menu.choice(item) { item } }
menu.default = (Config::SHAPES.index(existing["shape"]).to_i + 1).to_s if existing["shape"]
end
wall_width = %w[front rear].include?(face) ? dims["width"] : dims["length"]
ui.say("Viewing the #{face} wall from outside: width #{format("%.1f", wall_width)} mm; " \
"usable vertical centers lie between the floor and lid lip.")
width = number(shape == "circle" ? "Diameter (mm)" : "Width (mm)",
existing["width"] || 8.0, min: 0.1)
height = shape == "circle" ? width :
number("Height (mm)", existing["height"] || 6.0, min: 0.1)
horizontal = number("Center from the wall's left edge (mm)",
existing["horizontal"] || wall_width / 2, min: width / 2,
max: wall_width - width / 2)
min_z = dims["floor"] + height / 2
max_z = dims["lip_bottom"] - height / 2
z = number("Center height from exterior bottom (mm)",
existing["z"] || (min_z + max_z) / 2, min: min_z, max: max_z)
radius = if shape == "roundrect"
number("Corner radius (mm)", existing["radius"] ||
[width, height].min / 4, min: 0.01, max: [width, height].min / 2)
else
[width, height].min / 2
end
candidate = {
"name" => name, "face" => face, "shape" => shape,
"horizontal" => horizontal, "z" => z, "width" => width,
"height" => height, "radius" => radius
}
temporary = Config.new(Config.deep_copy(config.data).tap do |data|
data["cutouts"] = config.cutouts + [candidate]
end)
temporary.validate!
candidate
end
def choose_cutout(config, prompt)
ui.choose(prompt) do |menu|
config.cutouts.each_with_index do |cutout, index|
menu.choice(cutout["name"]) { index }
end
end
end
def choose_coupons(config)
selected = []
selected << "insert_coupon" if agreement(
"Export heat-set insert calibration coupon?",
config.outputs["coupons"].include?("insert_coupon")
)
selected << "switch_coupon" if agreement(
"Export switch-hole calibration coupon?",
config.outputs["coupons"].include?("switch_coupon")
)
selected << "fit_coupon" if agreement(
"Export lid-fit calibration coupon?",
config.outputs["coupons"].include?("fit_coupon")
)
config.outputs["coupons"] = selected
end
def advanced(config)
Config::ADVANCED_FIELDS.each do |section, fields|
next unless ui.agree("Adjust #{section.downcase}? (y/n) ") { |q| q.default = "no" }
ui.say(section)
fields.each do |field|
config.parameters[field] = number(field.tr("_", " "), config.parameters[field],
min: field == "usb_vertical_offset" ? nil : 0.01)
end
next unless section == "OLED buttons"
config.parameters["button_cap_size"] = pair(
"button cap width,length", config.parameters["button_cap_size"]
)
config.parameters["button_flange_size"] = pair(
"button flange width,length", config.parameters["button_flange_size"]
)
end
end
def agreement(prompt, default)
ui.agree("#{prompt} (y/n) ") { |question| question.default = default ? "yes" : "no" }
end
def required_text(prompt)
loop do
value = ui.ask("#{prompt}: ").to_s.strip
return value unless value.empty?
ui.say(ui.color("A value is required.", :red))
end
end
def text(prompt, default)
ui.ask("#{prompt}: ") { |question| question.default = default }.to_s.strip
end
def ask_slug(default)
loop do
value = text("Directory slug", default)
return value if value.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
ui.say(ui.color("Use lowercase letters, numbers, and single hyphens.", :red))
end
end
def integer(prompt, default, range)
loop do
value = ui.ask("#{prompt}: ", Integer) { |question| question.default = default }
return value if range.cover?(value)
ui.say(ui.color("Enter a value from #{range.begin} through #{range.end}.", :red))
end
end
def number(prompt, default, min: nil, max: nil)
loop do
value = ui.ask("#{prompt}: ", Float) { |question| question.default = default }.to_f
return value if (min.nil? || value >= min) && (max.nil? || value <= max)
bounds = [min && "at least #{min}", max && "at most #{max}"].compact.join(" and ")
ui.say(ui.color("Enter a value #{bounds}.", :red))
end
end
def pair(prompt, default)
loop do
raw = text(prompt, default.join(","))
values = raw.split(",").map { |item| Float(item.strip) }
return values if values.length == 2 && values.all?(&:positive?)
ui.say(ui.color("Enter two positive comma-separated dimensions.", :red))
rescue ArgumentError
ui.say(ui.color("Enter two numeric comma-separated dimensions.", :red))
end
end
def slugify(name)
name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-?\z/, "").yield_self do |slug|
slug.empty? ? "feather-case" : slug
end
end
def yes_no(value) = value ? "yes" : "no"
end
end