Add glyphon renderer

This commit is contained in:
Jeremy Soller 2023-12-18 13:54:08 -07:00
parent 11384d0c33
commit 65b613e6e4
No known key found for this signature in database
GPG key ID: DCFCA852D3906975
7 changed files with 1511 additions and 919 deletions

1431
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,10 +7,36 @@ edition = "2021"
[dependencies] [dependencies]
alacritty_terminal = "0.19" alacritty_terminal = "0.19"
softbuffer = "0.4" bytemuck = "1.14"
env_logger = "0.10"
log = "0.4"
tiny-skia = "0.11" tiny-skia = "0.11"
winit = "0.29"
[dependencies.cosmic-text] [dependencies.cosmic-text]
git = "https://github.com/pop-os/cosmic-text.git" git = "https://github.com/pop-os/cosmic-text.git"
branch = "refactor" branch = "refactor"
[dependencies.glyphon]
git = "https://github.com/jackpot51/glyphon.git"
branch = "refactor"
optional = true
[dependencies.pollster]
version = "0.3"
optional = true
[dependencies.softbuffer]
git = "https://github.com/pop-os/softbuffer.git"
branch = "cosmic"
[dependencies.wgpu]
version = "0.18"
optional = true
[dependencies.winit]
git = "https://github.com/pop-os/winit.git"
branch = "master"
[features]
default = []
wgpu = ["dep:glyphon", "dep:pollster", "dep:wgpu"]

View file

@ -1,2 +1,6 @@
# cosmic-term # cosmic-term
WIP COSMIC terminal emulator, built using [alacritty\_terminal](https://docs.rs/alacritty_terminal) that is provided by the [alacritty](https://github.com/alacritty/alacritty) project. `cosmic-term` provides bidirectional rendering and ligatures with a custom renderer based on [cosmic-text](https://github.com/pop-os/cosmic-text). WIP COSMIC terminal emulator, built using [alacritty\_terminal](https://docs.rs/alacritty_terminal) that is provided by the [alacritty](https://github.com/alacritty/alacritty) project. `cosmic-term` provides bidirectional rendering and ligatures with a custom renderer based on [cosmic-text](https://github.com/pop-os/cosmic-text).
The `wgpu` feature, enabled by default, supports GPU rendering using `glyphon`
and `wgpu`. If `wgpu` is not enabled or fails to initialize, then rendering falls
back to using `softbuffer` and `tiny-skia`.

View file

@ -14,17 +14,21 @@ use alacritty_terminal::{
}; };
use cosmic_text::{ use cosmic_text::{
Attrs, AttrsList, Buffer, BufferLine, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Attrs, AttrsList, Buffer, BufferLine, Family, FontSystem, Metrics, Shaping, Style, SwashCache,
SwashContent, Weight, Wrap, Weight, Wrap,
}; };
use std::{mem, num::NonZeroU32, rc::Rc, slice, sync::Arc, time::Instant}; use std::{mem, rc::Rc, sync::Arc, time::Instant};
use tiny_skia::{ColorU8, Paint, PixmapMut, PixmapPaint, PixmapRef, Rect, Transform};
use winit::{ use winit::{
event::{ElementState, Event as WinitEvent, KeyEvent, Modifiers, WindowEvent}, event::{
ElementState, Event as WinitEvent, KeyboardInput, ModifiersState, VirtualKeyCode,
WindowEvent,
},
event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy}, event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy},
keyboard::{Key, ModifiersState, NamedKey},
window::WindowBuilder, window::WindowBuilder,
}; };
use self::renderer::Renderer;
mod renderer;
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct Size { struct Size {
width: f32, width: f32,
@ -132,6 +136,8 @@ fn colors() -> Colors {
} }
fn main() { fn main() {
env_logger::init();
let mut font_system = FontSystem::new(); let mut font_system = FontSystem::new();
let mut swash_cache = SwashCache::new(); let mut swash_cache = SwashCache::new();
let metrics = Metrics::new(14.0, 20.0); let metrics = Metrics::new(14.0, 20.0);
@ -147,9 +153,7 @@ fn main() {
}; };
println!("{}, {}", cell_width, cell_height); println!("{}, {}", cell_width, cell_height);
let event_loop = EventLoopBuilder::<TermEvent>::with_user_event() let event_loop = EventLoopBuilder::<TermEvent>::with_user_event().build();
.build()
.unwrap();
let event_loop_proxy = event_loop.create_proxy(); let event_loop_proxy = event_loop.create_proxy();
let window = Rc::new(WindowBuilder::new().build(&event_loop).unwrap()); let window = Rc::new(WindowBuilder::new().build(&event_loop).unwrap());
let (width, height) = { let (width, height) = {
@ -180,228 +184,86 @@ fn main() {
let mut notifier = Notifier(pty_event_loop.channel()); let mut notifier = Notifier(pty_event_loop.channel());
let pty_join_handle = pty_event_loop.spawn(); let pty_join_handle = pty_event_loop.spawn();
let context = softbuffer::Context::new(window.clone()).unwrap(); let mut renderer = Renderer::new(window.clone()).unwrap();
let mut surface = softbuffer::Surface::new(&context, window.clone()).unwrap(); let mut modifiers = ModifiersState::default();
let mut modifiers = Modifiers::default(); event_loop.run(move |event, _elwt, control_flow| {
event_loop *control_flow = ControlFlow::Wait;
.run(move |event, elwt| {
elwt.set_control_flow(ControlFlow::Wait);
match event { match event {
WinitEvent::WindowEvent { WinitEvent::RedrawRequested(window_id) if window_id == window.id() => {
window_id,
event: WindowEvent::RedrawRequested,
} if window_id == window.id() => {
let instant = Instant::now(); let instant = Instant::now();
let (width, height) = { renderer.render(&mut buffer, &mut font_system, &mut swash_cache);
let size = window.inner_size();
(size.width, size.height)
};
surface
.resize(
NonZeroU32::new(width).unwrap(),
NonZeroU32::new(height).unwrap(),
)
.unwrap();
let mut surface_buffer = surface.buffer_mut().unwrap();
let surface_buffer_u8 = unsafe {
slice::from_raw_parts_mut(
surface_buffer.as_mut_ptr() as *mut u8,
surface_buffer.len() * 4,
)
};
let mut pixmap =
PixmapMut::from_bytes(surface_buffer_u8, width, height).unwrap();
pixmap.fill(tiny_skia::Color::from_rgba8(0, 0, 0, 0xFF));
// Set scroll to view scroll
//TODO buffer.set_scroll(*scroll);
// Set size, will relayout and shape until scroll if changed
buffer.set_size(&mut font_system, width as f32, height as f32);
// Shape until scroll, ensures scroll is clamped
buffer.shape_until_scroll(&mut font_system, true);
// Update scroll after buffer clamps it
//TODO *scroll = buffer.scroll();
let mut paint = Paint::default();
paint.anti_alias = false;
let pixmap_paint = PixmapPaint::default();
let transform = Transform::identity();
for run in buffer.layout_runs() {
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((0., 0.), 1.0);
let glyph_color = match glyph.color_opt {
Some(some) => some,
None => cosmic_text::Color::rgb(0xFF, 0xFF, 0xFF),
};
let background_color = cosmic_text::Color(glyph.metadata as u32);
if background_color.0 != 0xFF000000 {
//TODO: Have to swap RGB for BGR
paint.set_color_rgba8(
background_color.b(),
background_color.g(),
background_color.r(),
background_color.a(),
);
pixmap.fill_rect(
Rect::from_xywh(
glyph.x,
run.line_top,
glyph.w,
metrics.line_height,
)
.unwrap(),
&paint,
transform,
None,
);
}
match swash_cache.get_image(&mut font_system, physical_glyph.cache_key)
{
Some(image) if !image.data.is_empty() => {
let mut data = Vec::with_capacity(
(image.placement.width * image.placement.height) as usize,
);
match image.content {
SwashContent::Mask => {
let mut i = 0;
while i < image.data.len() {
//TODO: Have to swap RGB for BGR
data.push(
ColorU8::from_rgba(
glyph_color.b(),
glyph_color.g(),
glyph_color.r(),
image.data[i],
)
.premultiply(),
);
i += 1;
}
}
SwashContent::Color => {
let mut i = 0;
while i < image.data.len() {
//TODO: Have to swap RGB for BGR
data.push(
ColorU8::from_rgba(
image.data[i + 2],
image.data[i + 1],
image.data[i],
image.data[i + 3],
)
.premultiply(),
);
i += 4;
}
}
SwashContent::SubpixelMask => {
todo!("TODO: SubpixelMask");
}
}
let glyph_pixmap = PixmapRef::from_bytes(
unsafe {
slice::from_raw_parts(
data.as_ptr() as *const u8,
data.len() * 4,
)
},
image.placement.width,
image.placement.height,
)
.unwrap();
pixmap.draw_pixmap(
physical_glyph.x + image.placement.left,
run.line_y as i32 + physical_glyph.y - image.placement.top,
glyph_pixmap,
&pixmap_paint,
transform,
None,
);
}
_ => {}
}
}
}
buffer.set_redraw(false);
surface_buffer.present().unwrap();
println!("draw {:?}", instant.elapsed()); println!("draw {:?}", instant.elapsed());
} }
WinitEvent::WindowEvent {
event: WindowEvent::ReceivedCharacter(c),
window_id,
} if window_id == window.id() => {
match (modifiers.logo(), modifiers.ctrl(), modifiers.alt()) {
(true, _, _) => {} // Ignore super
(false, true, _) => {
// Control keys
if c >= 'a' && c <= 'z' {
notifier.notify(vec![(c as u8) - b'a' + 1]);
}
}
(false, false, true) => {
// Alt keys
let mut buf = [0x1B, 0, 0, 0, 0];
let str = c.encode_utf8(&mut buf[1..]);
notifier.notify(str.as_bytes().to_vec());
}
(false, false, false) => {
let mut buf = [0, 0, 0, 0];
let str = c.encode_utf8(&mut buf);
notifier.notify(str.as_bytes().to_vec());
}
}
}
WinitEvent::WindowEvent { WinitEvent::WindowEvent {
event: event:
WindowEvent::KeyboardInput { WindowEvent::KeyboardInput {
event: input:
KeyEvent { KeyboardInput {
logical_key,
text: text_opt,
state: ElementState::Pressed, state: ElementState::Pressed,
virtual_keycode: Some(virtual_keycode),
.. ..
}, },
.. ..
}, },
window_id, window_id,
} if window_id == window.id() => { } if window_id == window.id() => {
println!("{:?} {:?}", modifiers, text_opt); match (modifiers.logo(), modifiers.ctrl(), modifiers.alt()) {
match (
modifiers.state().contains(ModifiersState::SUPER),
modifiers.state().contains(ModifiersState::CONTROL),
modifiers.state().contains(ModifiersState::ALT),
) {
(true, _, _) => {} // Ignore super (true, _, _) => {} // Ignore super
(false, true, _) => match text_opt { (false, true, _) => {
Some(text) if text.len() == 1 => { // Control keys will use ReceivedCharacter instead
// Control keys
let c = text.chars().next().unwrap_or_default();
if c >= 'a' && c <= 'z' {
notifier.notify(vec![(c as u8) - b'a' + 1]);
} }
(false, false, true) => {
//TODO: support Alt keys without character
} }
_ => {} (false, false, false) => match virtual_keycode {
}, VirtualKeyCode::Up => {
(false, false, true) => match text_opt {
Some(text) => {
// Alt keys
//TODO: handle alt keys with no text
let mut bytes = text.as_bytes().to_vec();
bytes.insert(0, b'\x1B');
notifier.notify(bytes);
}
None => {}
},
(false, false, false) => match text_opt {
Some(text) => {
notifier.notify(text.as_bytes().to_vec());
}
None => match logical_key {
Key::Named(NamedKey::ArrowUp) => {
notifier.notify(b"\x1B[A".as_slice()); notifier.notify(b"\x1B[A".as_slice());
} }
Key::Named(NamedKey::ArrowDown) => { VirtualKeyCode::Down => {
notifier.notify(b"\x1B[B".as_slice()); notifier.notify(b"\x1B[B".as_slice());
} }
Key::Named(NamedKey::ArrowRight) => { VirtualKeyCode::Right => {
notifier.notify(b"\x1B[C".as_slice()); notifier.notify(b"\x1B[C".as_slice());
} }
Key::Named(NamedKey::ArrowLeft) => { VirtualKeyCode::Left => {
notifier.notify(b"\x1B[D".as_slice()); notifier.notify(b"\x1B[D".as_slice());
} }
Key::Named(NamedKey::End) => { VirtualKeyCode::End => {
notifier.notify(b"\x1B[F".as_slice()); notifier.notify(b"\x1B[F".as_slice());
} }
Key::Named(NamedKey::Home) => { VirtualKeyCode::Home => {
notifier.notify(b"\x1B[H".as_slice()); notifier.notify(b"\x1B[H".as_slice());
} }
_ => {} _ => {}
}, },
},
} }
} }
WinitEvent::WindowEvent { WinitEvent::WindowEvent {
@ -414,11 +276,24 @@ fn main() {
event: WindowEvent::Resized(physical_size), event: WindowEvent::Resized(physical_size),
window_id, window_id,
} if window_id == window.id() => { } if window_id == window.id() => {
let instant = Instant::now();
dimensions.width = physical_size.width as f32; dimensions.width = physical_size.width as f32;
dimensions.height = physical_size.height as f32; dimensions.height = physical_size.height as f32;
notifier.on_resize(dimensions.into()); notifier.on_resize(dimensions.into());
term.lock().resize(dimensions); term.lock().resize(dimensions);
window.request_redraw();
buffer.set_size(
&mut font_system,
dimensions.width as f32,
dimensions.height as f32,
);
renderer.resize(physical_size.width, physical_size.height);
println!("resize {:?}", instant.elapsed());
} }
WinitEvent::WindowEvent { WinitEvent::WindowEvent {
event: WindowEvent::CloseRequested, event: WindowEvent::CloseRequested,
@ -429,7 +304,8 @@ fn main() {
WinitEvent::UserEvent(user_event) => { WinitEvent::UserEvent(user_event) => {
println!("{:?}", user_event); println!("{:?}", user_event);
match user_event { match user_event {
TermEvent::Exit => elwt.exit(), //TODO: other error codes?
TermEvent::Exit => *control_flow = ControlFlow::ExitWithCode(0),
TermEvent::PtyWrite(text) => notifier.notify(text.into_bytes()), TermEvent::PtyWrite(text) => notifier.notify(text.into_bytes()),
TermEvent::Title(title) => { TermEvent::Title(title) => {
window.set_title(&title); window.set_title(&title);
@ -544,6 +420,8 @@ fn main() {
} }
} }
buffer.shape_until_scroll(&mut font_system, true);
if buffer.redraw() { if buffer.redraw() {
window.request_redraw(); window.request_redraw();
} }
@ -552,8 +430,7 @@ fn main() {
} }
_ => {} _ => {}
} }
}) });
.unwrap();
//TODO: hangs after event loop exit pty_join_handle.join().unwrap(); //TODO: hangs after event loop exit pty_join_handle.join().unwrap();
} }

60
src/renderer/mod.rs Normal file
View file

@ -0,0 +1,60 @@
use cosmic_text::{Buffer, FontSystem, SwashCache};
use std::rc::Rc;
use winit::window::Window;
pub use self::software::SoftwareRenderer;
pub mod software;
#[cfg(feature = "wgpu")]
pub use self::wgpu::WgpuRenderer;
#[cfg(feature = "wgpu")]
pub mod wgpu;
pub enum Renderer {
Software(SoftwareRenderer),
#[cfg(feature = "wgpu")]
Wgpu(WgpuRenderer),
}
impl Renderer {
pub fn new(window: Rc<Window>) -> Result<Self, String> {
#[cfg(feature = "wgpu")]
match WgpuRenderer::new(window.clone()) {
Ok(renderer) => return Ok(Self::Wgpu(renderer)),
Err(err) => {
log::error!("failed to use hardware rendering: {}", err);
}
}
SoftwareRenderer::new(window).map(Renderer::Software)
}
pub fn render(
&mut self,
buffer: &mut Buffer,
font_system: &mut FontSystem,
swash_cache: &mut SwashCache,
) {
match self {
Self::Software(renderer) => {
renderer.render(buffer, font_system, swash_cache);
}
#[cfg(feature = "wgpu")]
Self::Wgpu(renderer) => {
renderer.render(buffer, font_system, swash_cache);
}
}
}
pub fn resize(&mut self, width: u32, height: u32) {
match self {
Self::Software(renderer) => {
renderer.resize(width, height);
}
#[cfg(feature = "wgpu")]
Self::Wgpu(renderer) => {
renderer.resize(width, height);
}
}
}
}

141
src/renderer/software.rs Normal file
View file

@ -0,0 +1,141 @@
use cosmic_text::{Buffer, FontSystem, SwashCache, SwashContent};
use std::{rc::Rc, slice};
use tiny_skia::{ColorU8, Paint, Pixmap, PixmapPaint, PixmapRef, Rect, Transform};
use winit::window::Window;
pub struct SoftwareRenderer {
pub window: Rc<Window>,
pub context: softbuffer::GraphicsContext,
}
impl SoftwareRenderer {
pub fn new(window: Rc<Window>) -> Result<Self, String> {
let context = unsafe { softbuffer::GraphicsContext::new(&*window, &*window) }
.map_err(|err| format!("failed to create context: {}", err))?;
Ok(Self { window, context })
}
pub fn render(
&mut self,
buffer: &mut Buffer,
font_system: &mut FontSystem,
swash_cache: &mut SwashCache,
) {
let (width, height) = {
let size = self.window.inner_size();
(size.width, size.height)
};
let mut pixmap = Pixmap::new(width, height).unwrap();
//TODO: configurable background
pixmap.fill(tiny_skia::Color::from_rgba8(0, 0, 0, 0xFF));
let line_height = buffer.metrics().line_height;
let mut paint = Paint::default();
paint.anti_alias = false;
let pixmap_paint = PixmapPaint::default();
let transform = Transform::identity();
for run in buffer.layout_runs() {
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((0., 0.), 1.0);
let glyph_color = match glyph.color_opt {
Some(some) => some,
None => cosmic_text::Color::rgb(0xFF, 0xFF, 0xFF),
};
let background_color = cosmic_text::Color(glyph.metadata as u32);
if background_color.0 != 0xFF000000 {
//TODO: Have to swap RGB for BGR
paint.set_color_rgba8(
background_color.b(),
background_color.g(),
background_color.r(),
background_color.a(),
);
pixmap.fill_rect(
Rect::from_xywh(glyph.x, run.line_top, glyph.w, line_height).unwrap(),
&paint,
transform,
None,
);
}
match swash_cache.get_image(font_system, physical_glyph.cache_key) {
Some(image) if !image.data.is_empty() => {
let mut data = Vec::with_capacity(
(image.placement.width * image.placement.height) as usize,
);
match image.content {
SwashContent::Mask => {
let mut i = 0;
while i < image.data.len() {
//TODO: Have to swap RGB for BGR
data.push(
ColorU8::from_rgba(
glyph_color.b(),
glyph_color.g(),
glyph_color.r(),
image.data[i],
)
.premultiply(),
);
i += 1;
}
}
SwashContent::Color => {
let mut i = 0;
while i < image.data.len() {
//TODO: Have to swap RGB for BGR
data.push(
ColorU8::from_rgba(
image.data[i + 2],
image.data[i + 1],
image.data[i],
image.data[i + 3],
)
.premultiply(),
);
i += 4;
}
}
SwashContent::SubpixelMask => {
todo!("TODO: SubpixelMask");
}
}
let glyph_pixmap = PixmapRef::from_bytes(
unsafe {
slice::from_raw_parts(data.as_ptr() as *const u8, data.len() * 4)
},
image.placement.width,
image.placement.height,
)
.unwrap();
pixmap.draw_pixmap(
physical_glyph.x + image.placement.left,
run.line_y as i32 + physical_glyph.y - image.placement.top,
glyph_pixmap,
&pixmap_paint,
transform,
None,
);
}
_ => {}
}
}
}
buffer.set_redraw(false);
self.context.set_buffer(
bytemuck::cast_slice(pixmap.data()),
width as u16,
height as u16,
);
}
pub fn resize(&mut self, width: u32, height: u32) {
//TODO: resize image here for better performance
self.window.request_redraw();
}
}

145
src/renderer/wgpu.rs Normal file
View file

@ -0,0 +1,145 @@
use glyphon::{
Attrs, Buffer, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea,
TextAtlas, TextBounds, TextRenderer,
};
use std::{error::Error, rc::Rc};
use wgpu::{
CommandEncoderDescriptor, CompositeAlphaMode, Device, DeviceDescriptor, Features, Instance,
InstanceDescriptor, Limits, LoadOp, MultisampleState, Operations, PresentMode, Queue,
RenderPassColorAttachment, RenderPassDescriptor, RequestAdapterOptions, Surface,
SurfaceConfiguration, TextureFormat, TextureUsages, TextureViewDescriptor,
};
use winit::window::Window;
pub struct WgpuRenderer {
pub window: Rc<Window>,
pub device: Device,
pub queue: Queue,
pub surface: Surface,
pub config: SurfaceConfiguration,
pub atlas: TextAtlas,
pub text_renderer: TextRenderer,
}
impl WgpuRenderer {
async fn new_async(window: Rc<Window>) -> Result<Self, String> {
let size = window.inner_size();
let instance = Instance::new(InstanceDescriptor::default());
let adapter = instance
.request_adapter(&RequestAdapterOptions::default())
.await
.ok_or(format!("failed to request adapter"))?;
let (device, queue) = adapter
.request_device(
&DeviceDescriptor {
label: None,
features: Features::empty(),
limits: Limits::downlevel_defaults(),
},
None,
)
.await
.map_err(|err| format!("failed to request device: {}", err))?;
let surface = unsafe { instance.create_surface(&*window) }
.map_err(|err| format!("failed to create surface: {}", err))?;
let swapchain_format = TextureFormat::Bgra8UnormSrgb;
let mut config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: swapchain_format,
width: size.width,
height: size.height,
present_mode: PresentMode::Fifo,
alpha_mode: CompositeAlphaMode::Opaque,
view_formats: vec![],
};
surface.configure(&device, &config);
let mut atlas = TextAtlas::new(&device, &queue, swapchain_format);
let mut text_renderer =
TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None);
Ok(Self {
window,
device,
queue,
surface,
config,
atlas,
text_renderer,
})
}
pub fn new(window: Rc<Window>) -> Result<Self, String> {
pollster::block_on(Self::new_async(window))
}
pub fn render(
&mut self,
buffer: &mut Buffer,
font_system: &mut FontSystem,
swash_cache: &mut SwashCache,
) {
self.text_renderer
.prepare(
&self.device,
&self.queue,
font_system,
&mut self.atlas,
Resolution {
width: self.config.width,
height: self.config.height,
},
[TextArea {
buffer: &buffer,
left: 0.0,
top: 0.0,
scale: 1.0,
bounds: TextBounds {
left: 0,
top: 0,
right: self.config.width as i32,
bottom: self.config.height as i32,
},
default_color: Color::rgb(255, 255, 255),
}],
swash_cache,
)
.unwrap();
let frame = self.surface.get_current_texture().unwrap();
let view = frame.texture.create_view(&TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&CommandEncoderDescriptor { label: None });
{
let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: None,
color_attachments: &[Some(RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
self.text_renderer.render(&self.atlas, &mut pass).unwrap();
}
self.queue.submit(Some(encoder.finish()));
frame.present();
self.atlas.trim();
}
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.window.request_redraw();
}
}