# 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