#![doc = include_str!("../docs/bevy-to-kitty.md")]
use bevy::prelude::*;
pub mod proto;
pub mod term;
pub mod pixels;
#[cfg(feature = "sprite")]
pub mod sprite;
#[cfg(feature = "text")]
pub mod text;
#[cfg(feature = "ui")]
pub mod ui;
#[cfg(feature = "frame")]
pub mod frame;
#[cfg(feature = "input")]
pub mod input;
pub use term::{FitBox, TermSize};
pub mod prelude {
#[cfg(feature = "input")]
pub use crate::input::{KittyClick, KittyKey};
pub use crate::pixels::{AssetPixels, Bitmap, DiskPixels, PixelRequest, PixelSource};
pub use crate::term::TermSize;
pub use crate::{KittyCamera, KittyConfig, KittyMode, KittyPlugin, KittySet};
}
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct KittyCamera;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum KittyMode {
#[default]
Sprite,
Frame,
}
#[derive(Resource, Clone, Debug)]
pub struct KittyConfig {
pub virtual_size: UVec2,
pub mode: KittyMode,
pub text_scale: f32,
pub terminal_size: Option<TermSize>,
pub text: bool,
pub ui: bool,
pub input: bool,
pub pixel_source: std::sync::Arc<dyn pixels::PixelSource>,
}
impl Default for KittyConfig {
fn default() -> Self {
Self {
virtual_size: UVec2::new(320, 180),
mode: KittyMode::default(),
text_scale: 4.0,
terminal_size: None,
text: true,
ui: true,
input: true,
pixel_source: std::sync::Arc::new(pixels::DiskPixels::from_env()),
}
}
}
impl KittyConfig {
pub(crate) fn clamped_text_scale(&self) -> f32 {
if !(1.0..=16.0).contains(&self.text_scale) {
warn!(
"[kitty] text_scale {} out of range 1..16 - clamping",
self.text_scale
);
}
self.text_scale.clamp(1.0, 16.0)
}
pub(crate) fn target_scale_factor(&self) -> f32 {
match self.mode {
KittyMode::Sprite => self.clamped_text_scale(),
KittyMode::Frame => 1.0,
}
}
}
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KittySet {
Input,
Render,
}
#[derive(Default)]
pub struct KittyPlugin {
pub config: KittyConfig,
}
impl KittyPlugin {
pub fn new(virtual_size: UVec2) -> Self {
Self {
config: KittyConfig {
virtual_size,
..default()
},
}
}
}
impl Plugin for KittyPlugin {
fn build(&self, app: &mut App) {
let config = self.config.clone();
info!(
"[kitty] mode {:?}, virtual {}x{}, text {}, input {}",
config.mode, config.virtual_size.x, config.virtual_size.y, config.text, config.input
);
#[cfg(not(feature = "frame"))]
if config.mode == KittyMode::Frame {
error!(
"[kitty] mode Frame requested but the `frame` cargo feature is off - \
nothing will be drawn. Enable it, or use KittyMode::Sprite."
);
}
#[cfg(not(feature = "sprite"))]
if config.mode == KittyMode::Sprite {
error!(
"[kitty] mode Sprite requested but the `sprite` cargo feature is off - \
nothing will be drawn."
);
}
#[cfg(not(feature = "text"))]
if config.text {
warn!(
"[kitty] config.text is on but the `text` cargo feature is off - \
no text will be rendered."
);
}
#[cfg(not(feature = "ui"))]
if config.ui {
warn!(
"[kitty] config.ui is on but the `ui` cargo feature is off - \
bevy_ui nodes will not be drawn."
);
}
#[cfg(not(feature = "input"))]
if config.input {
warn!(
"[kitty] config.input is on but the `input` cargo feature is off - \
the terminal will not be readable."
);
}
app.insert_resource(config.clone());
app.configure_sets(Update, KittySet::Input);
app.configure_sets(PostUpdate, KittySet::Render);
app.add_systems(Startup, enter_terminal);
app.add_systems(Last, restore_cursor_on_exit);
app.init_resource::<TargetState>();
app.add_systems(Startup, setup_render_target_once);
app.add_systems(Update, setup_render_target_once);
#[cfg(feature = "input")]
{
input::register_messages(app);
if config.input {
input::build(app);
}
}
#[cfg(feature = "frame")]
if config.mode == KittyMode::Frame {
frame::build(app);
}
#[cfg(feature = "sprite")]
if config.mode == KittyMode::Sprite {
sprite::build(app);
#[cfg(feature = "text")]
if config.text {
text::build(app);
}
#[cfg(feature = "ui")]
if config.ui {
ui::build(app);
}
}
}
}
#[derive(Resource, Default)]
pub(crate) struct TargetState {
pub(crate) image: Option<Handle<Image>>,
pub(crate) retargeted: bool,
}
fn setup_render_target_once(
mut commands: Commands,
mut state: ResMut<TargetState>,
mut images: ResMut<Assets<Image>>,
config: Res<KittyConfig>,
camera_q: Query<Entity, With<KittyCamera>>,
mut cameras: Query<&mut Camera>,
) {
use bevy::asset::RenderAssetUsages;
use bevy::camera::RenderTarget;
use bevy::image::Image;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
if state.retargeted {
return;
}
let Ok(camera) = camera_q.single() else {
return;
};
let sf = config.target_scale_factor();
let size = Extent3d {
width: (config.virtual_size.x as f32 * sf).round().max(1.0) as u32,
height: (config.virtual_size.y as f32 * sf).round().max(1.0) as u32,
depth_or_array_layers: 1,
};
let mut image = Image::new_fill(
size,
TextureDimension::D2,
&[0, 0, 0, 255],
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
);
image.texture_descriptor.usage =
TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_SRC | TextureUsages::RENDER_ATTACHMENT;
let handle = images.add(image);
let mut target: bevy::camera::ImageRenderTarget = handle.clone().into();
target.scale_factor = sf;
commands.entity(camera).insert(RenderTarget::Image(target));
if let Ok(mut cam) = cameras.get_mut(camera) {
cam.viewport = Some(bevy::camera::Viewport {
physical_position: UVec2::ZERO,
physical_size: UVec2::new(size.width, size.height),
..default()
});
}
#[cfg(feature = "ui")]
commands.entity(camera).insert(bevy::ui::IsDefaultUiCamera);
state.image = Some(handle);
state.retargeted = true;
info!(
"[kitty] render target armed: {}x{} physical at {sf:.1}x, logical stays {}x{}",
size.width, size.height, config.virtual_size.x, config.virtual_size.y
);
}
fn enter_terminal() {
let mut buf = Vec::new();
proto::enter_screen(&mut buf);
if !write_stdout(&buf, "terminal setup") {
return;
}
install_cursor_restore();
info!("[kitty] terminal prepared (cleared, cursor hidden)");
}
fn install_cursor_restore() {
use std::sync::OnceLock;
static GUARD: OnceLock<()> = OnceLock::new();
if GUARD.set(()).is_err() {
return; }
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
show_cursor();
prev(info);
}));
}
fn restore_cursor_on_exit(mut exits: MessageReader<AppExit>) {
if exits.read().next().is_some() {
info!("[kitty] app exiting, restoring cursor");
show_cursor();
}
}
pub fn show_cursor() {
use std::io::Write as _;
let mut buf = Vec::new();
proto::leave_screen(&mut buf);
let mut out = std::io::stdout().lock();
let _ = out.write_all(&buf);
let _ = out.flush();
}
pub(crate) fn write_stdout(buf: &[u8], what: &str) -> bool {
use std::io::Write as _;
if buf.is_empty() {
return true;
}
let mut out = std::io::stdout().lock();
if let Err(e) = out.write_all(buf) {
error!("[kitty] {what} stdout write failed: {e}");
return false;
}
if let Err(e) = out.flush() {
error!("[kitty] {what} stdout flush failed: {e}");
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_scale_is_clamped_both_ways() {
let too_small = KittyConfig {
text_scale: 0.1,
..default()
};
assert_eq!(too_small.clamped_text_scale(), 1.0);
let too_big = KittyConfig {
text_scale: 99.0,
..default()
};
assert_eq!(too_big.clamped_text_scale(), 16.0);
let ok = KittyConfig {
text_scale: 4.0,
..default()
};
assert_eq!(ok.clamped_text_scale(), 4.0);
}
#[test]
fn frame_mode_never_scales_its_target() {
let cfg = KittyConfig {
mode: KittyMode::Frame,
text_scale: 4.0,
..default()
};
assert_eq!(cfg.target_scale_factor(), 1.0);
let cfg = KittyConfig {
mode: KittyMode::Sprite,
text_scale: 4.0,
..default()
};
assert_eq!(cfg.target_scale_factor(), 4.0);
}
#[test]
fn default_is_sprite_mode_at_320x180() {
let cfg = KittyConfig::default();
assert_eq!(cfg.mode, KittyMode::Sprite);
assert_eq!(cfg.virtual_size, UVec2::new(320, 180));
}
}