gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Which fixtures a layout holds: the picker beside the patch editor, with
//! the rig to click and band and the two lists to move rows between.

use crate::fixture_editor::FixtureWindow;
use crate::fixture_editor::member_preview::member_preview;
use crate::fixture_editor::state::PatchEdit;
use crate::ui::window_common::{
    dialog_viewport_builder, embedded_close_clicked, gled_window_frame,
};
use egui::emath::Align;
use egui::epaint::{Margin, StrokeKind};
use egui::{Color32, Context, Frame, Id, Layout, ScrollArea, Sense, Ui, UiBuilder, Vec2};
use gled_model::collection::Collections;
use gled_model::patching::Patch;
use gled_model::patching::fixture::FixtureId;

/// One fixture as the membership lists show it.
struct FixtureRow {
    id: FixtureId,
    name: String,
    definition: String,
    address: String,
}

/// One side of the membership lists. Clicking a row moves it to `member`.
fn fixture_list(
    ui: &mut Ui,
    title: &str,
    rows: &[FixtureRow],
    member: bool,
    drag: &mut Option<ListDrag>,
) -> Vec<(FixtureId, bool)> {
    let mut moved = vec![];
    ui.label(egui::RichText::new(title).strong());
    Frame::new()
        .fill(Color32::from_gray(30))
        .inner_margin(4.0)
        .show(ui, |ui| {
            ui.set_height(180.0);
            ui.take_available_width();
            ScrollArea::vertical().id_salt(title).show(ui, |ui| {
                ui.spacing_mut().item_spacing.y = 0.0;
                for (index, row) in rows.iter().enumerate() {
                    let marked = drag
                        .as_ref()
                        .is_some_and(|drag| drag.from_member == member && drag.covers(index));
                    let response = fixture_row(ui, row, index, marked);

                    if response.drag_started_by(egui::PointerButton::Primary) {
                        *drag = Some(ListDrag {
                            from_member: member,
                            from: index,
                            to: index,
                        });
                    }
                    if let Some(in_flight) = drag.as_mut()
                        && in_flight.from_member == member
                        && response.contains_pointer()
                    {
                        in_flight.to = index;
                    }
                    // A drag ends on the row it started from, wherever the
                    // pointer happens to be by then.
                    if response.drag_stopped()
                        && let Some(in_flight) = drag.take()
                        && in_flight.from_member == member
                    {
                        for index in in_flight.range() {
                            if let Some(row) = rows.get(index) {
                                moved.push((row.id, !member));
                            }
                        }
                    }
                    if response.clicked() {
                        moved.push((row.id, !member));
                    }
                }
            });
        });
    moved
}

/// One row: the whole row reacts, not just an arrow, so a list reads and
/// behaves like a list.
fn fixture_row(ui: &mut Ui, row: &FixtureRow, index: usize, marked: bool) -> egui::Response {
    // The row is the fixture, not the list it currently sits in: keeping the id
    // across the move means the rect it leaves behind is taken over by a widget
    // whose own id is still around, which is what egui asks for.
    let response = ui
        .scope_builder(
            UiBuilder::new()
                .id(Id::new(("membership row", row.id)))
                .sense(Sense::click_and_drag()),
            |ui| {
                let fill = if marked {
                    Color32::from_rgb(60, 90, 130)
                } else if index.is_multiple_of(2) {
                    Color32::from_gray(34)
                } else {
                    Color32::from_gray(28)
                };
                Frame::new()
                    .fill(fill)
                    .inner_margin(Margin::symmetric(4, 2))
                    .show(ui, |ui| {
                        ui.take_available_width();
                        ui.horizontal(|ui| {
                            ui.set_width(ui.available_width());
                            let plain =
                                |text: egui::RichText| egui::Label::new(text).selectable(false);
                            ui.add(plain(row.name.clone().into()));
                            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
                                ui.add(plain(
                                    egui::RichText::new(&row.address)
                                        .color(Color32::from_gray(160)),
                                ));
                                ui.add(plain(
                                    egui::RichText::new(&row.definition)
                                        .color(Color32::from_gray(160)),
                                ));
                            });
                        });
                    });
            },
        )
        .response;

    if response.hovered() && !marked {
        ui.painter().rect_stroke(
            response.rect,
            0.0,
            ui.ctx().global_style().visuals.widgets.hovered.fg_stroke,
            StrokeKind::Inside,
        );
    }
    response
}

/// A run of rows being dragged from one list to the other. Nothing moves until
/// the button comes up, so the rows stay put under the pointer.
pub(crate) struct ListDrag {
    from_member: bool,
    from: usize,
    to: usize,
}

impl ListDrag {
    fn range(&self) -> std::ops::RangeInclusive<usize> {
        self.from.min(self.to)..=self.from.max(self.to)
    }

    fn covers(&self, index: usize) -> bool {
        self.range().contains(&index)
    }
}

impl FixtureWindow {
    /// Which fixtures the layout contains. Picking is spatial — two fixtures of
    /// the same definition are indistinguishable by name — but the two lists
    /// below the rig cover the case where name or address is what the user is
    /// going by.
    pub(super) fn member_picker(
        &mut self,
        ctx: &Context,
        edit: &mut PatchEdit<'_>,
        collections: &Collections,
    ) {
        if !self.state.picking_members {
            return;
        }
        let mut close = false;
        let mut moves: Vec<(FixtureId, bool)> = vec![];
        ctx.show_viewport_immediate(
            Self::picker_viewport(),
            dialog_viewport_builder(Self::PICKER_TITLE)
                .with_inner_size(Vec2::new(900.0, 760.0))
                .with_min_inner_size(Vec2::new(640.0, 520.0))
                .with_resizable(true),
            |ctx, _viewport_class| {
                if ctx.input(|input| input.viewport().close_requested())
                    || embedded_close_clicked(Self::PICKER_TITLE)
                {
                    close = true;
                }
                gled_window_frame(ctx, Self::PICKER_TITLE, |ui| {
                    ui.label(
                        "Click a fixture to include or exclude it. Drag a box to include \
                         several, alt-drag to exclude.",
                    );
                    egui::Panel::bottom("member picker actions").show(ui, |ui| {
                        ui.add_space(4.0);
                        if ui.button("Done").clicked() {
                            close = true;
                        }
                    });
                    egui::Panel::bottom("member picker lists")
                        .exact_size(220.0)
                        .show(ui, |ui| {
                            moves.extend(self.membership_lists(ui, edit.value, collections));
                        });
                    egui::CentralPanel::default().show(ui, |ui| {
                        // square, so the frame around it is the edge of the
                        // scene and the fixtures inside are as large as the
                        // window allows
                        let side = ui.available_size().min_elem() - 8.0;
                        ui.vertical_centered(|ui| {
                            Frame::canvas(ui.style()).show(ui, |ui| {
                                ui.set_width(side);
                                ui.set_height(side);
                                moves.extend(member_preview(
                                    ui,
                                    edit.value,
                                    self.state.selection,
                                    collections,
                                    &mut self.state.caches,
                                    &mut self.state.member_band,
                                ));
                            });
                        });
                    });
                });
            },
        );

        // a band or a run of rows moves several fixtures as one change
        let layout = self.state.selection;
        if !moves.is_empty() {
            edit.apply(|patch| {
                for (id, member) in moves {
                    patch.set_member(layout, id, member);
                }
            });
        }
        if close {
            self.state.pick_members(false);
        }
    }

    /// The included and the available fixtures, side by side. Returns the
    /// fixtures the user moved and where to.
    fn membership_lists(
        &mut self,
        ui: &mut Ui,
        patch: &Patch,
        collections: &Collections,
    ) -> Vec<(FixtureId, bool)> {
        let addresses = patch.addresses(collections);
        let layout = patch.layout(self.state.selection);

        let (mut included, mut available) = (vec![], vec![]);
        for (id, fixture) in &patch.topology.fixtures {
            let row = FixtureRow {
                id: *id,
                name: fixture.name.clone(),
                definition: patch
                    .definition(*id, collections)
                    .map(|asset| asset.name().to_string())
                    .unwrap_or_else(|| "unpatched".to_string()),
                address: addresses
                    .get(id)
                    .map(|address| address.to_string())
                    .unwrap_or_else(|| "-".to_string()),
            };
            if layout.is_some_and(|layout| layout.contains(*id)) {
                included.push(row);
            } else {
                available.push(row);
            }
        }

        // What is not in the layout on the left, what is on the right, so a
        // fixture travels rightwards as it joins.
        let mut moves = vec![];
        let drag = &mut self.list_drag;
        ui.columns(2, |columns| {
            moves.extend(fixture_list(
                &mut columns[0],
                "Available",
                &available,
                false,
                drag,
            ));
            moves.extend(fixture_list(
                &mut columns[1],
                "In layout",
                &included,
                true,
                drag,
            ));
        });
        moves
    }
}