Skip to main content

cqlib_core/visualization/circuit/
text.rs

1// This code is part of Cqlib.
2//
3// (C) Copyright China Telecom Quantum Group 2026
4//
5// This code is licensed under the Apache License, Version 2.0. You may
6// obtain a copy of this license in the LICENSE.txt file in the root directory
7// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
8//
9// Any modifications or derivative works of this code must retain this
10// copyright notice, and modified files need to carry a notice indicating
11// that they have been altered from the originals.
12
13//! Unicode text visualization backend.
14//!
15//! This module renders circuits as UTF-8 text diagrams using box-drawing characters.
16//! It supports:
17//! - layered operation layout,
18//! - multi-qubit connectors and span boxes,
19//! - control-flow markers (`If/Else/While/End`),
20//! - optional line wrapping with continuation arrows.
21//!
22//! # Examples
23//!
24//! ```rust
25//! use cqlib_core::circuit::{Circuit, Qubit};
26//! use cqlib_core::visualization::{TextDrawerOptions, circuit_to_text};
27//!
28//! let mut circuit = Circuit::new(2);
29//! circuit.h(Qubit::new(0)).unwrap();
30//! circuit.cx(Qubit::new(0), Qubit::new(1)).unwrap();
31//!
32//! let text = circuit_to_text(&circuit, &TextDrawerOptions::default()).unwrap();
33//! assert!(text.contains("H"));
34//! assert!(text.contains("■"));
35//! ```
36
37use crate::circuit::Circuit;
38use crate::visualization::VisualizationError;
39use crate::visualization::circuit::ir::{
40    VisualCircuit, VisualControlFlowKind, VisualOpStyle, VisualOperation,
41    flatten_control_flow_visual, reverse_visual_lanes,
42};
43use crate::visualization::circuit::layout::{VisualBuildOptions, build_visual_circuit};
44
45const MIN_LINE_WIDTH: isize = 10;
46const DEFAULT_LINE_WIDTH: isize = 80;
47
48/// Options for Unicode text drawing.
49///
50/// Text rendering uses dynamic width by content; [`TextDrawerOptions::line_width`] controls
51/// optional wrapping rather than fixed cell sizing.
52#[derive(Debug, Clone, Copy)]
53pub struct TextDrawerOptions {
54    /// Kept for API compatibility. Rendering width is dynamic by content.
55    pub cell_width: usize,
56    /// Whether to append parameter text to gate labels.
57    pub show_params: bool,
58    /// Whether to decompose circuit-gates before drawing.
59    pub decompose_circuit_gates: bool,
60    /// Max width per wrapped segment. Set `-1` to disable wrapping.
61    pub line_width: isize,
62    /// Whether to show `|0>` at the beginning of each qubit wire.
63    pub initial_state: bool,
64    /// Whether to reverse the displayed qubit order.
65    pub reverse_bits: bool,
66}
67
68impl TextDrawerOptions {
69    fn effective_line_width(&self) -> usize {
70        let width = self.line_width;
71        if width > MIN_LINE_WIDTH {
72            return width as usize;
73        }
74        if width < 0 {
75            return usize::MAX;
76        }
77        DEFAULT_LINE_WIDTH as usize
78    }
79}
80
81impl Default for TextDrawerOptions {
82    fn default() -> Self {
83        Self {
84            cell_width: 9,
85            show_params: true,
86            decompose_circuit_gates: false,
87            line_width: DEFAULT_LINE_WIDTH,
88            initial_state: false,
89            reverse_bits: false,
90        }
91    }
92}
93
94struct BoxChar;
95
96#[allow(dead_code)]
97impl BoxChar {
98    const TOP: &'static str = "╵";
99    const BOTTOM: &'static str = "╷";
100    const LEFT: &'static str = "╴";
101    const RIGHT: &'static str = "╶";
102    const TOP_BOTTOM: &'static str = "│";
103    const LEFT_RIGHT: &'static str = "─";
104
105    const TOP_LEFT: &'static str = "┘";
106    const TOP_RIGHT: &'static str = "└";
107    const BOTTOM_LEFT: &'static str = "┐";
108    const BOTTOM_RIGHT: &'static str = "┌";
109
110    const TOP_BOTTOM_LEFT: &'static str = "┤";
111    const TOP_BOTTOM_RIGHT: &'static str = "├";
112    const TOP_LEFT_RIGHT: &'static str = "┴";
113    const BOTTOM_LEFT_RIGHT: &'static str = "┬";
114    const TOP_BOTTOM_LEFT_RIGHT: &'static str = "┼";
115
116    const DOT: &'static str = "■";
117    const CONNECT: &'static str = "X";
118    const LEFT_ARROW: &'static str = "«";
119    const RIGHT_ARROW: &'static str = "»";
120}
121
122/// Draw a circuit as Unicode text.
123///
124/// # Arguments
125///
126/// * `circuit` - Input circuit to render.
127/// * `options` - Text rendering options such as line width and qubit order.
128///
129/// # Returns
130///
131/// A UTF-8 circuit diagram using box-drawing characters.
132///
133/// # Errors
134///
135/// Returns [`VisualizationError`] when IR build fails (for example, unknown qubit references).
136///
137/// # Examples
138///
139/// ```rust
140/// use cqlib_core::circuit::{Circuit, Qubit};
141/// use cqlib_core::visualization::{TextDrawerOptions, circuit_to_text};
142///
143/// let mut circuit = Circuit::new(1);
144/// circuit.h(Qubit::new(0)).unwrap();
145///
146/// let text = circuit_to_text(&circuit, &TextDrawerOptions::default()).unwrap();
147/// assert!(text.contains("Q0:"));
148/// ```
149pub fn circuit_to_text(
150    circuit: &Circuit,
151    options: &TextDrawerOptions,
152) -> Result<String, VisualizationError> {
153    let visual_options = VisualBuildOptions {
154        decompose_circuit_gates: options.decompose_circuit_gates,
155        ..VisualBuildOptions::default()
156    };
157    let visual = build_visual_circuit(circuit, &visual_options)?;
158    draw_text_from_visual(&visual, options)
159}
160
161/// Draw from pre-built visualization IR.
162///
163/// Use this API when you already have cached [`VisualCircuit`] IR.
164///
165/// # Arguments
166///
167/// * `visual` - Pre-built visualization IR produced by [`build_visual_circuit`].
168/// * `options` - Text rendering options such as line width and qubit order.
169///
170/// # Returns
171///
172/// A UTF-8 circuit diagram using box-drawing characters.
173///
174/// # Errors
175///
176/// This function currently only returns `Ok`, but keeps a `Result` return type for API
177/// symmetry with [`circuit_to_text`] and future backend validation.
178///
179/// # Examples
180///
181/// ```rust
182/// use cqlib_core::circuit::{Circuit, Qubit};
183/// use cqlib_core::visualization::{
184///     TextDrawerOptions, VisualBuildOptions, build_visual_circuit, draw_text_from_visual,
185/// };
186///
187/// let mut circuit = Circuit::new(1);
188/// circuit.h(Qubit::new(0)).unwrap();
189/// let visual = build_visual_circuit(&circuit, &VisualBuildOptions::default()).unwrap();
190/// let text = draw_text_from_visual(&visual, &TextDrawerOptions::default()).unwrap();
191/// assert!(text.contains("H"));
192/// ```
193pub fn draw_text_from_visual(
194    visual: &VisualCircuit,
195    options: &TextDrawerOptions,
196) -> Result<String, VisualizationError> {
197    if visual.num_qubits() == 0 {
198        return Ok("empty circuit".to_string());
199    }
200
201    let mut flattened = flatten_control_flow_visual(visual);
202    if options.reverse_bits {
203        flattened = reverse_visual_lanes(flattened);
204    }
205    let lines = make_lines(&flattened, options.show_params, options.initial_state);
206    let lines_count = total_rows(flattened.num_qubits());
207    let max_line_width = options.effective_line_width();
208
209    let start_qubits = lines[0].clone();
210    let mut current_data = start_qubits.clone();
211    let mut current_width = 0usize;
212    let mut data: Vec<Vec<Vec<String>>> = Vec::new();
213
214    for moment in lines.iter().skip(1) {
215        let moment_len = str_len(&moment[0].concat());
216        if max_line_width != usize::MAX && moment_len.saturating_add(current_width) > max_line_width
217        {
218            for row in &mut current_data {
219                row.push(BoxChar::RIGHT_ARROW.to_string());
220            }
221            data.push(current_data);
222
223            let mut next_data = Vec::with_capacity(lines_count);
224            for (i, sq) in start_qubits.iter().enumerate().take(lines_count) {
225                let mut row = vec![BoxChar::LEFT_ARROW.to_string()];
226                row.extend(sq.clone());
227                next_data.push(row);
228                let _ = i;
229            }
230            current_data = next_data;
231            current_width = 0;
232        }
233
234        for (i, mrow) in moment.iter().enumerate().take(lines_count) {
235            current_data[i].extend(mrow.clone());
236        }
237        current_width = current_width.saturating_add(moment_len);
238    }
239    data.push(current_data);
240
241    let block_strings: Vec<String> = data
242        .into_iter()
243        .map(|block| {
244            block
245                .into_iter()
246                .map(|row| row.concat())
247                .collect::<Vec<_>>()
248                .join("\n")
249        })
250        .collect();
251    Ok(block_strings.join("\n\n"))
252}
253
254fn y_wire(lane: usize) -> usize {
255    lane * 2 + 1
256}
257
258fn total_rows(num_qubits: usize) -> usize {
259    num_qubits * 2 + 1
260}
261
262fn make_lines(
263    visual: &VisualCircuit,
264    show_params: bool,
265    initial_state: bool,
266) -> Vec<Vec<Vec<String>>> {
267    let mut lines = Vec::new();
268    let lines_count = total_rows(visual.num_qubits());
269    let qubit_len = visual
270        .qubits
271        .iter()
272        .map(|q| q.id().to_string().len())
273        .max()
274        .unwrap_or(1);
275
276    let initial_prefix = if initial_state { "|0>" } else { "" };
277    let empty_line = " ".repeat(qubit_len + if initial_state { 9 } else { 6 });
278    let mut start_lines: Vec<Vec<String>> = Vec::with_capacity(lines_count);
279    for qubit in &visual.qubits {
280        start_lines.push(vec![empty_line.clone()]);
281        let q_label = format!("Q{}", qubit.id());
282        let wire_head = BoxChar::LEFT_RIGHT.repeat(2);
283        start_lines.push(vec![format!(
284            " {:>width$}: {}{}",
285            q_label,
286            initial_prefix,
287            wire_head,
288            width = qubit_len + 1
289        )]);
290    }
291    start_lines.push(vec![empty_line]);
292    lines.push(start_lines);
293
294    for moment in generate_moments(visual) {
295        let mut moment_lines: Vec<Vec<String>> = (0..lines_count)
296            .map(|i| {
297                if i % 2 == 1 {
298                    vec![BoxChar::LEFT_RIGHT.to_string()]
299                } else {
300                    vec![" ".to_string()]
301                }
302            })
303            .collect();
304
305        let columns = moment_to_columns(visual, &moment);
306        for column in columns.clone() {
307            let column_lines = draw_column(visual, &column, show_params);
308            for (line_i, line) in column_lines.into_iter().enumerate() {
309                moment_lines[line_i].extend(line);
310            }
311        }
312
313        for (i, row) in moment_lines.iter_mut().enumerate() {
314            row.push(if i % 2 == 1 {
315                BoxChar::LEFT_RIGHT.to_string()
316            } else {
317                " ".to_string()
318            });
319        }
320
321        let col_len = columns.len();
322        if col_len > 1 {
323            let s =
324                BoxChar::LEFT_RIGHT.repeat(str_len(&moment_lines[0].concat()).saturating_sub(2));
325            moment_lines[0] = vec![
326                BoxChar::BOTTOM_RIGHT.to_string(),
327                s.clone(),
328                BoxChar::BOTTOM_LEFT.to_string(),
329            ];
330            moment_lines[lines_count - 1] = vec![
331                BoxChar::TOP_RIGHT.to_string(),
332                s,
333                BoxChar::TOP_LEFT.to_string(),
334            ];
335        }
336        lines.push(moment_lines);
337    }
338
339    lines
340}
341
342fn generate_moments(visual: &VisualCircuit) -> Vec<Vec<usize>> {
343    let mut layers = vec![Vec::new(); visual.num_columns.max(1)];
344    for (op_idx, op) in visual.operations.iter().enumerate() {
345        if op.column >= layers.len() {
346            layers.resize(op.column + 1, Vec::new());
347        }
348        layers[op.column].push(op_idx);
349    }
350    layers
351}
352
353fn moment_to_columns(visual: &VisualCircuit, moment: &[usize]) -> Vec<Vec<usize>> {
354    let mut ranges: Vec<(usize, usize, usize)> = Vec::new();
355    for op_idx in moment {
356        let op = &visual.operations[*op_idx];
357        if op.lanes.is_empty() {
358            continue;
359        }
360        let mut ys = op.lanes.iter().map(|lane| y_wire(*lane));
361        if let Some(first) = ys.next() {
362            let mut min_y = first;
363            let mut max_y = first;
364            for y in ys {
365                min_y = min_y.min(y);
366                max_y = max_y.max(y);
367            }
368            ranges.push((min_y, max_y, *op_idx));
369        }
370    }
371
372    let mut columns: Vec<Vec<(usize, usize, usize)>> = Vec::new();
373    for range in ranges {
374        let (min_y, max_y, _) = range;
375        let mut placed = false;
376        for column in &mut columns {
377            let overlaps = column
378                .iter()
379                .any(|(cmin, cmax, _)| max_y >= *cmin && min_y <= *cmax);
380            if !overlaps {
381                column.push(range);
382                placed = true;
383                break;
384            }
385        }
386        if !placed {
387            columns.push(vec![range]);
388        }
389    }
390
391    columns
392        .into_iter()
393        .map(|col| col.into_iter().map(|(_, _, op_idx)| op_idx).collect())
394        .collect()
395}
396
397fn draw_column(visual: &VisualCircuit, column: &[usize], show_params: bool) -> Vec<Vec<String>> {
398    let lines_count = total_rows(visual.num_qubits());
399    let mut lines: Vec<Vec<String>> = vec![Vec::new(); lines_count];
400
401    for op_idx in column {
402        let op = &visual.operations[*op_idx];
403        draw_operation(visual, op, &mut lines, show_params);
404    }
405
406    let mut max_width = 1usize;
407    for line in &lines {
408        for s in line {
409            max_width = max_width.max(str_len(s));
410        }
411    }
412
413    let empty_line = " ".repeat(max_width);
414    let wire_line = BoxChar::LEFT_RIGHT.repeat(max_width);
415    for (i, line) in lines.iter_mut().enumerate() {
416        if i % 2 == 0 {
417            if let Some(token) = line.first() {
418                *line = vec![center_fill(token, max_width, ' ')];
419            } else {
420                *line = vec![empty_line.clone()];
421            }
422        } else if let Some(token) = line.first() {
423            *line = vec![center_fill(
424                token,
425                max_width,
426                BoxChar::LEFT_RIGHT.chars().next().unwrap_or('─'),
427            )];
428        } else {
429            *line = vec![wire_line.clone()];
430        }
431    }
432
433    lines
434}
435
436fn draw_operation(
437    visual: &VisualCircuit,
438    op: &VisualOperation,
439    lines: &mut [Vec<String>],
440    show_params: bool,
441) {
442    let label = compose_label(op, show_params);
443    match op.style {
444        VisualOpStyle::Gate => {
445            if op.lanes.len() <= 1 {
446                if let Some(lane) = op.lanes.first().copied() {
447                    let y = y_wire(lane);
448                    lines[y].push(label);
449                }
450                return;
451            }
452
453            if op.label == "FSIM" {
454                draw_fsim(lines, op);
455                return;
456            }
457
458            let mark_targets =
459                op.label == "UNITARY" || matches!(op.label.as_str(), "RXX" | "RYY" | "RZX" | "RZZ");
460            if op.span_box || mark_targets {
461                draw_span_box(lines, &op.lanes, &label, mark_targets);
462                return;
463            }
464
465            for lane in &op.lanes {
466                lines[y_wire(*lane)].push(label.clone());
467            }
468            draw_connect_vertical(lines, &op.covered_lanes);
469        }
470        VisualOpStyle::Controlled { num_controls } => {
471            for lane in op.lanes.iter().take(num_controls) {
472                lines[y_wire(*lane)].push(BoxChar::DOT.to_string());
473            }
474            for lane in op.lanes.iter().skip(num_controls) {
475                lines[y_wire(*lane)].push(label.clone());
476            }
477            draw_connect_vertical(lines, &op.covered_lanes);
478        }
479        VisualOpStyle::Cz => {
480            for lane in &op.lanes {
481                lines[y_wire(*lane)].push(BoxChar::DOT.to_string());
482            }
483            draw_connect_vertical(lines, &op.covered_lanes);
484        }
485        VisualOpStyle::Swap => {
486            for lane in &op.lanes {
487                lines[y_wire(*lane)].push(BoxChar::CONNECT.to_string());
488            }
489            draw_connect_vertical(lines, &op.covered_lanes);
490        }
491        VisualOpStyle::Barrier => {
492            let lanes: Vec<usize> = if op.lanes.is_empty() {
493                (0..visual.num_qubits()).collect()
494            } else {
495                op.lanes.clone()
496            };
497            for lane in lanes {
498                let y = y_wire(lane);
499                if y > 1 && y - 1 < lines.len() {
500                    lines[y - 1].push(BoxChar::TOP_BOTTOM.to_string());
501                }
502                if y < lines.len() {
503                    lines[y].push(BoxChar::TOP_BOTTOM.to_string());
504                }
505            }
506        }
507        VisualOpStyle::Measure | VisualOpStyle::Reset | VisualOpStyle::Delay => {
508            for lane in &op.lanes {
509                lines[y_wire(*lane)].push(label.clone());
510            }
511        }
512        VisualOpStyle::ControlFlow { .. } => {
513            if is_control_flow_marker(op) {
514                let span_lanes: &[usize] = if op.covered_lanes.is_empty() {
515                    &op.lanes
516                } else {
517                    &op.covered_lanes
518                };
519                draw_control_flow_marker(lines, span_lanes, &op.label);
520            }
521        }
522    }
523}
524
525fn draw_control_flow_marker(lines: &mut [Vec<String>], lanes: &[usize], label: &str) {
526    if lanes.is_empty() {
527        return;
528    }
529    let q_indices: Vec<usize> = lanes.iter().map(|lane| y_wire(*lane)).collect();
530    if q_indices.is_empty() {
531        return;
532    }
533    let min_idx = q_indices.iter().copied().min().unwrap_or(0);
534    let max_idx = q_indices.iter().copied().max().unwrap_or(min_idx);
535    let top_line = min_idx.saturating_sub(1);
536    let bottom_line = (max_idx + 1).min(lines.len().saturating_sub(1));
537    if bottom_line <= top_line {
538        lines[min_idx].push(label.to_string());
539        return;
540    }
541
542    let inner_width = (str_len(label) + 2).max(6);
543    lines[top_line].push(format!(
544        "{}{}{}",
545        BoxChar::BOTTOM_RIGHT,
546        BoxChar::LEFT_RIGHT.repeat(inner_width),
547        BoxChar::BOTTOM_LEFT
548    ));
549    lines[bottom_line].push(format!(
550        "{}{}{}",
551        BoxChar::TOP_RIGHT,
552        BoxChar::LEFT_RIGHT.repeat(inner_width),
553        BoxChar::TOP_LEFT
554    ));
555
556    let label_y = select_label_row(top_line, bottom_line);
557
558    #[allow(clippy::needless_range_loop)]
559    for y in (top_line + 1)..bottom_line {
560        let is_wire = y % 2 == 1;
561        let fill = ' ';
562        let mut body = fill.to_string().repeat(inner_width);
563        if y == label_y {
564            body = overlay_text_center(&body, label);
565        }
566        let left_side = if is_wire {
567            BoxChar::TOP_BOTTOM_LEFT
568        } else {
569            BoxChar::TOP_BOTTOM
570        };
571        let right_side = if is_wire {
572            BoxChar::TOP_BOTTOM_RIGHT
573        } else {
574            BoxChar::TOP_BOTTOM
575        };
576        lines[y].push(format!("{left_side}{body}{right_side}"));
577    }
578}
579
580fn is_control_flow_marker(op: &VisualOperation) -> bool {
581    matches!(op.style, VisualOpStyle::ControlFlow { .. })
582        && op.children.is_none()
583        && matches!(
584            op.style,
585            VisualOpStyle::ControlFlow {
586                kind: VisualControlFlowKind::IfStart
587                    | VisualControlFlowKind::ElseStart
588                    | VisualControlFlowKind::WhileStart
589                    | VisualControlFlowKind::ForStart
590                    | VisualControlFlowKind::SwitchStart
591                    | VisualControlFlowKind::CaseStart
592                    | VisualControlFlowKind::DefaultStart
593                    | VisualControlFlowKind::Break
594                    | VisualControlFlowKind::Continue
595                    | VisualControlFlowKind::End
596            }
597        )
598}
599
600fn draw_connect_vertical(lines: &mut [Vec<String>], lanes: &[usize]) {
601    if lanes.is_empty() {
602        return;
603    }
604    let mut ys = lanes.iter().map(|lane| y_wire(*lane));
605    let Some(first) = ys.next() else {
606        return;
607    };
608    let mut min_y = first;
609    let mut max_y = first;
610    for y in ys {
611        min_y = min_y.min(y);
612        max_y = max_y.max(y);
613    }
614
615    #[allow(clippy::needless_range_loop)]
616    for y in (min_y + 1)..max_y {
617        if y % 2 == 1 {
618            lines[y].push(BoxChar::TOP_BOTTOM_LEFT_RIGHT.to_string());
619        } else {
620            lines[y].push(BoxChar::TOP_BOTTOM.to_string());
621        }
622    }
623}
624
625fn draw_fsim(lines: &mut [Vec<String>], op: &VisualOperation) {
626    let span_lanes: &[usize] = if op.covered_lanes.is_empty() {
627        &op.lanes
628    } else {
629        &op.covered_lanes
630    };
631    if span_lanes.is_empty() {
632        return;
633    }
634
635    let mut ys = span_lanes.iter().map(|lane| y_wire(*lane));
636    let Some(first) = ys.next() else {
637        return;
638    };
639    let mut min_y = first;
640    let mut max_y = first;
641    for y in ys {
642        min_y = min_y.min(y);
643        max_y = max_y.max(y);
644    }
645
646    let block_width = 7usize;
647    let mid = block_width / 2;
648
649    // End qubit lines show gate name only.
650    let end_token = center_fill("FSIM", block_width, '─');
651    for lane in &op.lanes {
652        lines[y_wire(*lane)].push(end_token.clone());
653    }
654
655    #[allow(clippy::needless_range_loop)]
656    for y in (min_y + 1)..max_y {
657        let base_char = if y % 2 == 1 {
658            BoxChar::TOP_BOTTOM_LEFT_RIGHT.chars().next().unwrap_or('┼')
659        } else {
660            BoxChar::TOP_BOTTOM.chars().next().unwrap_or('│')
661        };
662
663        let mut token_chars: Vec<char> = if y % 2 == 1 {
664            vec!['─'; block_width]
665        } else {
666            vec![' '; block_width]
667        };
668        token_chars[mid] = base_char;
669        lines[y].push(token_chars.into_iter().collect());
670    }
671}
672
673fn draw_span_box(lines: &mut [Vec<String>], lanes: &[usize], label: &str, mark_targets: bool) {
674    let q_indices: Vec<usize> = lanes.iter().map(|lane| y_wire(*lane)).collect();
675    if q_indices.is_empty() {
676        return;
677    }
678    let min_idx = q_indices.iter().copied().min().unwrap_or(0);
679    let max_idx = q_indices.iter().copied().max().unwrap_or(min_idx);
680    let width = (str_len(label) + 4).max(7);
681
682    let label_bar = center_fill(label, width - 2, ' ');
683    let top_border = format!(
684        "{}{}{}",
685        BoxChar::BOTTOM_RIGHT,
686        BoxChar::LEFT_RIGHT.repeat(width - 2),
687        BoxChar::BOTTOM_LEFT
688    );
689    let bottom_border = format!(
690        "{}{}{}",
691        BoxChar::TOP_RIGHT,
692        BoxChar::LEFT_RIGHT.repeat(width - 2),
693        BoxChar::TOP_LEFT
694    );
695    let mid_blank = format!(
696        "{}{}{}",
697        BoxChar::TOP_BOTTOM,
698        " ".repeat(width - 2),
699        BoxChar::TOP_BOTTOM
700    );
701    let mid_label = format!(
702        "{}{}{}",
703        BoxChar::TOP_BOTTOM,
704        label_bar,
705        BoxChar::TOP_BOTTOM
706    );
707
708    let top_line = min_idx.saturating_sub(1);
709    let bottom_line = (max_idx + 1).min(lines.len().saturating_sub(1));
710    let center_line = (top_line + bottom_line) / 2;
711
712    lines[top_line].push(top_border);
713    lines[bottom_line].push(bottom_border);
714
715    let mut markers: Vec<(usize, String)> = Vec::new();
716    if mark_targets {
717        let mut uniq = Vec::new();
718        for q in q_indices {
719            if !uniq.contains(&q) {
720                uniq.push(q);
721            }
722        }
723        for (idx, qline) in uniq.into_iter().enumerate() {
724            markers.push((qline, idx.to_string()));
725        }
726    }
727
728    for (idx, line) in lines
729        .iter_mut()
730        .enumerate()
731        .take(bottom_line)
732        .skip(top_line + 1)
733    {
734        let mut content = if idx == center_line {
735            mid_label.clone()
736        } else {
737            mid_blank.clone()
738        };
739        if idx != center_line
740            && let Some((_, marker)) = markers.iter().find(|(q, _)| *q == idx)
741        {
742            content = with_marker(&content, marker);
743        }
744        line.push(content);
745    }
746}
747
748fn select_label_row(top_line: usize, bottom_line: usize) -> usize {
749    let mid = (top_line + bottom_line) / 2;
750    if mid > top_line && mid < bottom_line && mid & 1 == 0 {
751        return mid;
752    }
753    if mid > top_line + 1 {
754        let upper = mid - 1;
755        if upper & 1 == 0 {
756            return upper;
757        }
758    }
759    if mid + 1 < bottom_line {
760        let lower = mid + 1;
761        if lower & 1 == 0 {
762            return lower;
763        }
764    }
765    if mid > top_line && mid < bottom_line {
766        return mid;
767    }
768    top_line.saturating_add(1).min(bottom_line)
769}
770
771fn overlay_text_center(base: &str, text: &str) -> String {
772    let mut chars: Vec<char> = base.chars().collect();
773    if chars.is_empty() {
774        return text.to_string();
775    }
776    let text_chars: Vec<char> = text.chars().collect();
777    if text_chars.len() > chars.len() {
778        return text.to_string();
779    }
780    let text_len = text_chars.len();
781    let start = (chars.len() - text_len) / 2;
782    for (i, ch) in text_chars.into_iter().enumerate() {
783        chars[start + i] = ch;
784    }
785    chars.into_iter().collect()
786}
787
788fn with_marker(base: &str, marker: &str) -> String {
789    let chars: Vec<char> = base.chars().collect();
790    if chars.len() < 3 {
791        return base.to_string();
792    }
793    let marker_chars: Vec<char> = marker.chars().collect();
794    let mut out = Vec::with_capacity(chars.len());
795    out.push(chars[0]);
796    for ch in marker_chars {
797        out.push(ch);
798    }
799    let start = marker.len() + 1;
800    if start < chars.len() {
801        out.extend_from_slice(&chars[start..]);
802    }
803    out.into_iter().collect()
804}
805
806fn compose_label(op: &VisualOperation, show_params: bool) -> String {
807    if matches!(op.style, VisualOpStyle::Reset) {
808        return "|0>".to_string();
809    }
810    if show_params && !op.params.is_empty() {
811        format!("{}({})", op.label, op.params.join(","))
812    } else {
813        op.label.clone()
814    }
815}
816
817fn str_len(s: &str) -> usize {
818    s.chars().count()
819}
820
821fn center_fill(text: &str, width: usize, fill: char) -> String {
822    let len = str_len(text);
823    if len >= width {
824        return text.to_string();
825    }
826    let pad = width - len;
827    let left = pad / 2;
828    let right = pad - left;
829    format!(
830        "{}{}{}",
831        fill.to_string().repeat(left),
832        text,
833        fill.to_string().repeat(right)
834    )
835}