mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-13 19:01:21 +00:00
## Summary This PR adds first-class parallel printing support to OrcaSlicer for printers with multiple independent X-axis carriages — the **IDEX** and **IQEX** hardware families. The feature is named **IMEX** (independent multi-extruder) internally, which captures the supported topology space more accurately than either acronym alone, and is exposed in the UI under the user-facing label **IDEX/IQEX Configuration**. It is designed to be printer-agnostic and firmware-flexible, with Klipper, RepRapFirmware, and Marlin all supported for per-tool Pressure Advance and per-layer temperature management. MMU/AFC setups where multiple logical filament slots share one physical extruder are supported via a `physical_extruder_map` profile option. Printers whose firmware handles copy/mirror placement internally — RepRapFirmware IDEX duplication mode on Flashforge Creator Pro 2 / Creator 3 Pro is the canonical example — are supported through a firmware-managed-zones flag that emits a centered single-half slice for the firmware to fan out. The implementation spans printer configuration, process settings UI, bed visualization, per-plate mode selection, ghost-object rendering with per-plate filament overrides, placement validation, pre-slice conflict warnings, G-code generation (with per-tool PA, per-layer temperature management, and optional center-origin slice frame), and layer preview animation. It supports five distinct topology paradigms: - **1 gantry × 2–4 tools** — classic IDEX with 2 tools (BCN3D Sigma, Snapmaker J1, Tenlog Hands-2 style — two independent X carriages on a shared Y gantry); multi-extruder on a shared rail when extended to 3 or 4. - **2 gantries × 1 tool each** — two fully-independent XY systems sharing a bed (Vivedino Xplorer style). Both Y rails independent — effectively "half an IQEX." - **2×2 grid, independent quadrants** — IQEX running four separate prints in parallel. - **2×2 grid, paired-gantry multicolor** — IQEX where cross-gantry tools share color responsibilities via the **Span** tile state. - **Firmware-managed center-origin** — slicer emits a centered single-half slice; firmware decides where to physically fan it out. Overlays on any of the above hardware topologies; canonical example is Flashforge IDEX with RepRapFirmware. --- ## Feature Walkthrough ### Printer Configuration Printer preset options that declare IMEX capability and geometry: | Option | Type | Description | |--------|------|-------------| | `is_imex` | bool | Marks this printer as IMEX-capable | | `imex_firmware_managed_zones` | bool | Emit centered single-half slice; firmware handles copy/mirror placement (default `false`) | | `imex_gantry_count` | int | Number of independent Y-axis gantries (rows) | | `imex_tools_per_gantry` | int (1–4) | Toolheads per gantry along X (columns) | | `imex_nozzle_clearance_x` / `imex_nozzle_clearance_y` | float | Nozzle-to-collision-edge distance in mm (literal, not halved) | | `imex_tool_layout` | string | Physical orientation: front-left / front-right / rear-left / rear-right | | `imex_mode_names` | string[] | Names of user-defined parallel modes; first entry is the reserved `primary` sentinel | | `imex_mode_active_tools` | string[] | Tool role assignments per mode (e.g. `0:P,1:C,2:M,3:M`, or `0:P,1:S,2:M,3:M` with Span) | | `imex_mode_gcodes` | string[] | Firmware macro to activate per mode | | `physical_extruder_map` | string[] | 0-indexed map from logical filament slots to physical extruders (for MMU/AFC — see below) | Tool roles per mode: **Primary** (P), **Copy** (C), **Mirror** (M), **Span** (S), **Inactive**. ### Parallel Mode Editor [Kooha-2026-05-14-13-45-35.webm](https://github.com/user-attachments/assets/af7650e6-d636-478b-9398-3f02ec662f03) The **IDEX/IQEX Configuration** and **IDEX/IQEX Parallel Modes** sections live in the printer preset's **Multimaterial** page (visible only when `is_imex = true`). The mode editor is a visual grid: - Each row defines one named parallel mode. The first row is a reserved, non-deletable **Primary** row stored as the sentinel `primary` in `imex_mode_names`. - The Primary tool is always T0; **Tool 0 Position** chooses which physical corner it occupies, and its tile is read-only. The other tool buttons cycle Inactive → Copy → Mirror → Span → Inactive, color-coded, with Span offered only where it applies. - **Span** is only offered when `imex_gantry_count >= 2` AND the tile is on the primary's gantry row — it declares "this tool is the multi-color partner of Primary on the same gantry," distinct from a Copy/Mirror role on a non-primary gantry. - A G-code field per mode holds the firmware macro to activate that mode (e.g. `IMEX_COPY` for Klipper). A placeholder-browser button per row opens the `EditGCodeDialog` for quick insertion. - Modes can be added and removed (removed via a dedicated `imex_remove.svg` ScalableButton). - Deleting the active mode resets affected plates to Primary. - Tool assignments are preserved across `imex_gantry_count` changes: going from IQEX (4 carriages) → IDEX (2 carriages) → IQEX restores all previously assigned roles, and the visible grid anchors to the gantry row containing the Primary assignment. <!-- TODO: replace this placeholder with a re-recorded parallel-mode-editor video showing the Span tile state, the remove button, and the placeholder-browser button. Old URL (out of date): https://github.com/user-attachments/assets/cabf1f70-206d-4f84-9c2e-594b38b83951 --> ### Span Tile State — Paired-Gantry Multicolor <img width="2833" height="1300" alt="2026-05-14-135412_grim" src="https://github.com/user-attachments/assets/52203133-7380-459a-973f-0502a1edc3c2" /> For IQEX printers (`imex_gantry_count >= 2`), the **Span (S)** tile role enables paired-gantry multicolor mirror mode. The same active-tools string `0:P,1:C,2:M,3:M` is ambiguous between two distinct hardware jobs — four independent quadrants vs. paired-gantry multicolor mirror — so the topology has to be declared explicitly rather than inferred. Span on the primary's gantry row is the declaration. Behavior driven by Span presence: - **Multicolor block rule**: requires Span on primary's gantry to allow multi-color slicing in a parallel mode. Without Span, multi-color slicing in a parallel mode is blocked with an actionable error message. - **Ghost aggregation**: one aggregated ghost per non-primary gantry when Span is present, using the column-paired representative tool. Mixed-role gantries fall back to per-tool. - **Zone aggregation**: one row-strip zone per non-primary gantry when Span is present (cell at primary's column collapses col-sep; `make_boxes` expands to full-X strip). - **Aggregated mirror drag**: ghost translates 1:1 with primary in X (copy-style), with X-flip baked into the mesh-local frame so geometry still reads as mirrored. Gantries don't share an X rail, so reflecting motion serves no collision purpose. - **Carriage collision strip audit**: X-boundary checks require `zr == pri_row_k` (matching the existing Y-boundary `c == pri_col` constraint). Prevents spurious strips on primary's right edge in paired-gantry mc-mirror, where T3 sits diagonally and can't actually collide with primary's carriage. Single source of truth: `IMEXHelpers::group_imex_active_tools_by_gantry(active_tools_str, tools_per_gantry)`. Ghost factory and zone calculator both consume it, so pairing logic lives in one place. ### Per-Plate Mode Selection Each build plate has an IMEX mode icon in its toolbar (normal, hover, dark, and dark-hover SVG variants). The mode can be set independently per plate: - **Left-click** cycles through all non-sentinel modes in order. - **Right-click** opens a popup menu listing all modes as radio items for direct selection. - Mode changes are recorded in the undo/redo snapshot system. - The selected mode is persisted in the 3MF project file per plate (`imex_parallel_mode` key). https://github.com/user-attachments/assets/5ab497e3-9c2f-476a-ac67-ef34a592395b When a parallel mode is active alongside multi-material objects on the same plate, a warning badge (`obj_warning.svg`) overlays the plate icon — see **Pre-Slice Warning System** below. ### Bed Visualization + Auto-Arrange Constraints When an IMEX mode is active, the build plate renders the carriage grid: <img width="2761" height="1447" alt="IMEX bed visualization with active primary zone and dimmed secondary zones" src="https://github.com/user-attachments/assets/3ee64b7b-a3ed-40aa-ad07-fa2f1cb74edf" /> - The **active (primary) zone** is full brightness. - **Inactive zones** are dimmed with a color-coded overlay (blue for copy, orange for mirror). - **Zone dividers** are rendered as lines across the bed. - The grid is a full 2D layout: `imex_tools_per_gantry` columns × `imex_gantry_count` rows. Zone sizing is based on the **active** tool count only — inactive tools donate their bed share to active neighbors. - Span-present configs render as a single row-strip zone per non-primary gantry rather than per-tool quadrants, reflecting that the firmware will paint the multi-color across the entire strip. - Colors are drawn from the Okabe-Ito palette for colorblind accessibility, with a deuteranopia/protanopia-safe alternate theme. - **Auto-arrange** is constrained to the primary zone when a parallel mode is active — `ArrangeJob::process()` replaces full-bed `bedpts` with the primary zone corners via `PartPlate::imex_primary_zone()`. Collision strips are additionally registered as hard obstacles through `m_unselected` (the working NFP placer input, not the dead `excluded_regions` field), so placement cannot drop parts into the danger strips. ### Placement Validation https://github.com/user-attachments/assets/5cb5a94a-981e-4fe9-898e-7e2f891b4f40 Objects placed outside the primary zone block slicing: - `has_imex_placement_violations()` in `PartPlate` checks each object's convex hull against the primary zone boundaries and collision strips. - Violations inject into the existing `update_background_process` validation pathway — the Slice button is disabled and an error notification is shown. - Mirror tools additionally generate X-axis collision strips (copy tools move in the same direction and cannot collide). Y-direction strips are scoped to same-column tools to avoid false positives from diagonal mirror pairs. - Strip width is taken **literally** from `imex_nozzle_clearance_x` (the measurement is nozzle-to-collision-edge distance, not carriage half-width). - Multi-color block path: a pre-slice rule blocks multi-color slicing in parallel modes that can't physically support it (e.g. a "fake IMEX" mode where all tools sit on a single gantry without a Span partner). The rule is centralized in `imex_multicolor_block_reason()` with unit-test coverage of every gating case. ### Ghost Object Rendering + Per-Plate Filament Picker When a parallel mode is active, the slicer renders colored, transparent **ghost copies** of primary-head instances on the plate — one per secondary active head, transformed under its Copy/Mirror role (or aggregated per gantry when Span is present). - Ghosts track the primary through drag/rotate/scale/mirror and invalidate on mode, filament-map, or `physical_extruder_map` changes. - **Left-click** on a ghost opens the `IMEXFilamentPickerPopover` for that ghost's head — a compact `BitmapComboBox` that writes directly to the per-plate `imex_head_filament_map` (MMU lane override). - Mirror ghost geometry is a **true reflection about the zone-boundary plane** (`x = primary_zone_center.x + gantry_offset.x/2`), so the ghost stays anchored in the target zone as the primary moves and drag reflects correctly (primary +X → ghost −X, Y tracks 1:1). - Hover tooltip: `"Tn → filament N"` with a color swatch. When no filament resolves to a head, the tooltip surfaces an actionable message directing the user to extend the extruder count in the Machine tab. - Ghost rendering + picking are scoped to the active plate — non-active plates don't draw stale ghosts during arrange/preview transitions, and click-picking never falls through to a non-current plate's ghost geometry. - Per-plate `imex_head_filament_map` round-trips through the 3MF project file. ### Pre-Slice Warning System <img width="1786" height="911" alt="2026-05-14-135714_grim" src="https://github.com/user-attachments/assets/16532d47-2da5-4472-ab9c-4b8b00303bae" /> Before a plate slices, IMEX parallel-mode plates are checked for three classes of conflict: 1. **Multi-material on secondary tools** — when the mode has Copy/Mirror tools and the plate has multiple filaments active; plate icon gets a warning badge. 2. **Bed temperature mismatch** — any two carriages configured >5 °C apart. 3. **Filament type incompatibility** — filaments from different type families on active carriages. A dismissible Yes/No `RichMessageDialog` fires once per user action from both `on_action_slice_plate` and `on_action_slice_all`. The dialog has a **"Don't show again"** checkbox that persists to `app_config` as `imex_pre_slice_warnings=false`. A re-enable toggle lives in **Printer Settings → Multimaterial → IDEX/IQEX Configuration** so suppressed warnings can be restored. ### Firmware-Managed Zones — Center-Origin Slice <img width="701" height="197" alt="2026-05-14-135842_grim" src="https://github.com/user-attachments/assets/3e2dfaab-fb4f-42b1-bf9d-c88dc0c95110" /> The `imex_firmware_managed_zones` printer-config option (default `false`) supports IDEX/IQEX printers whose firmware applies its own copy/mirror offsets in non-primary modes. Canonical examples: **RepRapFirmware IDEX duplication mode** on **Flashforge Creator Pro 2 / Creator 3 Pro**. These printers expect a centered single-half slice at bed origin and fan toolheads out from there — the slicer-managed paradigm of placing toolpaths at zone-relative positions produces gcode the firmware can't reconcile, since it'd double-apply offsets. When the flag is on and the active mode is non-primary, the slicer subtracts the primary zone's plate-local center from the gcode emission frame: - **Writer offset** is augmented by the primary zone center so emitted gcode is centered at bed origin. - **Processor offset** stays at plate_origin only, so the gcode-preview visualizer renders the centered toolpath at bed center rather than at the prepare-view zone placement. The user sees what'll physically print after firmware fan-out. - **`translate_to_print_space()`** is augmented for frame coherence so `first_layer_print_min/max` placeholders consumed by user start_gcode (e.g. Flashforge's M118 "max delta from zero" header) reflect the centered frame. Slice-handoff runs per-slice: `PartPlate::refresh_imex_slice_offset()` is called from `Plater::priv::update_background_process` after `Print::apply()`, so reslicing with mode toggled but no plate change picks up the updated offset. When the flag is off, all related code paths reduce to no-ops byte-identical to standard slicer-managed behavior. The two stock-code touches at `Print.cpp:2553-2554` (gcode_offset composition) and `Print.cpp:2823-2829` (translate_to_print_space) are explicit additive shifts that collapse to identity when the offset is `Vec2d::Zero()`. ### G-code Injection The selected parallel mode's G-code is written into the output file immediately before `machine_start_gcode`: - Looks up the plate's active mode name in `imex_mode_names` and writes the corresponding `imex_mode_gcodes` entry. - Processed through `placeholder_parser_process()` first, so Klipper-style variable substitution works **and** any `{global}` declarations flow forward into `machine_start_gcode`. - Primary mode's G-code field is emitted too. - Guarded against headless CLI slicing — `ensure_imex_zones()` short-circuits when `m_plater` is null so the CLI path (used by upstream's regression-test CI step) doesn't segfault on `wxGetApp().preset_bundle` dereference. ### Placeholder Parser Integration <img width="535" height="393" alt="2026-05-14-140227_grim" src="https://github.com/user-attachments/assets/6d059c77-38f6-4418-bfe9-3bf963e8c836" /> Three placeholders register under **Slicing State** and are settable from anywhere downstream: | Placeholder | Type | Description | |---|---|---| | `imex_mode` | string | The active mode name | | `imex_mode_index` | int | Index into `imex_mode_names` | | `imex_mode_gcode` | string | The resolved mode G-code (post-parser) | ### Per-Tool Pressure Advance — Firmware-Agnostic `set_pressure_advance()` takes an optional tool index (default `-1`, preserving existing behavior for all non-IMEX call sites). Per firmware: - **Klipper**: `EXTRUDER=extruder[N]` when `tool >= 0`, bare command otherwise - **RepRapFirmware**: `M572 D<N>` when `tool >= 0`, `M572 D0` otherwise (preserves the pre-IMEX output; a bare `M572` applies to whatever tool is selected and errors when there is none) - **Marlin 2**: `M900 K<X> T<N>` when `tool >= 0`, bare `M900` otherwise - **Marlin Legacy / fallback**: `M900 K<X>` always - **Repetier**: `M233 X<X> Y<X>` (X is quadratic, Y is linear; same value applied to both) `m_imex_parallel_mode` is set once per export from the active plate mode. PA tool-qualification is gated on this being a **non-primary** parallel mode — primary-mode prints emit ordinary tool-change PA exactly like any non-IMEX printer. Secondary active tools in parallel modes receive explicit per-tool PA at print start since they never go through a tool-change sequence. ### Per-Layer Temperature Management In IMEX parallel modes, all active tools (primary + secondaries) get temperature commands in `layer_change_gcode`: - Layer 1 temperatures only emit on the first layer; subsequent layers use normal layer-change temperatures. - IMEX temperature handling is consolidated into the second-layer transition. - Layer-change temperature commands use `M104 T<N>` with `physical_extruder_map` translation when applicable. ### MMU / AFC Support via `physical_extruder_map` For printers where multiple logical filament slots share one physical extruder (MMU, AFC, toolchangers), the `physical_extruder_map` profile option translates tool slot indices to physical extruder qualifiers before G-code emission. - **Fallback**: on IMEX printers, `Print::apply()` uses the profile's map only when it has one entry per extruder (the `nozzle_diameter` count). Anything else, including the single-entry default, is replaced by the identity map `0..n-1`. - **Used by**: IMEX PA emission, layer-change temperature commands (`M104 T`, `EXTRUDER=`, `M572 D`), ghost color resolution, ghost cache key, tooltip lookup, click gate. - **Profile authoring example** for a 7-slot printer with a 4-lane MMU on extruder 0 and three independent direct drives on extruders 1/2/3: ```json "physical_extruder_map": ["0","0","0","0","1","2","3"] ``` Non-MMU printers need no action — the identity fallback handles them. - A centralized helper in `IMEXHelpers`, `effective_physical_extruder_map(explicit_pem, nozzle_count)` (with a `PresetBundle` overload that prefers the project's map over the printer's), routes all PA/temp/ghost-color lookups through a single code path. - **No UI for editing the map** in this PR — non-trivial MMU/AFC layouts require profile-authoring (hand-edit the printer JSON) or an updated printer profile shipped by a vendor. A future enhancement would expose a per-slot extruder picker in the Multimaterial section. ### Layer Preview Multi-Carriage Animation The sequential preview (scrubber) animates all active carriages simultaneously: - One toolhead marker (colored cube) per active carriage, in addition to the primary. - Secondary marker colors: cyan (T1), yellow (T2), magenta (T3). - **Copy** tools: marker placed at the same relative position within their bed zone as the primary is in the primary zone. - **Mirror** tools: reflect across the target zone's facing edge (left-of-copy reflects across copy zone's left edge; right-of-copy across the right edge). Y position is always zone-relative copy (all tools on a row share a physical Y rail). - Carriage footprint boxes use per-carriage `box_offset_x/y` so the nozzle marker sits at the physically correct edge of the footprint — zone-based X by default, collision-side edge for Mirror; gantry-behind Y for back-row primaries, flipped for front-row primaries with a back-row secondary. - The filament usage legend notes the active carriage count and mode name (e.g. `IMEX: ×2 (copy_mode)`). - **View menu toggle**: `View → Show IDEX/IQEX Toolhead` (visible only on the Preview tab, only when the active printer is IDEX/IQEX) hides/shows the per-carriage toolhead representation during preview playback. State persists to `app_config` as `show_imex_toolhead_boxes`. Useful when scrubbing through dense toolpaths and the boxes get in the way of seeing the underlying geometry: [Kooha-2026-05-14-14-04-09.webm](https://github.com/user-attachments/assets/9869cd4d-3b60-4a6b-8dca-7ee1fc573ac1) Copy mode: https://github.com/user-attachments/assets/0774d285-ba36-4b86-a594-fb6572c3aede Mirror mode: https://github.com/user-attachments/assets/51528926-94dc-4d90-9b1a-1b42d9044be7 --- ## Known Limitations **Per-layer G-code collision detection** The placement-time zone check catches gross violations (object placed in wrong zone) but does not verify that toolpaths on any given layer maintain adequate X separation between adjacent carriages. A per-layer check via `ConflictChecker` was designed but deferred. Without it, a print that passes placement validation could still crash carriages if the primary object's toolpaths reach too close to a zone boundary. **Brim avoidance of IMEX zones** Standard `bed_exclude_area` exclusion zones are already respected by brim generation. IMEX collision strips are not — the brim generator has no visibility into them. The correct fix (feeding computed strip polygons from `PartPlate` through the `Print` object to `Brim.cpp`) was designed but deferred. In practice, users should leave adequate clearance between printed objects and zone boundaries to account for brim width. **`extruder_printable_area` integration** IMEX zones are not clipped against per-extruder printable polygons, and there is no violation check for placing an object outside the intersection of its active extruder's printable area and the IMEX primary zone. Deferred pending clarification on the tool→extruder index mapping. **Ghost rendering in firmware-managed mode** When `imex_firmware_managed_zones` is on, ghost rendering is suppressed entirely. The existing `imex_head_transform` math is slicer-managed semantics (places ghosts at `primary_zone_center + gantry_offset`) and produces wrong positions when the toolpath is being emitted in a centered frame. Proper firmware-managed ghost rendering — showing where copies/mirrors will physically print after firmware fan-out — needs new transforms designed around firmware-frame positions rather than a coordinate-flip of the slicer-managed ones. Deferred to a follow-up. **Filament-accurate multi-region ghost color** For paired-gantry multicolor mirror (Span mode), the aggregated ghost currently uses the representative tool's filament color as a single solid swatch. A future enhancement would render the ghost split into per-source-tool regions, each colored by the secondary tool that mirrors it. Slicer-side correctness is already shipped — the G-code emits the right T-codes; the ghost is a visual aid only. **Ghost path overlays** Secondary carriage toolpaths are not rendered in the layer preview. The secondary markers animate correctly, but the paths they would trace are not drawn. Adding ghost path rendering would require duplicating and offsetting the toolpath geometry per secondary carriage, which is a significant addition to the libvgcode rendering pipeline. **No per-mode slicing** All carriages in a mode execute the same sliced toolpaths (transformed per zone). There is no support for slicing different objects for each carriage independently within one mode. **Global default IMEX mode** There is no job-level IMEX default mode. Each plate's mode must be set individually (default is always Primary). A future improvement would add a global default in the sidebar (following the same pattern as bed type and nozzle diameter), with per-plate overrides. **Dynamic GL-rendered mode icons** The per-plate icon currently uses static SVGs. A GL-rendered carriage-grid icon that visually represents the mode's tool layout would be a nicer UX but is deferred. **Slice-all thumbnail icon refresh** On the "Slice All" path, plates flagged as IMEX-violated do not refresh their toolbar icon. Root cause and fix identified, not yet applied. **Start-G-code filament placeholder** The resolved per-head filament map is not yet exposed as a `PlaceholderParser` vector. Exposing it would let MMU firmware macros pre-load lanes before print start. **No UI for `physical_extruder_map` authoring** The MMU/AFC slot-to-physical-extruder map is currently profile-only — there's no in-app dropdown or editor for it. Users with non-trivial MMU/AFC setups must hand-edit the printer-preset JSON (or rely on a vendor-supplied profile). A future enhancement would expose a per-slot extruder picker in the Multimaterial section so users can declare the mapping without touching JSON. **Bundled IQEX printer profile** A full IQEX printer profile with cover image, bed mesh, and matched process/filament profiles is not bundled with this PR. Users must currently author their own printer preset. Deferred to a follow-up profile-only PR. **Mode lifecycle gap** Per-plate mode is stored as a string (the mode name). If a mode is renamed or deleted from the printer preset after a project is saved, the plate's saved mode name will not resolve and will silently fall back to Primary on next load. A warning on load would be a useful addition. **Firmware-managed prepare→preview frame jump** In firmware-managed-zones mode, the prepare view shows the part at its placed-in-zone position while the preview view shows the centered slice. This is intentional — the views truthfully represent the prepare frame vs. the post-firmware-fan-out frame — but the visual jump can be confusing on first use. --- ## Firmware Assumptions - The implementation assumes the firmware handles all carriage synchronization and offset math. OrcaSlicer only injects the mode-activation macro before machine start and is entirely dependent on user configuration. - Per-tool Pressure Advance is firmware-aware (Klipper / RRF / Marlin 2 / Marlin Legacy / Repetier). Layer-change temperature commands use `M104 T<N>` with `physical_extruder_map` translation. - Tested against Klipper on a real IQEX printer. RepRapFirmware gcode emission is exercised via profile-driven slice tests (Flashforge Creator Pro 2 profile); Marlin and RRF paths have not been validated on real hardware print runs. - Mid-print mode switching is explicitly not supported. The mode is locked at print start. - Firmware-managed-zones mode requires the printer's firmware to translate centered slice coordinates into physical toolhead positions; the slicer does not attempt to model the firmware's offset logic. --- ## Files Changed (High-Level) | File | Change | |------|--------| | `src/libslic3r/PrintConfig.{cpp,hpp}` | IMEX config option definitions + declarations, `IMEXMode` enum, `physical_extruder_map`, `imex_head_filament_map` (plate option), `imex_firmware_managed_zones` | | `src/libslic3r/Preset.cpp` | IMEX keys registered in printer and process preset option lists | | `src/libslic3r/PrintApply.cpp` | `physical_extruder_map` identity fallback on IMEX printers | | `src/libslic3r/Print.{cpp,hpp}` | IMEX slice-offset field + accessors; `translate_to_print_space` augmentation for frame coherence | | `src/libslic3r/GCode.{cpp,hpp}` | Mode G-code injection, per-tool PA emission (firmware-agnostic), per-layer temperatures for all active tools, `m_imex_parallel_mode` state, `set_gcode_offset_with_imex_shift` writer/processor split | | `src/libslic3r/GCodeWriter.{cpp,hpp}` | `set_pressure_advance(tool_index = -1)` per-firmware implementation | | `src/libslic3r/IMEXHelpers.{cpp,hpp}` | `imex_head_transform` (Primary/Copy/Mirror), `parse_imex_active_tools`, `imex_primary_tool_for_mode`, `effective_physical_extruder_map`, per-head filament resolution, `group_imex_active_tools_by_gantry` (Span pairing), `compute_imex_slice_offset` (firmware-managed) | | `src/libslic3r/Format/bbs_3mf.cpp` | Per-plate IMEX mode and `imex_head_filament_map` serialization | | `src/slic3r/GUI/Tab.{cpp,hpp}` | `IMEXModesCtrl` in Multimaterial page, tool assignment persistence across gantry count changes, `primary` sentinel handling, Span tile state, firmware-managed-zones checkbox, null guard in `clear_pages()` | | `src/slic3r/GUI/PartPlate.{cpp,hpp}` | Zone visualization (Span row-strip aggregation), placement violation detection, per-plate mode icon, ghost volume rebuild on mode/map/object mutation, warning-badge overlay, pre-slice warning collection, `refresh_imex_slice_offset()`, ghost suppression in firmware-managed mode, headless-CLI guard | | `src/slic3r/GUI/Plater.cpp` | Validation pathway injection, per-plate mode popup, ghost click handling, pre-slice warning dialog with "Don't show again", IMEX multimaterial conflict routing, per-slice IMEX offset refresh in `update_background_process` | | `src/slic3r/GUI/GLCanvas3D.cpp` | Ghost rendering with per-head filament color and translucent blending, picking via volume composite id, active-plate scoping | | `src/slic3r/GUI/GCodeViewer.cpp` | Multi-carriage marker animation, Mirror math fix, carriage footprint box offsets, legend annotation | | `src/slic3r/GUI/IMEXFilamentPickerPopover.{cpp,hpp}` | Ghost-click filament picker | | `src/slic3r/GUI/Jobs/ArrangeJob.cpp` | Auto-arrange constrained to IMEX primary zone + collision strip exclusion via `m_unselected` | | `src/slic3r/GUI/OG_CustomCtrl.cpp` | Empty `option_set` guard for widget-only lines | | `resources/images/plate_imex_mode*.svg`, `imex_remove.svg` | Per-plate mode icons (light/dark/hover variants), remove button | | `tests/libslic3r/test_imex_helpers.cpp` | Coverage for IMEX head transforms, `effective_physical_extruder_map`, `imex_multicolor_block_reason` gating, `group_imex_active_tools_by_gantry` Span pairing, `compute_imex_slice_offset` | --- ## Testing Notes Validated on an IQEX printer (4 carriages, 2×2 grid, Klipper firmware) and against the Flashforge Creator Pro 2 profile (2-tool IDEX, RRF flavor, center-origin bed) with the following configurations: - **Primary only** — baseline, no regression vs. standard single-extruder workflow - **Copy mode** (T0 Primary, T1 Copy, same row) — carriage markers animate in sync offset by strip width; ghost tracks drag/rotate/scale - **Mirror mode** (T0 Primary, T1 Mirror, same row) — T1 marker reflects T0 across zone-boundary plane; ghost drag reflects X correctly while Y tracks 1:1 - **Cross-row copy + mirror** (T0 Primary row 0, T2 Copy row 1, T3 Mirror row 1) — T2 follows T0's zone-relative position; T3 mirrors T2's X; Y shared per row - **4-tool copy mode** (T0 Primary, T1/T2/T3 Copy) — zone sizing correct, all four markers + ghosts render in sync - **Paired-gantry multicolor (mc-mirror, Span)** — `0:P,1:S,2:M,3:M`: ghost aggregates to one per non-primary gantry, X-flipped, drag tracks 1:1 with primary; collision strip audit doesn't fire spurious strips on primary's right edge - **Firmware-managed copy mode** (Flashforge Creator Pro 2) — emitted gcode coordinates are centered at bed origin (X = −cube_half_width … +cube_half_width), `first_layer_print_min/max` placeholders evaluate symmetrically, M118 header produces correct "max delta from zero" values for Flashforge's existing template, ghosts suppressed - **Firmware-managed flag toggle** — flipping the checkbox off restores slicer-managed iMEX behavior (toolpath at zone position) byte-identically; flipping on restores center-origin slice - **Delete active mode** — no crash; plate resets to Primary - **Placement outside primary zone** — slicing blocked with error notification - **Multi-material conflict warning** — plate icon gets warning badge; Yes/No dialog fires on slice; "Don't show again" checkbox persists - **Multi-color block rule** — `0:P,1:C` on a single gantry blocks at slice time with actionable error message - **Ghost filament picker** — left-click on ghost opens picker; selection writes to `imex_head_filament_map` and round-trips through 3MF save/load - **Per-plate mode selection** — left-click cycles modes, right-click shows popup, undo/redo correctly reverts mode changes, mode persists through project save/load - **Gantry count change round-trip** — reducing from IQEX (4 carriages) to IDEX (2 carriages) and back restores all previously assigned tool roles - **MMU/AFC `physical_extruder_map`** — explicit map routes PA and temperature commands to correct physical extruder qualifiers; auto-derive fallback preserves 1:1 behavior for non-MMU printers - **Headless CLI slicing** — `orca-slicer --slice project.3mf` does not segfault on IMEX-enabled printers - **Dark mode** — all four plate-icon variants render correctly - **Pre-slice warnings suppress + restore** — `app_config` flag flips on checkbox; re-enable toggle in Multimaterial config restores dialog - **Stock non-IMEX printer regression check** — slicing a single-head Voron Trident 350 profile produces byte-identical gcode vs. baseline; the firmware-managed flag and Span tile state both no-op when not applicable - **Full unit test suite** — 247/247 passing, including Layer 1 coverage for `compute_imex_slice_offset`, `group_imex_active_tools_by_gantry` Span pairing, and `imex_multicolor_block_reason` gating
1095 lines
40 KiB
C++
1095 lines
40 KiB
C++
#include "OG_CustomCtrl.hpp"
|
|
#include "OptionsGroup.hpp"
|
|
#include "MarkdownTip.hpp"
|
|
#include "Plater.hpp"
|
|
#include "GUI_App.hpp"
|
|
#include "MsgDialog.hpp"
|
|
|
|
#include <wx/gdicmn.h>
|
|
#include <wx/panel.h>
|
|
#include <wx/stattext.h>
|
|
#include "slic3r/GUI/wxExtensions.hpp"
|
|
#include <cmath>
|
|
#include <wx/event.h>
|
|
#include <vector>
|
|
#include <wx/types.h>
|
|
#include <cassert>
|
|
#include "slic3r/GUI/Field.hpp"
|
|
#include <wx/dcclient.h>
|
|
#include <wx/dc.h>
|
|
#include <wx/colour.h>
|
|
#include <cstddef>
|
|
#include <wx/settings.h>
|
|
#include "slic3r/GUI/Widgets/StateColor.hpp"
|
|
#include "slic3r/GUI/GUI.hpp"
|
|
#include "libslic3r/Config.hpp"
|
|
#include <algorithm>
|
|
#include <wx/utils.h>
|
|
#include <boost/algorithm/string/split.hpp>
|
|
#include "I18N.hpp"
|
|
#include <slic3r/GUI/Widgets/Label.hpp>
|
|
#include <wx/validate.h>
|
|
|
|
namespace Slic3r { namespace GUI {
|
|
|
|
// BBS: modify param ui style
|
|
constexpr int titleWidth = 20;
|
|
constexpr int ctrlWidth = 50;
|
|
|
|
#define DISABLE_BLINKING
|
|
#define DISABLE_UNDO_SYS
|
|
|
|
static bool is_point_in_rect(const wxPoint& pt, const wxRect& rect)
|
|
{
|
|
return rect.GetLeft() <= pt.x && pt.x <= rect.GetRight() &&
|
|
rect.GetTop() <= pt.y && pt.y <= rect.GetBottom();
|
|
}
|
|
|
|
static wxSize get_bitmap_size(const wxBitmap& bmp)
|
|
{
|
|
#ifdef __APPLE__
|
|
return bmp.GetScaledSize();
|
|
#else
|
|
return bmp.GetSize();
|
|
#endif
|
|
}
|
|
|
|
OG_CustomCtrl::OG_CustomCtrl( wxWindow* parent,
|
|
OptionsGroup* og,
|
|
const wxPoint& pos /* = wxDefaultPosition*/,
|
|
const wxSize& size/* = wxDefaultSize*/,
|
|
const wxValidator& val /* = wxDefaultValidator*/,
|
|
const wxString& name/* = wxEmptyString*/) :
|
|
wxPanel(parent, wxID_ANY, pos, size, /*wxWANTS_CHARS |*/ wxBORDER_NONE | wxTAB_TRAVERSAL),
|
|
opt_group(og)
|
|
{
|
|
if (!wxOSX)
|
|
SetDoubleBuffered(true);// SetDoubleBuffered exists on Win and Linux/GTK, but is missing on OSX
|
|
SetBackgroundColour(parent->GetBackgroundColour());
|
|
|
|
// BBS: new font
|
|
m_font = Label::Body_14;
|
|
SetFont(m_font);
|
|
m_em_unit = em_unit(m_parent);
|
|
m_v_gap = lround(1.2 * m_em_unit);
|
|
m_v_gap2 = lround(0.8 * m_em_unit);
|
|
m_h_gap = lround(0.2 * m_em_unit);
|
|
|
|
//m_bmp_mode_sz = get_bitmap_size(create_scaled_bitmap("mode_simple", this, wxOSX ? 10 : 12));
|
|
m_bmp_blinking_sz = get_bitmap_size(create_scaled_bitmap("blank_16", this));
|
|
|
|
init_ctrl_lines();// from og.lines()
|
|
|
|
this->Bind(wxEVT_PAINT, &OG_CustomCtrl::OnPaint, this);
|
|
this->Bind(wxEVT_MOTION, &OG_CustomCtrl::OnMotion, this);
|
|
this->Bind(wxEVT_LEFT_DOWN, &OG_CustomCtrl::OnLeftDown, this);
|
|
this->Bind(wxEVT_LEAVE_WINDOW, &OG_CustomCtrl::OnLeaveWin, this);
|
|
}
|
|
|
|
void OG_CustomCtrl::init_ctrl_lines()
|
|
{
|
|
const std::vector<Line>& og_lines = opt_group->get_lines();
|
|
for (const Line& line : og_lines)
|
|
{
|
|
if (line.is_separator()) {
|
|
ctrl_lines.emplace_back(CtrlLine(0, this, line));
|
|
continue;
|
|
}
|
|
|
|
if (line.full_width && (
|
|
// description line
|
|
line.widget != nullptr ||
|
|
// description line with widget (button)
|
|
!line.get_extra_widgets().empty())
|
|
)
|
|
continue;
|
|
|
|
const std::vector<Option>& option_set = line.get_options();
|
|
wxCoord height;
|
|
|
|
// if we have a single option with no label, no sidetext just add it directly to sizer
|
|
if (option_set.size() == 1 && opt_group->label_width == 0 && option_set.front().opt.full_width &&
|
|
option_set.front().opt.sidetext.size() == 0 && option_set.front().side_widget == nullptr &&
|
|
line.get_extra_widgets().size() == 0)
|
|
{
|
|
height = m_bmp_blinking_sz.GetHeight() + m_v_gap;
|
|
ctrl_lines.emplace_back(CtrlLine(height, this, line, true));
|
|
}
|
|
else if (opt_group->label_width != 0 && (!line.label.IsEmpty() || option_set.front().opt.gui_type == ConfigOptionDef::GUIType::legend) )
|
|
{
|
|
wxSize label_sz = GetTextExtent(line.label);
|
|
if (opt_group->split_multi_line) {
|
|
if (option_set.size() > 1) // BBS
|
|
height = (label_sz.y + m_v_gap2) * option_set.size() + m_v_gap - m_v_gap2;
|
|
else
|
|
height = label_sz.y * (label_sz.GetWidth() > int(opt_group->label_width * m_em_unit) ? 2 : 1) + m_v_gap;
|
|
} else {
|
|
height = label_sz.y * (label_sz.GetWidth() > int(opt_group->label_width * m_em_unit) ? 2 : 1) + m_v_gap;
|
|
}
|
|
ctrl_lines.emplace_back(CtrlLine(height, this, line, false, opt_group->staticbox));
|
|
}
|
|
else
|
|
assert(false);
|
|
}
|
|
}
|
|
|
|
int OG_CustomCtrl::get_height(const Line& line)
|
|
{
|
|
for (auto ctrl_line : ctrl_lines)
|
|
if (&ctrl_line.og_line == &line)
|
|
return ctrl_line.height;
|
|
|
|
return 0;
|
|
}
|
|
|
|
wxPoint OG_CustomCtrl::get_pos(const Line& line, Field* field_in/* = nullptr*/)
|
|
{
|
|
// BBS: new layout
|
|
wxCoord v_pos = 0;
|
|
wxCoord h_pos = get_title_width() * m_em_unit;
|
|
|
|
auto correct_line_height = [](int& line_height, wxWindow* win)
|
|
{
|
|
int win_height = win->GetSize().GetHeight();
|
|
if (line_height < win_height)
|
|
line_height = win_height;
|
|
};
|
|
|
|
auto correct_horiz_pos = [this](int& h_pos, Field* field) {
|
|
if (m_max_win_width > 0 && field->getWindow()) {
|
|
int win_width = field->getWindow()->GetSize().GetWidth();
|
|
if (dynamic_cast<CheckBox*>(field))
|
|
win_width *= 0.5;
|
|
h_pos += m_max_win_width - win_width;
|
|
}
|
|
};
|
|
|
|
auto add_label_width = [&h_pos, this](CtrlLine &ctrl_line, wxString const & label, int label_width) {
|
|
wxClientDC dc(this);
|
|
dc.SetFont(m_font);
|
|
wxString multiline_text;
|
|
auto size = Label::split_lines(dc, label_width, label, multiline_text);
|
|
if (label_width > 0) size.x = label_width;
|
|
h_pos += size.x + m_h_gap;
|
|
if (ctrl_line.height < size.y)
|
|
ctrl_line.height = size.y;
|
|
};
|
|
|
|
auto add_buttons_width = [&h_pos] (int blinking_button_width) {
|
|
#ifndef DISABLE_BLINKING
|
|
# ifndef DISABLE_UNDO_SYS
|
|
h_pos += 3 * blinking_button_width;
|
|
# else
|
|
h_pos += 2 * blinking_button_width;
|
|
# endif
|
|
#else
|
|
# ifndef DISABLE_UNDO_SYS
|
|
h_pos += 2 * blinking_button_width;
|
|
# else
|
|
h_pos += 1 * blinking_button_width;
|
|
# endif
|
|
#endif
|
|
};
|
|
|
|
for (CtrlLine& ctrl_line : ctrl_lines) {
|
|
if (&ctrl_line.og_line == &line)
|
|
{
|
|
// BBS: new layout
|
|
// h_pos = m_bmp_mode_sz.GetWidth() + m_h_gap;
|
|
if (line.near_label_widget_win) {
|
|
wxSize near_label_widget_sz = line.near_label_widget_win->GetSize();
|
|
if (field_in)
|
|
h_pos += near_label_widget_sz.GetWidth() + m_h_gap;
|
|
else {
|
|
h_pos += m_h_gap; // ORCA fixes alignment of checkboxes on Setting Overrides page
|
|
break;
|
|
}
|
|
}
|
|
wxString label = line.label;
|
|
if (opt_group->label_width != 0)
|
|
add_label_width(ctrl_line, label, opt_group->label_width * m_em_unit);
|
|
|
|
int blinking_button_width = m_bmp_blinking_sz.GetWidth() + m_h_gap;
|
|
|
|
if (line.widget) {
|
|
#ifndef DISABLE_BLINKING
|
|
h_pos += (line.has_undo_ui() ? 3 : 1) * blinking_button_width;
|
|
#else
|
|
// When blinking is disabled, widget lines still need to honour
|
|
// option_label_at_right the same way regular lines do below.
|
|
if (opt_group->option_label_at_right)
|
|
add_buttons_width(blinking_button_width);
|
|
#endif
|
|
|
|
for (auto child : line.widget_sizer->GetChildren())
|
|
if (child->IsWindow())
|
|
correct_line_height(ctrl_line.height, child->GetWindow());
|
|
break;
|
|
}
|
|
|
|
if (opt_group->option_label_at_right) // BBS: position buttons at right
|
|
add_buttons_width(blinking_button_width);
|
|
|
|
// If we have a single option with no sidetext
|
|
const std::vector<Option>& option_set = line.get_options();
|
|
if (option_set.size() == 1 && option_set.front().opt.sidetext.size() == 0 &&
|
|
option_set.front().side_widget == nullptr && line.get_extra_widgets().size() == 0)
|
|
{
|
|
// BBS: new layout
|
|
// h_pos += 3 * blinking_button_width;
|
|
Field* field = opt_group->get_field(option_set.front().opt_id);
|
|
correct_line_height(ctrl_line.height, field->getWindow());
|
|
correct_horiz_pos(h_pos, field);
|
|
break;
|
|
}
|
|
|
|
bool is_multioption_line = option_set.size() > 1;
|
|
for (auto opt : option_set) {
|
|
Field* field = opt_group->get_field(opt.opt_id);
|
|
correct_line_height(ctrl_line.height, field->getWindow());
|
|
|
|
if (!opt_group->option_label_at_right) { // BBS: position option label at right
|
|
ConfigOptionDef option = opt.opt;
|
|
// add label if any
|
|
if (is_multioption_line && !option.label.empty()) {
|
|
//! To correct translation by context have to use wxGETTEXT_IN_CONTEXT macro from wxWidget 3.1.1
|
|
auto label = (option.label == L_CONTEXT("Top", "Layers") || option.label == L_CONTEXT("Bottom", "Layers")) ? _L_CONTEXT(option.label, "Layers") :
|
|
_(option.label);
|
|
// BBS
|
|
// label += ":";
|
|
add_label_width(ctrl_line, label, opt_group->sublabel_width * m_em_unit);
|
|
h_pos += 8;
|
|
}
|
|
|
|
}
|
|
|
|
if (field == field_in) {
|
|
correct_horiz_pos(h_pos, field);
|
|
break;
|
|
}
|
|
if (opt_group->split_multi_line) {// BBS
|
|
v_pos += (ctrl_line.height - m_v_gap + m_v_gap2) / option_set.size();
|
|
} else {
|
|
// BBS: new layout
|
|
h_pos += field->getWindow()->GetSize().x;
|
|
add_buttons_width(blinking_button_width);
|
|
if (option_set.size() == 1 && option_set.front().opt.full_width)
|
|
break;
|
|
|
|
// add sidetext if any
|
|
if (!field->combine_side_text() && (!opt.opt.sidetext.empty() || opt_group->sidetext_width > 0))
|
|
h_pos += opt_group->sidetext_width * m_em_unit + m_h_gap;
|
|
|
|
if (opt.opt_id != option_set.back().opt_id) //! istead of (opt != option_set.back())
|
|
h_pos += lround(0.6 * m_em_unit);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
if (ctrl_line.is_visible)
|
|
v_pos += ctrl_line.height;
|
|
}
|
|
|
|
return wxPoint(h_pos, v_pos);
|
|
}
|
|
|
|
// BBS: draw multi-line title
|
|
static void draw_title(wxDC& dc, wxPoint pos, const wxString& text, const wxColour* color, int width)
|
|
{
|
|
wxString multiline_text;
|
|
if (width > 0 && dc.GetTextExtent(text).x > width) {
|
|
multiline_text = text;
|
|
|
|
size_t idx = size_t(-1);
|
|
size_t start = 0;
|
|
for (size_t i = 0; i < multiline_text.Len(); i++)
|
|
{
|
|
if (multiline_text[i] == ' ')
|
|
{
|
|
if (dc.GetTextExtent(multiline_text.SubString(start, i)).x < width)
|
|
idx = i;
|
|
else {
|
|
if (idx == size_t(-1))
|
|
idx = i;
|
|
multiline_text[idx] = '\n';
|
|
start = idx + 1;
|
|
idx = size_t(-1);
|
|
}
|
|
}
|
|
}
|
|
if (idx != size_t(-1))
|
|
multiline_text[idx] = '\n';
|
|
}
|
|
|
|
if (!text.IsEmpty()) {
|
|
const wxString& out_text = multiline_text.IsEmpty() ? text : multiline_text;
|
|
wxCoord text_width, text_height;
|
|
dc.GetMultiLineTextExtent(out_text, &text_width, &text_height);
|
|
|
|
wxColour old_clr = dc.GetTextForeground();
|
|
wxFont old_font = dc.GetFont();
|
|
dc.SetTextForeground(color ? *color :
|
|
#ifdef _WIN32
|
|
wxGetApp().get_label_clr_default());
|
|
#else
|
|
wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT));
|
|
#endif /* _WIN32 */
|
|
dc.DrawText(out_text, pos);
|
|
dc.SetTextForeground(old_clr);
|
|
dc.SetFont(old_font);
|
|
|
|
if (width < 1)
|
|
width = text_width;
|
|
}
|
|
}
|
|
|
|
void OG_CustomCtrl::OnPaint(wxPaintEvent&)
|
|
{
|
|
// case, when custom controll is destroyed but doesn't deleted from the evet loop
|
|
if(!this->opt_group->custom_ctrl)
|
|
return;
|
|
|
|
wxPaintDC dc(this);
|
|
|
|
wxCoord h_pos = get_title_width() * m_em_unit + 4; // ORCA Align label with group title. StaticLine.cpp uses 18px for icon 5px for spacing. Spacing doesnt scales on messureSize()
|
|
wxCoord v_pos = 0;
|
|
// BBS: new layout
|
|
if (!GetLabel().IsEmpty()) {
|
|
dc.SetFont(Label::Head_16);
|
|
wxColour color = StateColor::darkModeColorFor("#283436");
|
|
draw_title(dc, {0, v_pos}, GetLabel(), &color, h_pos);
|
|
dc.SetFont(m_font);
|
|
}
|
|
for (CtrlLine& line : ctrl_lines) {
|
|
if (!line.is_visible)
|
|
continue;
|
|
line.render(dc, h_pos, v_pos);
|
|
v_pos += line.height;
|
|
}
|
|
}
|
|
|
|
void OG_CustomCtrl::OnMotion(wxMouseEvent& event)
|
|
{
|
|
const wxPoint pos = event.GetLogicalPosition(wxClientDC(this));
|
|
wxString tooltip;
|
|
std::string markdowntip;
|
|
|
|
// BBS: markdown tip
|
|
CtrlLine* focusedLine = nullptr;
|
|
// BBS
|
|
|
|
bool suppress_hyperlinks = false;
|
|
|
|
for (CtrlLine& line : ctrl_lines) {
|
|
if (!line.is_visible) continue;
|
|
line.is_focused = is_point_in_rect(pos, line.rect_label);
|
|
if (line.is_focused) {
|
|
if (!suppress_hyperlinks && !line.og_line.label_path.empty())
|
|
tooltip = OptionsGroup::get_url(line.og_line.label_path) + "\n\n";
|
|
tooltip += line.og_line.label_tooltip;
|
|
// BBS: markdown tip
|
|
focusedLine = &line;
|
|
markdowntip = line.og_line.label.empty()
|
|
? line.og_line.get_options().front().opt_id : into_u8(line.og_line.label);
|
|
markdowntip.erase(0, markdowntip.find_last_of('#') + 1);
|
|
// BBS
|
|
break;
|
|
}
|
|
|
|
size_t undo_icons_cnt = line.rects_undo_icon.size();
|
|
assert(line.rects_undo_icon.size() == line.rects_undo_to_sys_icon.size());
|
|
const std::vector<Option>& option_set = line.og_line.get_options();
|
|
for (size_t opt_idx = 0; opt_idx < undo_icons_cnt; opt_idx++) {
|
|
const std::string& opt_key = option_set[opt_idx].opt_id;
|
|
if (is_point_in_rect(pos, line.rects_undo_icon[opt_idx])) {
|
|
if (line.og_line.has_undo_ui())
|
|
tooltip = *line.og_line.undo_tooltip();
|
|
else if (Field* field = opt_group->get_field(opt_key))
|
|
tooltip = *field->undo_tooltip();
|
|
break;
|
|
}
|
|
if (is_point_in_rect(pos, line.rects_undo_to_sys_icon[opt_idx])) {
|
|
if (line.og_line.has_undo_ui())
|
|
tooltip = *line.og_line.undo_to_sys_tooltip();
|
|
else if (Field* field = opt_group->get_field(opt_key))
|
|
tooltip = *field->undo_to_sys_tooltip();
|
|
break;
|
|
}
|
|
if (opt_idx < line.rects_edit_icon.size() && is_point_in_rect(pos, line.rects_edit_icon[opt_idx])) {
|
|
if (Field* field = opt_group->get_field(opt_key); field && field->has_edit_ui())
|
|
tooltip = *field->edit_tooltip();
|
|
break;
|
|
}
|
|
}
|
|
if (!tooltip.IsEmpty())
|
|
break;
|
|
}
|
|
|
|
// Set tooltips with information for each icon
|
|
// BBS: markdown tip
|
|
if (!markdowntip.empty()) {
|
|
wxWindow* window = GetGrandParent();
|
|
assert(focusedLine);
|
|
wxPoint pos2 = { 250, focusedLine->rect_label.y };
|
|
pos2 = ClientToScreen(pos2);
|
|
if (MarkdownTip::ShowTip(markdowntip, into_u8(tooltip), pos2)) {
|
|
tooltip.clear();
|
|
}
|
|
}
|
|
else {
|
|
MarkdownTip::ShowTip(markdowntip, "", {tooltip.empty() ? 0 : 1, 0});
|
|
}
|
|
if (GetToolTipText() != tooltip)
|
|
this->SetToolTip(tooltip);
|
|
// BBS
|
|
|
|
Refresh();
|
|
Update();
|
|
event.Skip();
|
|
}
|
|
|
|
void OG_CustomCtrl::OnLeftDown(wxMouseEvent& event)
|
|
{
|
|
const wxPoint pos = event.GetLogicalPosition(wxClientDC(this));
|
|
|
|
for (const CtrlLine& line : ctrl_lines) {
|
|
if (!line.is_visible) continue;
|
|
if (line.launch_browser())
|
|
return;
|
|
size_t undo_icons_cnt = line.rects_undo_icon.size();
|
|
assert(line.rects_undo_icon.size() == line.rects_undo_to_sys_icon.size());
|
|
|
|
const std::vector<Option>& option_set = line.og_line.get_options();
|
|
for (size_t opt_idx = 0; opt_idx < undo_icons_cnt; opt_idx++) {
|
|
const std::string& opt_key = option_set[opt_idx].opt_id;
|
|
if (is_point_in_rect(pos, line.rects_undo_icon[opt_idx])) {
|
|
if (line.og_line.has_undo_ui()) {
|
|
if (ConfigOptionsGroup* conf_OG = dynamic_cast<ConfigOptionsGroup*>(line.ctrl->opt_group))
|
|
conf_OG->back_to_initial_value(opt_key);
|
|
}
|
|
else if (Field* field = opt_group->get_field(opt_key))
|
|
field->on_back_to_initial_value();
|
|
event.Skip();
|
|
return;
|
|
}
|
|
if (is_point_in_rect(pos, line.rects_undo_to_sys_icon[opt_idx])) {
|
|
if (line.og_line.has_undo_ui()) {
|
|
if (ConfigOptionsGroup* conf_OG = dynamic_cast<ConfigOptionsGroup*>(line.ctrl->opt_group))
|
|
conf_OG->back_to_sys_value(opt_key);
|
|
}
|
|
else if (Field* field = opt_group->get_field(opt_key))
|
|
field->on_back_to_sys_value();
|
|
event.Skip();
|
|
return;
|
|
}
|
|
|
|
if (opt_idx < line.rects_edit_icon.size() && is_point_in_rect(pos, line.rects_edit_icon[opt_idx])) {
|
|
if (Field* field = opt_group->get_field(opt_key))
|
|
field->on_edit_value();
|
|
event.Skip();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
SetFocusIgnoringChildren();
|
|
}
|
|
|
|
void OG_CustomCtrl::OnLeaveWin(wxMouseEvent& event)
|
|
{
|
|
for (CtrlLine& line : ctrl_lines)
|
|
line.is_focused = false;
|
|
|
|
// BBS: markdown tip
|
|
MarkdownTip::ShowTip("", "", {});
|
|
|
|
Refresh();
|
|
Update();
|
|
event.Skip();
|
|
}
|
|
|
|
bool OG_CustomCtrl::update_visibility(ConfigOptionMode mode)
|
|
{
|
|
// BBS: new layout
|
|
wxCoord h_pos = (ctrlWidth + get_title_width() - titleWidth) * m_em_unit;
|
|
wxCoord h_pos2 = get_title_width() * m_em_unit;
|
|
wxCoord v_pos = 0;
|
|
|
|
bool has_visible_lines = false;
|
|
for (CtrlLine& line : ctrl_lines) {
|
|
line.update_visibility(mode);
|
|
if (line.is_visible) {
|
|
v_pos += (wxCoord)line.height;
|
|
|
|
if (!line.is_separator()) { // Ignore separators
|
|
has_visible_lines = true;
|
|
}
|
|
}
|
|
}
|
|
// BBS: multi-line title
|
|
SetFont(Label::Head_16);
|
|
wxSize label_sz = GetTextExtent(GetLabel());
|
|
SetFont(m_font);
|
|
auto lineHeight = label_sz.y;
|
|
while (label_sz.x > h_pos2) {
|
|
label_sz.x -= h_pos2;
|
|
label_sz.y += lineHeight;
|
|
}
|
|
if (v_pos < label_sz.y) v_pos = label_sz.y;
|
|
|
|
this->SetMinSize(wxSize(h_pos, v_pos));
|
|
|
|
return has_visible_lines;
|
|
}
|
|
|
|
// BBS: call by Tab/Page
|
|
void OG_CustomCtrl::fixup_items_positions()
|
|
{
|
|
if (GetParent() == nullptr || GetPosition().y + GetSize().y < GetParent()->GetSize().y)
|
|
return;
|
|
for (CtrlLine& line : ctrl_lines) {
|
|
line.correct_items_positions();
|
|
}
|
|
}
|
|
|
|
void OG_CustomCtrl::correct_window_position(wxWindow* win, const Line& line, Field* field/* = nullptr*/)
|
|
{
|
|
wxPoint pos = get_pos(line, field);
|
|
int line_height = get_height(line);
|
|
if (opt_group->split_multi_line) { // BBS
|
|
if (line.get_options().size() > 1)
|
|
line_height = (line_height - m_v_gap + m_v_gap2) / line.get_options().size();
|
|
}
|
|
pos.y += std::max(0, int(0.5 * (line_height - win->GetSize().y)));
|
|
win->SetPosition(pos);
|
|
};
|
|
|
|
void OG_CustomCtrl::correct_widgets_position(wxSizer* widget, const Line& line, Field* field/* = nullptr*/) {
|
|
auto children = widget->GetChildren();
|
|
wxPoint line_pos = get_pos(line, field);
|
|
int line_height = get_height(line);
|
|
for (auto child : children)
|
|
if (child->IsWindow()) {
|
|
wxPoint pos = line_pos;
|
|
wxSize sz = child->GetWindow()->GetSize();
|
|
pos.y += std::max(0, int(0.5 * (line_height - sz.y)));
|
|
if (line.extra_widget_sizer && widget == line.extra_widget_sizer)
|
|
pos.x += m_h_gap;
|
|
child->GetWindow()->SetPosition(pos);
|
|
line_pos.x += sz.x + m_h_gap;
|
|
}
|
|
};
|
|
|
|
void OG_CustomCtrl::init_max_win_width()
|
|
{
|
|
if (opt_group->ctrl_horiz_alignment == wxALIGN_RIGHT && m_max_win_width == 0)
|
|
for (CtrlLine& line : ctrl_lines) {
|
|
if (int max_win_width = line.get_max_win_width();
|
|
m_max_win_width < max_win_width)
|
|
m_max_win_width = max_win_width;
|
|
}
|
|
}
|
|
|
|
int OG_CustomCtrl::get_title_width()
|
|
{
|
|
if (!GetLabel().IsEmpty())
|
|
return titleWidth;
|
|
else
|
|
return 2;
|
|
}
|
|
|
|
void OG_CustomCtrl::set_max_win_width(int max_win_width)
|
|
{
|
|
if (m_max_win_width == max_win_width)
|
|
return;
|
|
m_max_win_width = max_win_width;
|
|
for (CtrlLine& line : ctrl_lines)
|
|
line.correct_items_positions();
|
|
|
|
GetParent()->Layout();
|
|
}
|
|
|
|
|
|
void OG_CustomCtrl::msw_rescale()
|
|
{
|
|
#ifdef __WXOSX__
|
|
return;
|
|
#endif
|
|
// BBS: new font
|
|
m_font = Label::Body_14;
|
|
SetFont(m_font);
|
|
m_em_unit = em_unit(m_parent);
|
|
m_v_gap = lround(1.2 * m_em_unit);
|
|
m_v_gap2 = lround(0.8 * m_em_unit);
|
|
m_h_gap = lround(0.2 * m_em_unit);
|
|
|
|
//m_bmp_mode_sz = create_scaled_bitmap("mode_simple", this, wxOSX ? 10 : 12).GetSize();
|
|
m_bmp_blinking_sz = create_scaled_bitmap("blank_16", this).GetSize();
|
|
if (m_bmp_multi_extruder.bmp().IsOk())
|
|
m_bmp_multi_extruder.msw_rescale();
|
|
|
|
m_max_win_width = 0;
|
|
|
|
wxCoord h_pos = (ctrlWidth + get_title_width() - titleWidth) * m_em_unit;
|
|
wxCoord h_pos2 = get_title_width() * m_em_unit;
|
|
wxCoord v_pos = 0;
|
|
for (CtrlLine& line : ctrl_lines) {
|
|
line.msw_rescale();
|
|
if (line.is_visible)
|
|
v_pos += (wxCoord)line.height;
|
|
}
|
|
// BBS: multi-line title
|
|
SetFont(Label::Head_16);
|
|
wxSize label_sz = GetTextExtent(GetLabel());
|
|
SetFont(m_font);
|
|
auto lineHeight = label_sz.y;
|
|
while (label_sz.x > h_pos2) {
|
|
label_sz.x -= h_pos2;
|
|
label_sz.y += lineHeight;
|
|
}
|
|
if (v_pos < label_sz.y) v_pos = label_sz.y;
|
|
// BBS: new layout
|
|
this->SetMinSize(wxSize(h_pos, v_pos));
|
|
|
|
GetParent()->Layout();
|
|
}
|
|
|
|
void OG_CustomCtrl::sys_color_changed()
|
|
{
|
|
if (m_bmp_multi_extruder.bmp().IsOk())
|
|
m_bmp_multi_extruder.msw_rescale();
|
|
}
|
|
|
|
OG_CustomCtrl::CtrlLine::CtrlLine( wxCoord height,
|
|
OG_CustomCtrl* ctrl,
|
|
const Line& og_line,
|
|
bool draw_just_act_buttons /* = false*/,
|
|
bool draw_mode_bitmap/* = true*/):
|
|
height(height),
|
|
ctrl(ctrl),
|
|
og_line(og_line),
|
|
draw_just_act_buttons(draw_just_act_buttons),
|
|
draw_mode_bitmap(draw_mode_bitmap)
|
|
{
|
|
|
|
for (size_t i = 0; i < og_line.get_options().size(); i++) {
|
|
rects_undo_icon.emplace_back(wxRect());
|
|
rects_undo_to_sys_icon.emplace_back(wxRect());
|
|
}
|
|
}
|
|
|
|
int OG_CustomCtrl::CtrlLine::get_max_win_width()
|
|
{
|
|
int max_win_width = 0;
|
|
if (!draw_just_act_buttons) {
|
|
const std::vector<Option>& option_set = og_line.get_options();
|
|
for (auto opt : option_set) {
|
|
Field* field = ctrl->opt_group->get_field(opt.opt_id);
|
|
if (field && field->getWindow())
|
|
max_win_width = field->getWindow()->GetSize().GetWidth();
|
|
}
|
|
}
|
|
|
|
return max_win_width;
|
|
}
|
|
|
|
void OG_CustomCtrl::CtrlLine::correct_items_positions()
|
|
{
|
|
if (draw_just_act_buttons || !is_visible)
|
|
return;
|
|
|
|
if (og_line.near_label_widget_win)
|
|
ctrl->correct_window_position(og_line.near_label_widget_win, og_line);
|
|
if (og_line.widget_sizer)
|
|
ctrl->correct_widgets_position(og_line.widget_sizer, og_line);
|
|
if (og_line.extra_widget_sizer)
|
|
ctrl->correct_widgets_position(og_line.extra_widget_sizer, og_line);
|
|
|
|
const std::vector<Option>& option_set = og_line.get_options();
|
|
for (auto opt : option_set) {
|
|
Field* field = ctrl->opt_group->get_field(opt.opt_id);
|
|
if (!field)
|
|
continue;
|
|
if (field->getSizer())
|
|
ctrl->correct_widgets_position(field->getSizer(), og_line, field);
|
|
else if (field->getWindow())
|
|
ctrl->correct_window_position(field->getWindow(), og_line, field);
|
|
}
|
|
}
|
|
|
|
void OG_CustomCtrl::CtrlLine::msw_rescale()
|
|
{
|
|
// if we have a single option with no label, no sidetext
|
|
if (draw_just_act_buttons)
|
|
height = get_bitmap_size(create_scaled_bitmap("empty")).GetHeight();
|
|
|
|
if (ctrl->opt_group->label_width != 0 && !og_line.label.IsEmpty()) {
|
|
wxSize label_sz = ctrl->GetTextExtent(og_line.label);
|
|
if (ctrl->opt_group->split_multi_line) { // BBS
|
|
const std::vector<Option> &option_set = og_line.get_options();
|
|
if (option_set.size() > 1)
|
|
height = (label_sz.y + ctrl->m_v_gap2) * option_set.size() + ctrl->m_v_gap - ctrl->m_v_gap2;
|
|
else
|
|
height = label_sz.y * (label_sz.GetWidth() > int(ctrl->opt_group->label_width * ctrl->m_em_unit) ? 2 : 1) + ctrl->m_v_gap;
|
|
} else {
|
|
height = label_sz.y * (label_sz.GetWidth() > int(ctrl->opt_group->label_width * ctrl->m_em_unit) ? 2 : 1) + ctrl->m_v_gap;
|
|
}
|
|
}
|
|
|
|
correct_items_positions();
|
|
}
|
|
|
|
void OG_CustomCtrl::CtrlLine::update_visibility(ConfigOptionMode mode)
|
|
{
|
|
if (og_line.is_separator())
|
|
return;
|
|
const std::vector<Option>& option_set = og_line.get_options();
|
|
|
|
// Widget-only lines (no options appended) are always visible unless explicitly hidden.
|
|
if (option_set.empty()) {
|
|
is_visible = og_line.toggle_visible;
|
|
if (og_line.near_label_widget_win)
|
|
og_line.near_label_widget_win->Show(is_visible);
|
|
if (og_line.widget_sizer)
|
|
og_line.widget_sizer->ShowItems(is_visible);
|
|
if (og_line.extra_widget_sizer)
|
|
og_line.extra_widget_sizer->ShowItems(is_visible);
|
|
correct_items_positions();
|
|
return;
|
|
}
|
|
|
|
const ConfigOptionMode& line_mode = option_set.front().opt.mode;
|
|
is_visible = og_line.toggle_visible && line_mode <= mode;
|
|
|
|
if (draw_just_act_buttons)
|
|
return;
|
|
|
|
if (og_line.near_label_widget_win)
|
|
og_line.near_label_widget_win->Show(is_visible);
|
|
if (og_line.widget_sizer)
|
|
og_line.widget_sizer->ShowItems(is_visible);
|
|
if (og_line.extra_widget_sizer)
|
|
og_line.extra_widget_sizer->ShowItems(is_visible);
|
|
|
|
for (auto opt : option_set) {
|
|
Field* field = ctrl->opt_group->get_field(opt.opt_id);
|
|
if (!field)
|
|
continue;
|
|
|
|
if (field->getSizer()) {
|
|
auto children = field->getSizer()->GetChildren();
|
|
for (auto child : children)
|
|
if (child->IsWindow())
|
|
child->GetWindow()->Show(is_visible);
|
|
}
|
|
else if (field->getWindow())
|
|
field->getWindow()->Show(is_visible);
|
|
}
|
|
|
|
correct_items_positions();
|
|
}
|
|
|
|
void OG_CustomCtrl::CtrlLine::render_separator(wxDC& dc, wxCoord v_pos)
|
|
{
|
|
wxPoint begin(ctrl->m_h_gap, v_pos);
|
|
wxPoint end(ctrl->GetSize().GetWidth() - ctrl->m_h_gap, v_pos);
|
|
|
|
wxPen old_pen = dc.GetPen();
|
|
// pen.SetColour(*wxLIGHT_GREY);
|
|
dc.SetPen(*wxTRANSPARENT_PEN);
|
|
dc.DrawLine(begin, end);
|
|
dc.SetPen(old_pen);
|
|
}
|
|
|
|
void OG_CustomCtrl::CtrlLine::render(wxDC& dc, wxCoord h_pos, wxCoord v_pos)
|
|
{
|
|
if (is_separator()) {
|
|
render_separator(dc, v_pos);
|
|
return;
|
|
}
|
|
|
|
Field* field = og_line.get_options().empty() ? nullptr :
|
|
ctrl->opt_group->get_field(og_line.get_options().front().opt_id);
|
|
|
|
bool suppress_hyperlinks = false;
|
|
if (draw_just_act_buttons) {
|
|
//BBS: GUI refactor
|
|
if (field && field->undo_bitmap()) {
|
|
// if (field)
|
|
// BBS: new layout
|
|
const wxPoint pos = draw_act_bmps(dc, wxPoint(h_pos, v_pos), field->undo_to_sys_bitmap()->bmp(),
|
|
field->undo_bitmap()->bmp(), field->blink());
|
|
if (field->has_edit_ui())
|
|
draw_edit_bmp(dc, pos, *field->edit_bitmap());
|
|
}
|
|
return;
|
|
}
|
|
|
|
const std::vector<Option>& option_set = og_line.get_options();
|
|
|
|
// ORCA draw multi_extruder icon to spacing before field lines so labels keep aligned
|
|
bool is_multi_extruder = false;
|
|
if (ctrl->opt_group->draw_multi_extruder)
|
|
for (const Option& opt : option_set)
|
|
is_multi_extruder |= opt.opt_id.find_last_of('#') != std::string::npos;
|
|
if (is_multi_extruder) {
|
|
if (!ctrl->m_bmp_multi_extruder.bmp().IsOk())
|
|
ctrl->m_bmp_multi_extruder = ScalableBitmap(ctrl, "multi_extruder");
|
|
const wxBitmap& bmp = ctrl->m_bmp_multi_extruder.bmp();
|
|
const wxSize bmp_sz = get_bitmap_size(bmp); // Logical size. GetWidth() returns physical pixels on macOS retina which pushes icon outside of control
|
|
const wxCoord icon_x = std::max(0, h_pos - bmp_sz.GetWidth() - ctrl->m_h_gap);
|
|
const wxCoord icon_y = v_pos + lround((height - bmp_sz.GetHeight()) / 2);
|
|
dc.DrawBitmap(bmp, icon_x, icon_y, true);
|
|
}
|
|
|
|
if (og_line.near_label_widget_win)
|
|
h_pos += og_line.near_label_widget_win->GetSize().x + ctrl->m_h_gap;
|
|
|
|
wxString label = og_line.label;
|
|
wxColour blink_color = StateColor::darkModeColorFor("#009688");
|
|
bool is_url_string = false;
|
|
if (ctrl->opt_group->label_width != 0 && !label.IsEmpty()) {
|
|
const wxColour* text_clr = field ? field->label_color() : og_line.label_color();
|
|
for (const Option& opt : option_set) {
|
|
Field* field = ctrl->opt_group->get_field(opt.opt_id);
|
|
if (field && field->blink()) {
|
|
text_clr = &blink_color;
|
|
break;
|
|
}
|
|
}
|
|
is_url_string = !suppress_hyperlinks && !og_line.label_path.empty();
|
|
// BBS
|
|
h_pos = draw_text(dc, wxPoint(h_pos, v_pos), label /* + ":" */, text_clr, h_pos + ctrl->opt_group->label_width * ctrl->m_em_unit - h_pos, is_url_string, true);
|
|
}
|
|
|
|
// If there's a widget, build it and set result to the correct position.
|
|
#ifndef DISABLE_BLINKING
|
|
if (og_line.widget != nullptr) {
|
|
draw_blinking_bmp(dc, wxPoint(h_pos, v_pos), og_line.blink);
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
// If we're here, we have more than one option or a single option with sidetext
|
|
// so we need a horizontal sizer to arrange these things
|
|
|
|
auto add_field_width = [&h_pos, this] (Field* field) {
|
|
if (field) {
|
|
if (field->getSizer())
|
|
{
|
|
auto children = field->getSizer()->GetChildren();
|
|
for (auto child : children)
|
|
if (child->IsWindow())
|
|
h_pos += child->GetWindow()->GetSize().x + ctrl->m_h_gap;
|
|
}
|
|
else if (field->getWindow()) {
|
|
h_pos += field->getWindow()->GetSize().x + ctrl->m_h_gap;
|
|
}
|
|
}
|
|
};
|
|
|
|
auto draw_buttons = [&h_pos, &dc, &v_pos, this](Field* field, size_t bmp_rect_id = 0) {
|
|
if (field && field->undo_to_sys_bitmap()) {
|
|
h_pos = draw_act_bmps(dc, wxPoint(h_pos, v_pos), field->undo_to_sys_bitmap()->bmp(), field->undo_bitmap()->bmp(), field->blink(), bmp_rect_id).x;
|
|
}
|
|
#ifndef DISABLE_BLINKING
|
|
else if (field && !field->undo_to_sys_bitmap() && field->blink())
|
|
draw_blinking_bmp(dc, wxPoint(h_pos, v_pos), field->blink());
|
|
#endif
|
|
};
|
|
|
|
wxCoord h_pos2 = h_pos;
|
|
// If we have a single option with no sidetext just add it directly to the grid sizer
|
|
if (option_set.size() == 1 && option_set.front().opt.sidetext.size() == 0 &&
|
|
option_set.front().side_widget == nullptr && og_line.get_extra_widgets().size() == 0)
|
|
{
|
|
// BBS: new layout
|
|
if (ctrl->opt_group->option_label_at_right)
|
|
draw_buttons(field);
|
|
add_field_width(field);
|
|
wxCoord h_pos3 = h_pos;
|
|
if (!ctrl->opt_group->option_label_at_right)
|
|
draw_buttons(field);
|
|
// update width for full_width fields
|
|
if (option_set.front().opt.full_width && field && field->getWindow())
|
|
field->getWindow()->SetSize(ctrl->GetSize().x - h_pos2 + h_pos3 - h_pos - ctrl->m_em_unit * 3, -1);
|
|
return;
|
|
}
|
|
|
|
size_t bmp_rect_id = 0;
|
|
bool is_multioption_line = option_set.size() > 1;
|
|
for (const Option& opt : option_set) {
|
|
field = ctrl->opt_group->get_field(opt.opt_id);
|
|
ConfigOptionDef option = opt.opt;
|
|
if (ctrl->opt_group->option_label_at_right)
|
|
draw_buttons(field, bmp_rect_id++);
|
|
if (ctrl->opt_group->option_label_at_right) // BBS
|
|
add_field_width(field);
|
|
// add label if any
|
|
if (is_multioption_line && !option.label.empty()) {
|
|
//! To correct translation by context have to use wxGETTEXT_IN_CONTEXT macro from wxWidget 3.1.1
|
|
label = (option.label == L_CONTEXT("Top", "Layers") || option.label == L_CONTEXT("Bottom", "Layers")) ?
|
|
_L_CONTEXT(option.label, "Layers") : _(option.label);
|
|
//if (!ctrl->opt_group->option_label_at_right) // BBS
|
|
//label += ":";
|
|
|
|
if (is_url_string)
|
|
is_url_string = false;
|
|
else if(opt == option_set.front())
|
|
is_url_string = !suppress_hyperlinks && !og_line.label_path.empty();
|
|
wxColor c = StateColor::darkModeColorFor("#6B6B6B");
|
|
h_pos = draw_text(dc, wxPoint(h_pos, v_pos), label, field ? (field->blink() ? &blink_color : &c) : nullptr, ctrl->opt_group->sublabel_width * ctrl->m_em_unit, is_url_string);
|
|
h_pos += 8;
|
|
}
|
|
|
|
// BBS: new layout
|
|
// add field
|
|
if (option_set.size() == 1 && option_set.front().opt.full_width)
|
|
break;
|
|
if (!ctrl->opt_group->option_label_at_right) // BBS
|
|
add_field_width(field);
|
|
// add sidetext if any
|
|
// BBS: new layout
|
|
wxCoord offset = 0;
|
|
if (!field->combine_side_text() && (!option.sidetext.empty() || ctrl->opt_group->sidetext_width > 0)) {
|
|
wxCoord h_pos2 = h_pos + dc.GetTextExtent(_(option.sidetext)).x;
|
|
h_pos = draw_text(dc, wxPoint(h_pos, v_pos), _(option.sidetext), nullptr, ctrl->opt_group->sidetext_width * ctrl->m_em_unit);
|
|
offset = h_pos - h_pos2;
|
|
}
|
|
// BBS: new layout
|
|
if (!ctrl->opt_group->option_label_at_right) {
|
|
offset -= ctrl->m_h_gap; h_pos -= offset;
|
|
draw_buttons(field, bmp_rect_id++);
|
|
h_pos += offset;
|
|
}
|
|
|
|
if (opt.opt_id != option_set.back().opt_id) //! istead of (opt != option_set.back())
|
|
h_pos += lround(0.6 * ctrl->m_em_unit);
|
|
|
|
if (ctrl->opt_group->split_multi_line) { // BBS
|
|
v_pos += (height - ctrl->m_v_gap + ctrl->m_v_gap2) / option_set.size();
|
|
h_pos = h_pos2;
|
|
}
|
|
}
|
|
}
|
|
|
|
wxCoord OG_CustomCtrl::CtrlLine::draw_text(wxDC &dc, wxPoint pos, const wxString &text, const wxColour *color, int width, bool is_url/* = false*/, bool is_main/* = false*/)
|
|
{
|
|
wxString multiline_text;
|
|
auto size = Label::split_lines(dc, width, text, multiline_text);
|
|
|
|
if (!text.IsEmpty()) {
|
|
const wxString& out_text = multiline_text.IsEmpty() ? text : multiline_text;
|
|
|
|
if (ctrl->opt_group->split_multi_line && !is_main) { // BBS
|
|
const std::vector<Option> &option_set = og_line.get_options();
|
|
pos.y = pos.y + lround(((height - ctrl->m_v_gap + ctrl->m_v_gap2) / option_set.size() - size.y) / 2);
|
|
} else {
|
|
pos.y = pos.y + lround((height - size.y) / 2);
|
|
}
|
|
if (width > 0 && is_main)
|
|
rect_label = wxRect(pos, wxSize(size.x, size.y));
|
|
|
|
wxColour old_clr = dc.GetTextForeground();
|
|
wxFont old_font = dc.GetFont();
|
|
wxColor clr_url = StateColor::darkModeColorFor("#009688");
|
|
if (is_focused && is_url) {
|
|
// temporary workaround for the OSX because of strange Bold font behavior on BigSerf
|
|
#ifdef __APPLE__
|
|
dc.SetFont(old_font.Underlined());
|
|
#else
|
|
dc.SetFont(old_font.Bold().Underlined());
|
|
#endif
|
|
color = &clr_url;
|
|
}
|
|
dc.SetTextForeground(color ? *color :
|
|
#ifdef _WIN32
|
|
wxGetApp().get_label_clr_default());
|
|
#else
|
|
wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT));
|
|
#endif /* _WIN32 */
|
|
dc.DrawText(out_text, pos);
|
|
dc.SetTextForeground(old_clr);
|
|
dc.SetFont(old_font);
|
|
|
|
if (width < 1)
|
|
width = size.x;
|
|
}
|
|
|
|
return pos.x + width + ctrl->m_h_gap;
|
|
}
|
|
|
|
wxPoint OG_CustomCtrl::CtrlLine::draw_blinking_bmp(wxDC& dc, wxPoint pos, bool is_blinking)
|
|
{
|
|
wxBitmap bmp_blinking = create_scaled_bitmap(is_blinking ? "blank_16" : "empty", ctrl);
|
|
wxCoord h_pos = pos.x;
|
|
wxCoord v_pos = pos.y + lround((height - get_bitmap_size(bmp_blinking).GetHeight()) / 2);
|
|
|
|
dc.DrawBitmap(bmp_blinking, h_pos, v_pos);
|
|
|
|
int bmp_dim = get_bitmap_size(bmp_blinking).GetWidth();
|
|
|
|
h_pos += bmp_dim + ctrl->m_h_gap;
|
|
return wxPoint(h_pos, v_pos);
|
|
}
|
|
|
|
wxPoint OG_CustomCtrl::CtrlLine::draw_act_bmps(wxDC& dc, wxPoint pos, const wxBitmap& bmp_undo_to_sys, const wxBitmap& bmp_undo, bool is_blinking, size_t rect_id, bool is_main)
|
|
{
|
|
#ifndef DISABLE_BLINKING
|
|
pos = draw_blinking_bmp(dc, pos, is_blinking);
|
|
#else
|
|
if (ctrl->opt_group->split_multi_line && !is_main) { // BBS
|
|
const std::vector<Option> &option_set = og_line.get_options();
|
|
if (option_set.size() > 1)
|
|
pos.y += lround(((height - ctrl->m_v_gap + ctrl->m_v_gap2) / option_set.size() - get_bitmap_size(bmp_undo).GetHeight()) / 2);
|
|
else
|
|
pos.y += lround((height - get_bitmap_size(bmp_undo).GetHeight()) / 2);
|
|
} else {
|
|
pos.y += lround((height - get_bitmap_size(bmp_undo).GetHeight()) / 2);
|
|
}
|
|
#endif
|
|
wxCoord h_pos = pos.x - ctrl->m_h_gap; // Orca: adjust position to the left
|
|
wxCoord v_pos = pos.y;
|
|
|
|
#ifndef DISABLE_UNDO_SYS
|
|
//BBS: GUI refactor
|
|
dc.DrawBitmap(bmp_undo_to_sys, h_pos, v_pos);
|
|
|
|
int bmp_dim = get_bitmap_size(bmp_undo_to_sys).GetWidth();
|
|
rects_undo_to_sys_icon[rect_id] = wxRect(h_pos, v_pos, bmp_dim, bmp_dim);
|
|
|
|
h_pos += bmp_dim + ctrl->m_h_gap;
|
|
#endif
|
|
dc.DrawBitmap(og_line.undo_to_sys ? bmp_undo_to_sys : bmp_undo, h_pos, v_pos);
|
|
|
|
int bmp_dim2 = get_bitmap_size(bmp_undo).GetWidth();
|
|
(og_line.undo_to_sys ? rects_undo_to_sys_icon[rect_id] : rects_undo_icon[rect_id]) = wxRect(h_pos, v_pos, bmp_dim2, bmp_dim2);
|
|
|
|
h_pos += bmp_dim2 + ctrl->m_h_gap;
|
|
|
|
return wxPoint(h_pos, v_pos);
|
|
}
|
|
|
|
wxCoord OG_CustomCtrl::CtrlLine::draw_edit_bmp(wxDC &dc, wxPoint pos, const wxBitmap& bmp_edit)
|
|
{
|
|
const wxCoord h_pos = pos.x + ctrl->m_h_gap;
|
|
const wxCoord v_pos = pos.y;
|
|
const int bmp_w = bmp_edit.GetWidth();
|
|
rects_edit_icon.emplace_back(wxRect(h_pos, v_pos, bmp_w, bmp_w));
|
|
|
|
dc.DrawBitmap(bmp_edit, h_pos, v_pos);
|
|
|
|
return h_pos + bmp_w + ctrl->m_h_gap;
|
|
}
|
|
|
|
bool OG_CustomCtrl::CtrlLine::launch_browser() const
|
|
{
|
|
if (!is_focused || og_line.label_path.empty())
|
|
return false;
|
|
|
|
return OptionsGroup::launch_browser(og_line.label_path);
|
|
}
|
|
|
|
} // GUI
|
|
} // Slic3r
|