mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -04:00
Per-widget style (#7667)
* Closes <https://github.com/emilk/egui/issues/7586> Implementation of the per-widget style of the action plan to add better styling option to egui --------- Co-authored-by: adrien <221212@umons.ac.be> Co-authored-by: Adrien Zianne <adrien@iq002.ipa.iqrypto.com> Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
This commit is contained in:
@@ -424,6 +424,7 @@ mod ui_stack;
|
||||
pub mod util;
|
||||
pub mod viewport;
|
||||
mod widget_rect;
|
||||
pub mod widget_style;
|
||||
pub mod widget_text;
|
||||
pub mod widgets;
|
||||
|
||||
|
||||
203
crates/egui/src/widget_style.rs
Normal file
203
crates/egui/src/widget_style.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use emath::Vec2;
|
||||
use epaint::{Color32, FontId, Shadow, Stroke, text::TextWrapMode};
|
||||
|
||||
use crate::{
|
||||
Frame, Response, Style, TextStyle,
|
||||
style::{WidgetVisuals, Widgets},
|
||||
};
|
||||
|
||||
/// General text style
|
||||
pub struct TextVisuals {
|
||||
/// Font used
|
||||
pub font_id: FontId,
|
||||
|
||||
/// Font color
|
||||
pub color: Color32,
|
||||
|
||||
/// Text decoration
|
||||
pub underline: Stroke,
|
||||
pub strikethrough: Stroke,
|
||||
}
|
||||
|
||||
/// General widget style
|
||||
pub struct WidgetStyle {
|
||||
pub frame: Frame,
|
||||
|
||||
pub text: TextVisuals,
|
||||
|
||||
pub stroke: Stroke,
|
||||
}
|
||||
|
||||
pub struct ButtonStyle {
|
||||
pub frame: Frame,
|
||||
pub text_style: TextVisuals,
|
||||
}
|
||||
|
||||
pub struct CheckboxStyle {
|
||||
/// Frame around
|
||||
pub frame: Frame,
|
||||
|
||||
/// Text next to it
|
||||
pub text_style: TextVisuals,
|
||||
|
||||
/// Checkbox size
|
||||
pub checkbox_size: f32,
|
||||
|
||||
/// Checkmark size
|
||||
pub check_size: f32,
|
||||
|
||||
/// Frame of the checkbox itself
|
||||
pub checkbox_frame: Frame,
|
||||
|
||||
/// Checkmark stroke
|
||||
pub check_stroke: Stroke,
|
||||
}
|
||||
|
||||
pub struct LabelStyle {
|
||||
/// Frame around
|
||||
pub frame: Frame,
|
||||
|
||||
/// Text style
|
||||
pub text: TextVisuals,
|
||||
|
||||
/// Wrap mode used
|
||||
pub wrap_mode: TextWrapMode,
|
||||
}
|
||||
|
||||
pub struct SeparatorStyle {
|
||||
/// How much space is allocated in the layout direction
|
||||
pub spacing: f32,
|
||||
|
||||
/// How to paint it
|
||||
pub stroke: Stroke,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WidgetState {
|
||||
Noninteractive,
|
||||
#[default]
|
||||
Inactive,
|
||||
Hovered,
|
||||
Active,
|
||||
}
|
||||
|
||||
impl Widgets {
|
||||
pub fn state(&self, state: WidgetState) -> &WidgetVisuals {
|
||||
match state {
|
||||
WidgetState::Noninteractive => &self.noninteractive,
|
||||
WidgetState::Inactive => &self.inactive,
|
||||
WidgetState::Hovered => &self.hovered,
|
||||
WidgetState::Active => &self.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn widget_state(&self) -> WidgetState {
|
||||
if !self.sense.interactive() {
|
||||
WidgetState::Noninteractive
|
||||
} else if self.is_pointer_button_down_on() || self.has_focus() || self.clicked() {
|
||||
WidgetState::Active
|
||||
} else if self.hovered() || self.highlighted() {
|
||||
WidgetState::Hovered
|
||||
} else {
|
||||
WidgetState::Inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
pub fn widget_style(&self, state: WidgetState) -> WidgetStyle {
|
||||
let visuals = self.visuals.widgets.state(state);
|
||||
let font_id = self.override_font_id.clone();
|
||||
WidgetStyle {
|
||||
frame: Frame {
|
||||
fill: visuals.bg_fill,
|
||||
stroke: visuals.bg_stroke,
|
||||
corner_radius: visuals.corner_radius,
|
||||
inner_margin: self.spacing.button_padding.into(),
|
||||
..Default::default()
|
||||
},
|
||||
stroke: visuals.fg_stroke,
|
||||
text: TextVisuals {
|
||||
color: self
|
||||
.visuals
|
||||
.override_text_color
|
||||
.unwrap_or_else(|| visuals.text_color()),
|
||||
font_id: font_id.unwrap_or_else(|| TextStyle::Body.resolve(self)),
|
||||
strikethrough: Stroke::NONE,
|
||||
underline: Stroke::NONE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn button_style(&self, state: WidgetState, selected: bool) -> ButtonStyle {
|
||||
let mut visuals = *self.visuals.widgets.state(state);
|
||||
let mut ws = self.widget_style(state);
|
||||
|
||||
if selected {
|
||||
visuals.weak_bg_fill = self.visuals.selection.bg_fill;
|
||||
visuals.bg_fill = self.visuals.selection.bg_fill;
|
||||
visuals.fg_stroke = self.visuals.selection.stroke;
|
||||
ws.text.color = self.visuals.selection.stroke.color;
|
||||
}
|
||||
|
||||
ButtonStyle {
|
||||
frame: Frame {
|
||||
fill: visuals.weak_bg_fill,
|
||||
stroke: visuals.bg_stroke,
|
||||
corner_radius: visuals.corner_radius,
|
||||
outer_margin: (-Vec2::splat(visuals.expansion)).into(),
|
||||
inner_margin: (self.spacing.button_padding + Vec2::splat(visuals.expansion)
|
||||
- Vec2::splat(visuals.bg_stroke.width))
|
||||
.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_style: ws.text,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkbox_style(&self, state: WidgetState) -> CheckboxStyle {
|
||||
let visuals = self.visuals.widgets.state(state);
|
||||
let ws = self.widget_style(state);
|
||||
CheckboxStyle {
|
||||
frame: Frame::new(),
|
||||
checkbox_size: self.spacing.icon_width,
|
||||
check_size: self.spacing.icon_width_inner,
|
||||
checkbox_frame: Frame {
|
||||
fill: visuals.bg_fill,
|
||||
corner_radius: visuals.corner_radius,
|
||||
stroke: visuals.bg_stroke,
|
||||
// Use the inner_margin for the expansion
|
||||
inner_margin: visuals.expansion.into(),
|
||||
..Default::default()
|
||||
},
|
||||
text_style: ws.text,
|
||||
check_stroke: ws.stroke,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label_style(&self, state: WidgetState) -> LabelStyle {
|
||||
let ws = self.widget_style(state);
|
||||
LabelStyle {
|
||||
frame: Frame {
|
||||
fill: ws.frame.fill,
|
||||
inner_margin: 0.0.into(),
|
||||
outer_margin: 0.0.into(),
|
||||
stroke: Stroke::NONE,
|
||||
shadow: Shadow::NONE,
|
||||
corner_radius: 0.into(),
|
||||
},
|
||||
text: ws.text,
|
||||
wrap_mode: TextWrapMode::Wrap,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn separator_style(&self, _state: WidgetState) -> SeparatorStyle {
|
||||
let visuals = self.visuals.noninteractive();
|
||||
SeparatorStyle {
|
||||
spacing: 6.0,
|
||||
stroke: visuals.bg_stroke,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
use epaint::Margin;
|
||||
|
||||
use crate::{
|
||||
Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, Color32, CornerRadius, Frame,
|
||||
Image, IntoAtoms, NumExt as _, Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, Vec2,
|
||||
Widget, WidgetInfo, WidgetText, WidgetType,
|
||||
widget_style::{ButtonStyle, WidgetState},
|
||||
};
|
||||
|
||||
/// Clickable button with text.
|
||||
@@ -272,6 +275,7 @@ impl<'a> Button<'a> {
|
||||
limit_image_size,
|
||||
} = self;
|
||||
|
||||
// Min size height always equal or greater than interact size if not small
|
||||
if !small {
|
||||
min_size.y = min_size.y.at_least(ui.spacing().interact_size.y);
|
||||
}
|
||||
@@ -290,51 +294,58 @@ impl<'a> Button<'a> {
|
||||
|
||||
let has_frame_margin = frame.unwrap_or_else(|| ui.visuals().button_frame);
|
||||
|
||||
let id = ui.next_auto_id();
|
||||
let response: Option<Response> = ui.ctx().read_response(id);
|
||||
let state = response.map(|r| r.widget_state()).unwrap_or_default();
|
||||
|
||||
let ButtonStyle { frame, text_style } = ui.style().button_style(state, selected);
|
||||
|
||||
let mut button_padding = if has_frame_margin {
|
||||
ui.spacing().button_padding
|
||||
frame.inner_margin
|
||||
} else {
|
||||
Vec2::ZERO
|
||||
Margin::ZERO
|
||||
};
|
||||
|
||||
if small {
|
||||
button_padding.y = 0.0;
|
||||
button_padding.bottom = 0;
|
||||
button_padding.top = 0;
|
||||
}
|
||||
|
||||
let mut prepared = layout
|
||||
.frame(Frame::new().inner_margin(button_padding))
|
||||
.min_size(min_size)
|
||||
.allocate(ui);
|
||||
// Override global style by local style
|
||||
let mut frame = frame;
|
||||
if let Some(fill) = fill {
|
||||
frame = frame.fill(fill);
|
||||
}
|
||||
if let Some(corner_radius) = corner_radius {
|
||||
frame = frame.corner_radius(corner_radius);
|
||||
}
|
||||
if let Some(stroke) = stroke {
|
||||
frame = frame.stroke(stroke);
|
||||
}
|
||||
|
||||
let response = if ui.is_rect_visible(prepared.response.rect) {
|
||||
let visuals = ui.style().interact_selectable(&prepared.response, selected);
|
||||
frame = frame.inner_margin(button_padding);
|
||||
|
||||
let visible_frame = if frame_when_inactive {
|
||||
has_frame_margin
|
||||
// Apply the style font and color as fallback
|
||||
layout = layout
|
||||
.fallback_font(text_style.font_id.clone())
|
||||
.fallback_text_color(text_style.color);
|
||||
|
||||
// Retrocompatibility with button settings
|
||||
layout = if has_frame_margin && (state != WidgetState::Inactive || frame_when_inactive) {
|
||||
layout.frame(frame)
|
||||
} else {
|
||||
has_frame_margin
|
||||
&& (prepared.response.hovered()
|
||||
|| prepared.response.is_pointer_button_down_on()
|
||||
|| prepared.response.has_focus())
|
||||
layout.frame(Frame::new().inner_margin(frame.inner_margin))
|
||||
};
|
||||
|
||||
let mut prepared = layout.min_size(min_size).allocate(ui);
|
||||
|
||||
// Get AtomLayoutResponse, empty if not visible
|
||||
let response = if ui.is_rect_visible(prepared.response.rect) {
|
||||
if image_tint_follows_text_color {
|
||||
prepared.map_images(|image| image.tint(visuals.text_color()));
|
||||
prepared.map_images(|image| image.tint(text_style.color));
|
||||
}
|
||||
|
||||
prepared.fallback_text_color = visuals.text_color();
|
||||
|
||||
if visible_frame {
|
||||
let stroke = stroke.unwrap_or(visuals.bg_stroke);
|
||||
let fill = fill.unwrap_or(visuals.weak_bg_fill);
|
||||
prepared.frame = prepared
|
||||
.frame
|
||||
.inner_margin(
|
||||
button_padding + Vec2::splat(visuals.expansion) - Vec2::splat(stroke.width),
|
||||
)
|
||||
.outer_margin(-Vec2::splat(visuals.expansion))
|
||||
.fill(fill)
|
||||
.stroke(stroke)
|
||||
.corner_radius(corner_radius.unwrap_or(visuals.corner_radius));
|
||||
}
|
||||
prepared.fallback_text_color = text_style.color;
|
||||
|
||||
prepared.paint(ui)
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use emath::Rect;
|
||||
|
||||
use crate::{
|
||||
Atom, AtomLayout, Atoms, Id, IntoAtoms, NumExt as _, Response, Sense, Shape, Ui, Vec2, Widget,
|
||||
WidgetInfo, WidgetType, epaint, pos2,
|
||||
WidgetInfo, WidgetType, epaint, pos2, widget_style::CheckboxStyle,
|
||||
};
|
||||
|
||||
// TODO(emilk): allow checkbox without a text label
|
||||
@@ -55,14 +57,25 @@ impl Widget for Checkbox<'_> {
|
||||
indeterminate,
|
||||
} = self;
|
||||
|
||||
let spacing = &ui.spacing();
|
||||
let icon_width = spacing.icon_width;
|
||||
// Get the widget style by reading the response from the previous pass
|
||||
let id = ui.next_auto_id();
|
||||
let response: Option<Response> = ui.ctx().read_response(id);
|
||||
let state = response.map(|r| r.widget_state()).unwrap_or_default();
|
||||
|
||||
let mut min_size = Vec2::splat(spacing.interact_size.y);
|
||||
min_size.y = min_size.y.at_least(icon_width);
|
||||
let CheckboxStyle {
|
||||
check_size,
|
||||
checkbox_frame,
|
||||
checkbox_size,
|
||||
frame,
|
||||
check_stroke,
|
||||
text_style,
|
||||
} = ui.style().checkbox_style(state);
|
||||
|
||||
let mut min_size = Vec2::splat(ui.spacing().interact_size.y);
|
||||
min_size.y = min_size.y.at_least(checkbox_size);
|
||||
|
||||
// In order to center the checkbox based on min_size we set the icon height to at least min_size.y
|
||||
let mut icon_size = Vec2::splat(icon_width);
|
||||
let mut icon_size = Vec2::splat(checkbox_size);
|
||||
icon_size.y = icon_size.y.at_least(min_size.y);
|
||||
let rect_id = Id::new("egui::checkbox");
|
||||
atoms.push_left(Atom::custom(rect_id, icon_size));
|
||||
@@ -72,6 +85,7 @@ impl Widget for Checkbox<'_> {
|
||||
let mut prepared = AtomLayout::new(atoms)
|
||||
.sense(Sense::click())
|
||||
.min_size(min_size)
|
||||
.frame(frame)
|
||||
.allocate(ui);
|
||||
|
||||
if prepared.response.clicked() {
|
||||
@@ -96,18 +110,21 @@ impl Widget for Checkbox<'_> {
|
||||
});
|
||||
|
||||
if ui.is_rect_visible(prepared.response.rect) {
|
||||
// let visuals = ui.style().interact_selectable(&response, *checked); // too colorful
|
||||
let visuals = *ui.style().interact(&prepared.response);
|
||||
prepared.fallback_text_color = visuals.text_color();
|
||||
prepared.fallback_text_color = text_style.color;
|
||||
let response = prepared.paint(ui);
|
||||
|
||||
if let Some(rect) = response.rect(rect_id) {
|
||||
let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect);
|
||||
let big_icon_rect = Rect::from_center_size(
|
||||
pos2(rect.left() + checkbox_size / 2.0, rect.center().y),
|
||||
Vec2::splat(checkbox_size),
|
||||
);
|
||||
let small_icon_rect =
|
||||
Rect::from_center_size(big_icon_rect.center(), Vec2::splat(check_size));
|
||||
ui.painter().add(epaint::RectShape::new(
|
||||
big_icon_rect.expand(visuals.expansion),
|
||||
visuals.corner_radius,
|
||||
visuals.bg_fill,
|
||||
visuals.bg_stroke,
|
||||
big_icon_rect.expand(checkbox_frame.inner_margin.left.into()),
|
||||
checkbox_frame.corner_radius,
|
||||
checkbox_frame.fill,
|
||||
checkbox_frame.stroke,
|
||||
epaint::StrokeKind::Inside,
|
||||
));
|
||||
|
||||
@@ -116,7 +133,7 @@ impl Widget for Checkbox<'_> {
|
||||
ui.painter().add(Shape::hline(
|
||||
small_icon_rect.x_range(),
|
||||
small_icon_rect.center().y,
|
||||
visuals.fg_stroke,
|
||||
check_stroke,
|
||||
));
|
||||
} else if *checked {
|
||||
// Check mark:
|
||||
@@ -126,7 +143,7 @@ impl Widget for Checkbox<'_> {
|
||||
pos2(small_icon_rect.center().x, small_icon_rect.bottom()),
|
||||
pos2(small_icon_rect.right(), small_icon_rect.top()),
|
||||
],
|
||||
visuals.fg_stroke,
|
||||
check_stroke,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{Response, Sense, Ui, Vec2, Widget, vec2};
|
||||
use crate::{Response, Sense, Ui, Vec2, Widget, vec2, widget_style::SeparatorStyle};
|
||||
|
||||
/// A visual separator. A horizontal or vertical line (depending on [`crate::Layout`]).
|
||||
///
|
||||
@@ -13,7 +13,7 @@ use crate::{Response, Sense, Ui, Vec2, Widget, vec2};
|
||||
/// ```
|
||||
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
|
||||
pub struct Separator {
|
||||
spacing: f32,
|
||||
spacing: Option<f32>,
|
||||
grow: f32,
|
||||
is_horizontal_line: Option<bool>,
|
||||
}
|
||||
@@ -21,7 +21,7 @@ pub struct Separator {
|
||||
impl Default for Separator {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spacing: 6.0,
|
||||
spacing: None,
|
||||
grow: 0.0,
|
||||
is_horizontal_line: None,
|
||||
}
|
||||
@@ -38,7 +38,7 @@ impl Separator {
|
||||
/// this is the width of the separator widget.
|
||||
#[inline]
|
||||
pub fn spacing(mut self, spacing: f32) -> Self {
|
||||
self.spacing = spacing;
|
||||
self.spacing = Some(spacing);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -93,6 +93,18 @@ impl Widget for Separator {
|
||||
is_horizontal_line,
|
||||
} = self;
|
||||
|
||||
// Get the widget style by reading the response from the previous pass
|
||||
let id = ui.next_auto_id();
|
||||
let response: Option<Response> = ui.ctx().read_response(id);
|
||||
let state = response.map(|r| r.widget_state()).unwrap_or_default();
|
||||
let SeparatorStyle {
|
||||
spacing: spacing_style,
|
||||
stroke,
|
||||
} = ui.style().separator_style(state);
|
||||
|
||||
// override the spacing if not set
|
||||
let spacing = spacing.unwrap_or(spacing_style);
|
||||
|
||||
let is_horizontal_line = is_horizontal_line
|
||||
.unwrap_or_else(|| ui.is_grid() || !ui.layout().main_dir().is_horizontal());
|
||||
|
||||
@@ -111,7 +123,6 @@ impl Widget for Separator {
|
||||
let (rect, response) = ui.allocate_at_least(size, Sense::hover());
|
||||
|
||||
if ui.is_rect_visible(response.rect) {
|
||||
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
|
||||
let painter = ui.painter();
|
||||
if is_horizontal_line {
|
||||
painter.hline(
|
||||
|
||||
@@ -721,12 +721,13 @@ impl TextEdit<'_> {
|
||||
paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None);
|
||||
}
|
||||
|
||||
if !clip_text {
|
||||
// Allocate additional space if edits were made this frame that changed the size. This is important so that,
|
||||
// if there's a ScrollArea, it can properly scroll to the cursor.
|
||||
// Condition `!clip_text` is important to avoid breaking layout for `TextEdit::singleline` (PR #5640)
|
||||
let extra_size = galley.size() - rect.size();
|
||||
if extra_size.x > 0.0 || extra_size.y > 0.0 {
|
||||
if !clip_text
|
||||
&& let extra_size = galley.size() - rect.size()
|
||||
&& (extra_size.x > 0.0 || extra_size.y > 0.0)
|
||||
{
|
||||
match ui.layout().main_dir() {
|
||||
crate::Direction::LeftToRight | crate::Direction::TopDown => {
|
||||
ui.allocate_rect(
|
||||
@@ -753,7 +754,9 @@ impl TextEdit<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Avoid an ID shift during this pass if the textedit grow
|
||||
ui.skip_ahead_auto_ids(1);
|
||||
}
|
||||
|
||||
painter.galley(galley_pos, galley.clone(), text_color);
|
||||
|
||||
@@ -33,21 +33,25 @@ cargo check --quiet -p egui_demo_app --lib --target wasm32-unknown-unknown --al
|
||||
cargo test --quiet --all-targets --all-features
|
||||
cargo test --quiet --doc # slow - checks all doc-tests
|
||||
|
||||
cargo check --quiet -p eframe --no-default-features --features "glow"
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
cargo check --quiet -p eframe --no-default-features --features "glow","x11"
|
||||
cargo check --quiet -p eframe --no-default-features --features "glow","wayland"
|
||||
cargo check --quiet -p eframe --no-default-features --features "wgpu","x11"
|
||||
cargo check --quiet -p eframe --no-default-features --features "wgpu","wayland"
|
||||
else
|
||||
cargo check --quiet -p eframe --no-default-features --features "glow"
|
||||
cargo check --quiet -p eframe --no-default-features --features "wgpu"
|
||||
fi
|
||||
|
||||
cargo check --quiet -p egui --no-default-features --features "serde"
|
||||
cargo check --quiet -p egui_demo_app --no-default-features --features "glow"
|
||||
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
cargo check --quiet -p egui_demo_app --no-default-features --features "glow","x11"
|
||||
cargo check --quiet -p egui_demo_app --no-default-features --features "glow","wayland"
|
||||
cargo check --quiet -p egui_demo_app --no-default-features --features "wgpu","x11"
|
||||
cargo check --quiet -p egui_demo_app --no-default-features --features "wgpu","wayland"
|
||||
else
|
||||
cargo check --quiet -p egui_demo_app --no-default-features --features "glow"
|
||||
cargo check --quiet -p egui_demo_app --no-default-features --features "wgpu"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5ca946ae1875730db15a7e525d2edfab4b55d9a07ad72998c565ce0c7c9bea90
|
||||
size 8400
|
||||
oid sha256:540388365970accbc4c03aa34809b1f07d4e31c8d56bccb8f73da9c2292cb36a
|
||||
size 8426
|
||||
|
||||
@@ -339,7 +339,7 @@ impl<'a> VisualTests<'a> {
|
||||
|
||||
f(&mut harness);
|
||||
|
||||
harness.step();
|
||||
harness.run();
|
||||
|
||||
let image = harness.render().expect("Failed to render harness");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user