mirror of
https://github.com/emilk/egui.git
synced 2026-09-01 14:20:04 -04:00
Add a canvas based font fallback
This commit is contained in:
@@ -1622,6 +1622,8 @@ dependencies = [
|
|||||||
"similar-asserts",
|
"similar-asserts",
|
||||||
"skrifa",
|
"skrifa",
|
||||||
"vello_cpu",
|
"vello_cpu",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -83,6 +83,17 @@ serde = { workspace = true, optional = true, features = ["derive", "rc"] }
|
|||||||
|
|
||||||
epaint_default_fonts = { workspace = true, optional = true }
|
epaint_default_fonts = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
wasm-bindgen.workspace = true
|
||||||
|
web-sys = { workspace = true, features = [
|
||||||
|
"CanvasRenderingContext2d",
|
||||||
|
"HtmlCanvasElement",
|
||||||
|
"ImageData",
|
||||||
|
"TextMetrics",
|
||||||
|
"Document",
|
||||||
|
"Window",
|
||||||
|
] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion.workspace = true
|
criterion.workspace = true
|
||||||
mimalloc.workspace = true
|
mimalloc.workspace = true
|
||||||
|
|||||||
225
crates/epaint/src/text/canvas_renderer.rs
Normal file
225
crates/epaint/src/text/canvas_renderer.rs
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
|
||||||
|
|
||||||
|
use crate::text::font::ScaledMetrics;
|
||||||
|
|
||||||
|
/// Data for a glyph rendered via HTML5 canvas
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CanvasGlyphData {
|
||||||
|
/// RGBA pixel data from canvas ImageData
|
||||||
|
pub image_data: Vec<u8>,
|
||||||
|
/// Width of the glyph in pixels
|
||||||
|
pub width: u32,
|
||||||
|
/// Height of the glyph in pixels
|
||||||
|
pub height: u32,
|
||||||
|
/// Advance width for horizontal text layout
|
||||||
|
pub advance_width: f32,
|
||||||
|
/// Horizontal offset from origin
|
||||||
|
pub offset_x: f32,
|
||||||
|
/// Vertical offset from origin (baseline)
|
||||||
|
pub offset_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders glyphs using HTML5 canvas (WASM only)
|
||||||
|
///
|
||||||
|
/// This renderer uses the browser's native text rendering capabilities
|
||||||
|
/// to rasterize glyphs that are not available in the bundled fonts.
|
||||||
|
pub struct CanvasGlyphRenderer {
|
||||||
|
canvas: HtmlCanvasElement,
|
||||||
|
context: CanvasRenderingContext2d,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanvasGlyphRenderer {
|
||||||
|
/// Create a new canvas glyph renderer
|
||||||
|
///
|
||||||
|
/// Returns an error if canvas creation or context acquisition fails
|
||||||
|
pub fn new() -> Result<Self, JsValue> {
|
||||||
|
let window = web_sys::window().ok_or("No window object")?;
|
||||||
|
let document = window.document().ok_or("No document object")?;
|
||||||
|
|
||||||
|
let canvas = document
|
||||||
|
.create_element("canvas")?
|
||||||
|
.dyn_into::<HtmlCanvasElement>()?;
|
||||||
|
|
||||||
|
// Start with a small canvas, will resize as needed
|
||||||
|
canvas.set_width(128);
|
||||||
|
canvas.set_height(128);
|
||||||
|
|
||||||
|
let context = canvas
|
||||||
|
.get_context("2d")?
|
||||||
|
.ok_or("Failed to get 2d context")?
|
||||||
|
.dyn_into::<CanvasRenderingContext2d>()?;
|
||||||
|
|
||||||
|
Ok(Self { canvas, context })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a glyph using canvas
|
||||||
|
///
|
||||||
|
/// Tries each font family in order until one renders the character successfully.
|
||||||
|
/// Returns None if the character cannot be rendered or has zero width.
|
||||||
|
pub fn render_glyph(
|
||||||
|
&mut self,
|
||||||
|
chr: char,
|
||||||
|
metrics: &ScaledMetrics,
|
||||||
|
font_families: &[String],
|
||||||
|
bin: crate::text::font::SubpixelBin,
|
||||||
|
) -> Option<CanvasGlyphData> {
|
||||||
|
// metrics.scale is the absolute font size in pixels (includes DPI and zoom)
|
||||||
|
let font_size_px = metrics.scale;
|
||||||
|
let subpixel_offset = bin.as_float();
|
||||||
|
|
||||||
|
// Try each font family in the fallback chain
|
||||||
|
for family in font_families {
|
||||||
|
if let Some(data) = self.try_render_with_font(chr, font_size_px, subpixel_offset, family) {
|
||||||
|
return Some(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with generic sans-serif as last resort
|
||||||
|
self.try_render_with_font(chr, font_size_px, subpixel_offset, "sans-serif")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to render a glyph with a specific font family
|
||||||
|
fn try_render_with_font(
|
||||||
|
&mut self,
|
||||||
|
chr: char,
|
||||||
|
font_size_px: f32,
|
||||||
|
subpixel_offset: f32,
|
||||||
|
font_family: &str,
|
||||||
|
) -> Option<CanvasGlyphData> {
|
||||||
|
let font_string = format!("{}px {}", font_size_px, font_family);
|
||||||
|
self.context.set_font(&font_string);
|
||||||
|
|
||||||
|
let text = chr.to_string();
|
||||||
|
|
||||||
|
// Measure the text to get metrics
|
||||||
|
let text_metrics = self.context.measure_text(&text).ok()?;
|
||||||
|
let advance_width = text_metrics.width() as f32;
|
||||||
|
|
||||||
|
// Skip if character is not supported (zero width)
|
||||||
|
if advance_width < 0.1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bounding box metrics
|
||||||
|
let ascent = text_metrics.actual_bounding_box_ascent();
|
||||||
|
let descent = text_metrics.actual_bounding_box_descent();
|
||||||
|
let left = text_metrics.actual_bounding_box_left();
|
||||||
|
let right = text_metrics.actual_bounding_box_right();
|
||||||
|
|
||||||
|
// Canvas measureText returns values at the specified font size
|
||||||
|
// We render at font_size_px which is already in the right scale
|
||||||
|
let width = (right + left).ceil() as u32;
|
||||||
|
let height = (ascent + descent).ceil() as u32;
|
||||||
|
|
||||||
|
// Skip zero-size glyphs
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit maximum glyph size to prevent excessive memory usage
|
||||||
|
const MAX_GLYPH_SIZE: u32 = 256;
|
||||||
|
if width > MAX_GLYPH_SIZE || height > MAX_GLYPH_SIZE {
|
||||||
|
log::warn!(
|
||||||
|
"Glyph '{}' too large ({}x{}), max size is {}x{}",
|
||||||
|
chr,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
MAX_GLYPH_SIZE,
|
||||||
|
MAX_GLYPH_SIZE
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize canvas if needed
|
||||||
|
if width > self.canvas.width() || height > self.canvas.height() {
|
||||||
|
self.canvas.set_width(width.max(128));
|
||||||
|
self.canvas.set_height(height.max(128));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the canvas
|
||||||
|
self.context.clear_rect(
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
self.canvas.width() as f64,
|
||||||
|
self.canvas.height() as f64,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up rendering
|
||||||
|
self.context.set_fill_style_str("white");
|
||||||
|
self.context.set_text_baseline("alphabetic");
|
||||||
|
|
||||||
|
// Render the text at the correct position
|
||||||
|
// The baseline is at y = ascent, and we offset x by left bearing + subpixel offset
|
||||||
|
if let Err(e) = self.context.fill_text(&text, left + subpixel_offset as f64, ascent) {
|
||||||
|
log::debug!("Failed to render '{}': {:?}", chr, e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract image data (now at device pixel resolution)
|
||||||
|
let image_data = self
|
||||||
|
.context
|
||||||
|
.get_image_data(0.0, 0.0, width as f64, height as f64)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let rgba_data = image_data.data().0;
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"Canvas glyph '{}': advance={}, size={}x{}, offset=({}, {}), font_size={}px",
|
||||||
|
chr, advance_width, width, height, -left as f32, -ascent as f32, font_size_px
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(CanvasGlyphData {
|
||||||
|
image_data: rgba_data,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
advance_width,
|
||||||
|
offset_x: -left as f32,
|
||||||
|
offset_y: -ascent as f32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CANVAS_RENDERER: RefCell<Option<CanvasGlyphRenderer>> = RefCell::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the canvas renderer if not already initialized
|
||||||
|
fn ensure_canvas_renderer() -> Result<(), JsValue> {
|
||||||
|
CANVAS_RENDERER.with(|renderer_cell| {
|
||||||
|
if renderer_cell.borrow().is_none() {
|
||||||
|
match CanvasGlyphRenderer::new() {
|
||||||
|
Ok(renderer) => {
|
||||||
|
*renderer_cell.borrow_mut() = Some(renderer);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to create canvas renderer: {:?}", e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a glyph using the thread-local canvas renderer
|
||||||
|
pub fn render_glyph_with_canvas(
|
||||||
|
chr: char,
|
||||||
|
metrics: &ScaledMetrics,
|
||||||
|
font_families: &[String],
|
||||||
|
bin: crate::text::font::SubpixelBin,
|
||||||
|
) -> Option<CanvasGlyphData> {
|
||||||
|
ensure_canvas_renderer().ok()?;
|
||||||
|
|
||||||
|
CANVAS_RENDERER.with(|renderer_cell| {
|
||||||
|
renderer_cell
|
||||||
|
.borrow_mut()
|
||||||
|
.as_mut()?
|
||||||
|
.render_glyph(chr, metrics, font_families, bin)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -204,10 +204,10 @@ impl FontCell {
|
|||||||
) -> Option<GlyphAllocation> {
|
) -> Option<GlyphAllocation> {
|
||||||
let glyph_id = glyph_info.id?;
|
let glyph_id = glyph_info.id?;
|
||||||
|
|
||||||
debug_assert!(
|
// Return None for NOTDEF - this will trigger canvas fallback on WASM
|
||||||
glyph_id != skrifa::GlyphId::NOTDEF,
|
if glyph_id == skrifa::GlyphId::NOTDEF {
|
||||||
"Can't allocate glyph for id 0"
|
return None;
|
||||||
);
|
}
|
||||||
|
|
||||||
let mut path = kurbo::BezPath::new();
|
let mut path = kurbo::BezPath::new();
|
||||||
let mut pen = VelloPen {
|
let mut pen = VelloPen {
|
||||||
@@ -341,6 +341,11 @@ pub struct FontFace {
|
|||||||
location: skrifa::instance::Location,
|
location: skrifa::instance::Location,
|
||||||
glyph_info_cache: ahash::HashMap<char, GlyphInfo>,
|
glyph_info_cache: ahash::HashMap<char, GlyphInfo>,
|
||||||
glyph_alloc_cache: ahash::HashMap<GlyphCacheKey, GlyphAllocation>,
|
glyph_alloc_cache: ahash::HashMap<GlyphCacheKey, GlyphAllocation>,
|
||||||
|
|
||||||
|
/// Cache for canvas-rendered glyphs (WASM only)
|
||||||
|
/// Key: (character, pixels_per_point bits, px_scale_factor bits, subpixel bin)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
canvas_glyph_cache: ahash::HashMap<(char, u32, u32, SubpixelBin), GlyphAllocation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FontFace {
|
impl FontFace {
|
||||||
@@ -436,6 +441,8 @@ impl FontFace {
|
|||||||
location,
|
location,
|
||||||
glyph_info_cache: Default::default(),
|
glyph_info_cache: Default::default(),
|
||||||
glyph_alloc_cache: Default::default(),
|
glyph_alloc_cache: Default::default(),
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
canvas_glyph_cache: Default::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,26 +621,65 @@ impl FontFace {
|
|||||||
SubpixelBin::new(h_pos)
|
SubpixelBin::new(h_pos)
|
||||||
};
|
};
|
||||||
|
|
||||||
let entry = match self
|
// For canvas glyphs (NOTDEF), we need to use the character for caching
|
||||||
.glyph_alloc_cache
|
// because all canvas glyphs have the same glyph_id
|
||||||
.entry(GlyphCacheKey::new(glyph_id, metrics, bin))
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
if glyph_id == skrifa::GlyphId::NOTDEF {
|
||||||
std::collections::hash_map::Entry::Occupied(glyph_alloc) => {
|
// Use character-based cache key for canvas glyphs
|
||||||
let mut glyph_alloc = *glyph_alloc.get();
|
// Include bin for subpixel positioning
|
||||||
glyph_alloc.advance_width_px = advance_width_px; // Hack to get `\t` and thin space to work, since they use the same glyph id as ` ` (space).
|
let canvas_cache_key = (chr, metrics.pixels_per_point.to_bits(), metrics.px_scale_factor.to_bits(), bin);
|
||||||
return (glyph_alloc, h_pos_round);
|
|
||||||
}
|
|
||||||
std::collections::hash_map::Entry::Vacant(entry) => entry,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Check canvas glyph cache
|
||||||
|
if let Some(&cached_alloc) = self.canvas_glyph_cache.get(&canvas_cache_key) {
|
||||||
|
return (cached_alloc, h_pos_round);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render with canvas at subpixel offset
|
||||||
|
let allocation = Self::try_allocate_canvas_glyph_static(atlas, metrics, chr, bin).unwrap_or_default();
|
||||||
|
|
||||||
|
// Cache it
|
||||||
|
self.canvas_glyph_cache.insert(canvas_cache_key, allocation);
|
||||||
|
return (allocation, h_pos_round);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first (for non-canvas glyphs)
|
||||||
|
let cache_key = GlyphCacheKey::new(glyph_id, metrics, bin);
|
||||||
|
if let Some(&cached_alloc) = self.glyph_alloc_cache.get(&cache_key) {
|
||||||
|
let mut glyph_alloc = cached_alloc;
|
||||||
|
glyph_alloc.advance_width_px = advance_width_px; // Hack to get `\t` and thin space to work, since they use the same glyph id as ` ` (space).
|
||||||
|
return (glyph_alloc, h_pos_round);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate the glyph
|
||||||
let allocation = self
|
let allocation = self
|
||||||
.font
|
.font
|
||||||
.allocate_glyph_uncached(atlas, metrics, &glyph_info, bin, &self.location)
|
.allocate_glyph_uncached(atlas, metrics, &glyph_info, bin, &self.location)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
entry.insert(allocation);
|
// Insert into cache
|
||||||
|
self.glyph_alloc_cache.insert(cache_key, allocation);
|
||||||
(allocation, h_pos_round)
|
(allocation, h_pos_round)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn try_allocate_canvas_glyph_static(
|
||||||
|
atlas: &mut TextureAtlas,
|
||||||
|
metrics: &ScaledMetrics,
|
||||||
|
chr: char,
|
||||||
|
bin: SubpixelBin,
|
||||||
|
) -> Option<GlyphAllocation> {
|
||||||
|
use crate::text::canvas_renderer;
|
||||||
|
|
||||||
|
// Build font family list - just use a generic font for now
|
||||||
|
// TODO: We could pass the full font family list from CachedFamily
|
||||||
|
let font_families: Vec<String> = vec!["sans-serif".to_string()];
|
||||||
|
|
||||||
|
// Try to render the glyph with subpixel offset
|
||||||
|
let canvas_data = canvas_renderer::render_glyph_with_canvas(chr, metrics, &font_families, bin)?;
|
||||||
|
|
||||||
|
// Allocate the canvas-rendered glyph
|
||||||
|
Some(allocate_canvas_glyph(atlas, metrics, canvas_data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(emilk): rename?
|
// TODO(emilk): rename?
|
||||||
@@ -787,6 +833,64 @@ fn invisible_char(c: char) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub(super) fn allocate_canvas_glyph(
|
||||||
|
atlas: &mut TextureAtlas,
|
||||||
|
metrics: &ScaledMetrics,
|
||||||
|
canvas_data: crate::text::canvas_renderer::CanvasGlyphData,
|
||||||
|
) -> GlyphAllocation {
|
||||||
|
use crate::text::canvas_renderer::CanvasGlyphData;
|
||||||
|
|
||||||
|
let CanvasGlyphData {
|
||||||
|
image_data,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
advance_width,
|
||||||
|
offset_x,
|
||||||
|
offset_y,
|
||||||
|
} = canvas_data;
|
||||||
|
|
||||||
|
// Get alpha_from_coverage before allocating
|
||||||
|
let alpha_from_coverage = atlas.options().alpha_from_coverage;
|
||||||
|
|
||||||
|
// Allocate space in the texture atlas
|
||||||
|
let (glyph_pos, image) = atlas.allocate((width as usize, height as usize));
|
||||||
|
|
||||||
|
// Convert RGBA to alpha channel
|
||||||
|
// Canvas ImageData is RGBA, we need alpha channel only
|
||||||
|
for y in 0..height as usize {
|
||||||
|
for x in 0..width as usize {
|
||||||
|
let idx = (y * width as usize + x) * 4;
|
||||||
|
// Use alpha channel from ImageData
|
||||||
|
let alpha = image_data[idx + 3] as f32 / 255.0;
|
||||||
|
image[(x + glyph_pos.0, y + glyph_pos.1)] =
|
||||||
|
alpha_from_coverage.color_from_coverage(alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate offset in points
|
||||||
|
let offset_in_points =
|
||||||
|
Vec2::new(offset_x, offset_y) / metrics.pixels_per_point + metrics.y_offset_in_points * Vec2::Y;
|
||||||
|
|
||||||
|
GlyphAllocation {
|
||||||
|
id: skrifa::GlyphId::NOTDEF, // Mark as canvas-rendered
|
||||||
|
advance_width_px: advance_width,
|
||||||
|
uv_rect: UvRect {
|
||||||
|
offset: offset_in_points,
|
||||||
|
size: Vec2::new(width as f32, height as f32) / metrics.pixels_per_point,
|
||||||
|
min: [glyph_pos.0 as u16, glyph_pos.1 as u16],
|
||||||
|
max: [
|
||||||
|
(glyph_pos.0 + width as usize) as u16,
|
||||||
|
(glyph_pos.1 + height as usize) as u16,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(super) fn is_cjk_ideograph(c: char) -> bool {
|
pub(super) fn is_cjk_ideograph(c: char) -> bool {
|
||||||
('\u{4E00}' <= c && c <= '\u{9FFF}')
|
('\u{4E00}' <= c && c <= '\u{9FFF}')
|
||||||
|
|||||||
@@ -525,6 +525,23 @@ impl CachedFamily {
|
|||||||
return Some((*font_key, glyph_info));
|
return Some((*font_key, glyph_info));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try canvas fallback for WASM
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Use a special GlyphInfo to indicate canvas rendering is needed
|
||||||
|
// We use NOTDEF as the glyph ID to mark this as a canvas glyph
|
||||||
|
// The advance width will be filled in when we actually render it
|
||||||
|
let canvas_glyph_info = GlyphInfo {
|
||||||
|
id: Some(skrifa::GlyphId::NOTDEF),
|
||||||
|
advance_width_unscaled: OrderedFloat(0.0), // Will be filled in during rendering
|
||||||
|
};
|
||||||
|
// Use first font key as placeholder (the actual font family list will be used during rendering)
|
||||||
|
if let Some(&first_font_key) = self.fonts.first() {
|
||||||
|
return Some((first_font_key, canvas_glyph_info));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ mod fonts;
|
|||||||
mod text_layout;
|
mod text_layout;
|
||||||
mod text_layout_types;
|
mod text_layout_types;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod canvas_renderer;
|
||||||
|
|
||||||
/// One `\t` character is this many spaces wide.
|
/// One `\t` character is this many spaces wide.
|
||||||
pub const TAB_SIZE: usize = 4;
|
pub const TAB_SIZE: usize = 4;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user