#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![forbid(unsafe_code)]
mod cli;
mod dataset_read;
mod export_stack;
mod file_dialog;
mod file_write;
mod font_scan;
mod label_fonts;
#[cfg(test)]
mod lifecycle_tests;
mod project_file;
mod provider_watch;
mod range_file;
mod range_http;
mod session;
mod tile_http;
use cli::{Startup, USAGE};
use dataset_read::{drain_dropped_paths, open_startup_paths};
use file_dialog::{Ask, PathPrompt, PromptOutcome};
use file_write::PendingFileWrite;
use label_fonts::{
ScannedFont, cjk_bold_paths, cjk_regular_paths, drain_cjk_font, install_label_fonts,
start_cjk_font_scan,
};
use oxigis_core::ArchiveFormat;
use oxigis_ui::{
ArchiveLayerConfig, ArchiveProbe, ArchiveTileProvider, ArchiveTileTransport, BasemapConfig,
BoxedTileProvider, CogLayerConfig, CogTileProvider, LocalLayerOp, MemoryRangeTransport,
RangeTransport, VectorTileConfig, VectorTileProvider, XyzTileProvider, mbtiles::MbTilesReader,
};
use provider_watch::{
InstalledProvider, InstalledVectorSource, ProviderWatch, RasterWatch, SharedArchive, SharedCog,
SharedVector, SharedXyz, VectorWatch,
};
struct OxigisDesktopApp {
inner: oxigis_ui::OxigisApp,
fonts_installed: bool,
cjk_font: Option<std::sync::mpsc::Receiver<ScannedFont>>,
print_job: Option<PrintJob>,
startup_paths: Vec<std::path::PathBuf>,
provider_watch: ProviderWatch,
session: session::SessionState,
path_prompt: Option<PathPrompt>,
pending_pdf: Option<Box<oxigis_ui::print::PrintRequest>>,
pending_project_save: Option<oxigis_ui::ProjectSaveRequest>,
pending_file_write: Option<PendingFileWrite>,
window_title: String,
observed_geometry: Option<session::WindowGeometry>,
observed_maximized: bool,
geometry_rescued: bool,
closing: bool,
}
struct PrintJob {
result: std::sync::mpsc::Receiver<Result<ExportReport, String>>,
path: std::path::PathBuf,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ExportReport {
missing_raster: usize,
missing_vector: usize,
total: usize,
}
impl ExportReport {
fn status(&self, path: &std::path::Path) -> String {
let mut message = format!("PDF exported to {}.", path.display());
if self.missing_raster > 0 {
message.push_str(&format!(
" {} of {} basemap tiles were missing (drawn gray).",
self.missing_raster, self.total,
));
}
if self.missing_vector > 0 {
message.push_str(&format!(
" {} of {} vector tiles were missing.",
self.missing_vector, self.total,
));
}
message
}
}
fn build_tile_provider(config: &BasemapConfig, ctx: &egui::Context) -> Option<InstalledProvider> {
let transport = match tile_http::HttpTileTransport::new() {
Ok(transport) => transport,
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: could not start the tile worker pool");
return None;
}
};
match XyzTileProvider::new(config, ctx, Box::new(transport)) {
Ok(provider) => {
tracing::info!(
template = config.url_template,
user_agent = tile_http::HttpTileTransport::user_agent(),
"OxiGIS desktop: XYZ basemap attached",
);
let provider = std::sync::Arc::new(provider);
Some(InstalledProvider {
provider: Box::new(SharedXyz(std::sync::Arc::clone(&provider))),
watch: RasterWatch::basemap(provider),
})
}
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: invalid basemap URL template");
None
}
}
}
fn build_cog_provider(
cog: &CogLayerConfig,
basemap: Option<&BasemapConfig>,
ctx: &egui::Context,
) -> Option<InstalledProvider> {
let transport = match range_http::HttpRangeTransport::new() {
Ok(transport) => transport,
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: could not start the COG worker pool");
return None;
}
};
let provider = match CogTileProvider::new(cog, ctx, Box::new(transport)) {
Ok(provider) => provider,
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: could not build the COG provider");
return None;
}
};
tracing::info!(url = cog.url, "OxiGIS desktop: COG layer attached");
let (base, basemap) = split_base(basemap.and_then(|config| build_tile_provider(config, ctx)));
let provider = match base {
Some(base) => provider.with_base(base),
None => provider,
};
let provider = std::sync::Arc::new(provider);
Some(InstalledProvider {
provider: Box::new(SharedCog(std::sync::Arc::clone(&provider))),
watch: RasterWatch::cog(provider, basemap),
})
}
fn split_base(
base: Option<InstalledProvider>,
) -> (
Option<BoxedTileProvider>,
Option<std::sync::Arc<XyzTileProvider>>,
) {
match base {
Some(base) => (Some(base.provider), base.watch.into_basemap()),
None => (None, None),
}
}
fn build_archive_transport(
config: &ArchiveLayerConfig,
bytes: Option<std::sync::Arc<[u8]>>,
) -> Option<Box<dyn RangeTransport>> {
if let Some(bytes) = bytes {
return Some(Box::new(MemoryRangeTransport::from_shared(bytes)));
}
if config.is_local() {
return match range_file::FileRangeTransport::new() {
Ok(transport) => Some(Box::new(transport)),
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: could not start the archive worker pool");
None
}
};
}
match range_http::HttpRangeTransport::new() {
Ok(transport) => Some(Box::new(transport)),
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: could not start the archive worker pool");
None
}
}
}
fn archive_length(
config: &ArchiveLayerConfig,
bytes: Option<&std::sync::Arc<[u8]>>,
) -> Option<u64> {
if let Some(bytes) = bytes {
return u64::try_from(bytes.len()).ok();
}
if !config.is_local() {
return None;
}
std::fs::metadata(config.location())
.ok()
.map(|metadata| metadata.len())
}
fn build_archive_provider(
config: &ArchiveLayerConfig,
basemap: Option<&BasemapConfig>,
ctx: &egui::Context,
bytes: Option<std::sync::Arc<[u8]>>,
reader: Option<std::sync::Arc<MbTilesReader>>,
) -> Option<InstalledProvider> {
let length = archive_length(config, bytes.as_ref());
let built = match config.format {
ArchiveFormat::MbTiles => match reader {
Some(reader) => ArchiveTileProvider::mbtiles(config.location().to_owned(), reader, ctx),
None => {
let transport = build_archive_transport(config, bytes)?;
ArchiveTileProvider::paged_mbtiles(
config.location().to_owned(),
ctx,
transport,
length,
)
}
},
ArchiveFormat::PmTiles => {
let transport = build_archive_transport(config, bytes)?;
ArchiveTileProvider::pmtiles(config.location().to_owned(), ctx, transport)
}
};
let provider = match built {
Ok(provider) => provider,
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: could not build the archive provider");
return None;
}
};
tracing::info!(
archive = config.location(),
"OxiGIS desktop: raster tile archive attached",
);
let (base, basemap) = split_base(basemap.and_then(|config| build_tile_provider(config, ctx)));
let provider = match base {
Some(base) => provider.with_base(base),
None => provider,
};
let provider = std::sync::Arc::new(provider);
Some(InstalledProvider {
provider: Box::new(SharedArchive(std::sync::Arc::clone(&provider))),
watch: RasterWatch::archive(provider, basemap),
})
}
fn build_vector_source(
config: &VectorTileConfig,
ctx: &egui::Context,
bytes: Option<std::sync::Arc<[u8]>>,
reader: Option<std::sync::Arc<MbTilesReader>>,
) -> Option<InstalledVectorSource> {
let archive_transport = match config.archive.as_ref() {
Some(archive) if archive.format == ArchiveFormat::MbTiles => Some(match reader {
Some(reader) => ArchiveTileTransport::mbtiles(archive.location().to_owned(), reader),
None => {
let length = archive_length(archive, bytes.as_ref());
ArchiveTileTransport::paged_mbtiles(
archive.location().to_owned(),
build_archive_transport(archive, bytes)?,
length,
)
}
}),
Some(archive) => Some(ArchiveTileTransport::pmtiles(
archive.location().to_owned(),
build_archive_transport(archive, bytes)?,
)),
None => None,
};
let transport: Box<dyn oxigis_ui::TileTransport> = match archive_transport.clone() {
Some(transport) => Box::new(transport),
None => match tile_http::HttpTileTransport::new() {
Ok(transport) => Box::new(transport),
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: could not start the vector worker pool");
return None;
}
},
};
match VectorTileProvider::new(config, ctx, transport) {
Ok(provider) => {
tracing::info!(
template = config.url_template,
rules = config.paints.len(),
"OxiGIS desktop: vector tile layer attached",
);
let provider = std::sync::Arc::new(provider);
Some(InstalledVectorSource {
source: Box::new(SharedVector(std::sync::Arc::clone(&provider))),
watch: VectorWatch::new(provider, archive_transport),
})
}
Err(error) => {
tracing::error!(%error, "OxiGIS desktop: invalid vector tile URL template");
None
}
}
}
fn build_stack_source(
plan: &oxigis_ui::TileLayerPlan,
ctx: &egui::Context,
bytes: Option<std::sync::Arc<[u8]>>,
reader: Option<std::sync::Arc<MbTilesReader>>,
) -> Result<(oxigis_ui::TileLayerGpuSource, provider_watch::StackWatch), String> {
match &plan.source {
oxigis_ui::TileLayerSource::Cog(config) => build_cog_provider(config, None, ctx)
.map(|built| {
(
oxigis_ui::TileLayerGpuSource::Raster(built.provider),
provider_watch::StackWatch::Raster(built.watch),
)
})
.ok_or_else(|| "the COG provider could not be built".to_owned()),
oxigis_ui::TileLayerSource::RasterArchive(config) => {
build_archive_provider(config, None, ctx, bytes, reader)
.map(|built| {
(
oxigis_ui::TileLayerGpuSource::Raster(built.provider),
provider_watch::StackWatch::Raster(built.watch),
)
})
.ok_or_else(|| "the tile archive could not be opened".to_owned())
}
oxigis_ui::TileLayerSource::Xyz(config) => build_tile_provider(config, ctx)
.map(|built| {
(
oxigis_ui::TileLayerGpuSource::Raster(built.provider),
provider_watch::StackWatch::Raster(built.watch),
)
})
.ok_or_else(|| "the XYZ tile provider could not be built".to_owned()),
oxigis_ui::TileLayerSource::Vector(config) => {
build_vector_source(config, ctx, bytes, reader)
.map(|built| {
(
oxigis_ui::TileLayerGpuSource::Vector(built.source),
provider_watch::StackWatch::Vector(built.watch),
)
})
.ok_or_else(|| "the vector tile source could not be built".to_owned())
}
}
}
fn stack_archive_data(
app: &oxigis_ui::OxigisApp,
plan: &oxigis_ui::TileLayerPlan,
) -> (
Option<std::sync::Arc<[u8]>>,
Option<std::sync::Arc<MbTilesReader>>,
) {
let location = match &plan.source {
oxigis_ui::TileLayerSource::RasterArchive(config) => Some(config.location()),
oxigis_ui::TileLayerSource::Vector(config) => {
config.archive.as_ref().map(ArchiveLayerConfig::location)
}
oxigis_ui::TileLayerSource::Cog(_) | oxigis_ui::TileLayerSource::Xyz(_) => None,
};
match location {
Some(location) => (app.archive_bytes(location), app.mbtiles_reader(location)),
None => (None, None),
}
}
const PRINT_TILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
const PRINT_TILE_ALLOWANCE: std::time::Duration = std::time::Duration::from_millis(300);
const PRINT_TILE_MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(90);
fn tile_budget(tiles: usize) -> std::time::Duration {
let allowance = u32::try_from(tiles)
.ok()
.and_then(|tiles| PRINT_TILE_ALLOWANCE.checked_mul(tiles))
.unwrap_or(PRINT_TILE_MAX_WAIT);
PRINT_TILE_TIMEOUT
.saturating_add(allowance)
.min(PRINT_TILE_MAX_WAIT)
}
fn export_file_name() -> String {
static EXPORT_SEQUENCE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs());
let sequence = EXPORT_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if sequence == 0 {
format!("oxigis-export-{seconds}.pdf")
} else {
format!("oxigis-export-{seconds}-{sequence}.pdf")
}
}
fn export_pdf_to(
request: &oxigis_ui::print::PrintRequest,
ctx: &egui::Context,
path: &std::path::Path,
) -> Result<ExportReport, String> {
let legacy = request.stack_fits_legacy_slots();
let provider: Option<BoxedTileProvider> = if legacy {
Some(
match (request.archive.as_ref(), request.cog.as_ref()) {
(Some(archive), _) => {
build_archive_provider(archive, Some(&request.basemap), ctx, None, None)
}
(None, Some(cog)) => build_cog_provider(cog, Some(&request.basemap), ctx),
(None, None) => build_tile_provider(&request.basemap, ctx),
}
.ok_or_else(|| "no tile provider could be built for the export".to_string())?
.provider,
)
} else {
None
};
use oxigis_ui::print::{FaceRole, PrintFace};
let mut chain = vec![PrintFace::regular(
oxifont_bundled::NOTO_SANS_REGULAR.to_vec(),
FaceRole::ScreenShared,
)];
for path in cjk_regular_paths().iter() {
if let Some(bytes) = font_scan::read_cjk_font(path) {
chain.push(PrintFace::regular(bytes, FaceRole::ScreenShared));
}
}
for path in cjk_bold_paths().iter() {
if let Some(bytes) = font_scan::read_cjk_font(path) {
chain.push(PrintFace::bold(bytes, FaceRole::ScreenShared));
}
}
let fonts = oxigis_ui::print::PrintFonts::with_roles(chain);
let map_box = oxigis_ui::print::map_box(&request.options);
let out_px = oxigis_ui::print::raster_size_px(&map_box, &request.options);
let compose = oxigis_ui::print::compose_view(request.view, out_px);
let required = oxigis_ui::print::required_tiles(&compose);
let mut report = ExportReport {
total: required.len(),
..ExportReport::default()
};
let composed = match provider.as_ref() {
Some(_) => None,
None => Some(export_stack::compose_stack(
request, ctx, &compose, &required,
)?),
};
if let Some(composed) = composed.as_ref() {
report.missing_raster = composed.missing_raster;
report.missing_vector = composed.missing_vector;
}
if let Some(provider) = provider.as_ref() {
let deadline = std::time::Instant::now() + tile_budget(required.len());
loop {
let missing = required
.iter()
.filter(|tile| provider.tile(**tile).is_none())
.count();
if missing == 0 {
break;
}
if std::time::Instant::now() >= deadline {
tracing::warn!(
missing,
total = required.len(),
"OxiGIS desktop: PDF export proceeding with missing tiles",
);
report.missing_raster = missing;
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
let mut vector_tiles: Vec<(
oxigis_render::TileId,
std::sync::Arc<oxigis_render::mvt::VectorTile>,
)> = Vec::new();
if let Some(config) = request.vector.as_ref().filter(|_| legacy)
&& let Some(installed) = build_vector_source(config, ctx, None, None)
{
let source = installed.source;
let deadline = std::time::Instant::now() + tile_budget(required.len());
loop {
let _ = source.begin_frame(compose);
let missing = required
.iter()
.filter(|tile| {
let absent = source.decoded(**tile).is_none();
if absent {
let _ = source.mesh(**tile);
}
absent
})
.count();
if missing == 0 {
break;
}
if std::time::Instant::now() >= deadline {
tracing::warn!(
missing,
total = required.len(),
"OxiGIS desktop: PDF export proceeding with missing vector tiles",
);
report.missing_vector = missing;
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
for tile in &required {
if let Some(decoded) = source.decoded(*tile) {
vector_tiles.push((*tile, decoded));
}
}
}
let (rgb, stack_tiles) = match (provider.as_ref(), composed) {
(Some(provider), _) => (
oxigis_ui::print::compose_map_rgb(&compose, &mut |tile| provider.tile(tile)),
Vec::new(),
),
(None, Some(composed)) => (composed.rgb, composed.vector),
(None, None) => (
vec![oxigis_ui::print::MISSING_TILE_GRAY; out_px[0] as usize * out_px[1] as usize * 3],
Vec::new(),
),
};
let pdf = oxigis_ui::print::pdf_document_with(
request,
&compose,
&rgb,
out_px,
&fonts,
&oxigis_ui::print::PrintVectorTiles {
single: &vector_tiles,
stack: &stack_tiles,
},
)?;
std::fs::write(path, &pdf)
.map_err(|error| format!("could not write {}: {error}", path.display()))?;
tracing::info!(path = %path.display(), bytes = pdf.len(), "OxiGIS desktop: PDF exported");
Ok(report)
}
fn apply_local_ops(
app: &mut oxigis_ui::OxigisApp,
render_state: Option<&eframe::egui_wgpu::RenderState>,
) {
for op in app.take_pending_local_ops() {
let Some(render_state) = render_state else {
tracing::error!(
?op,
"OxiGIS desktop: the GPU map is not attached, so the local layer op was discarded",
);
continue;
};
let applied = match op {
LocalLayerOp::Add(id, layer) => {
oxigis_ui::map_gpu::add_local_vector_layer(render_state, id, *layer)
}
LocalLayerOp::Remove(id) => {
oxigis_ui::map_gpu::remove_local_vector_layer(render_state, id).is_some()
}
LocalLayerOp::SetVisibility(id, visible) => {
oxigis_ui::map_gpu::set_local_layer_visibility(render_state, id, visible)
}
LocalLayerOp::SetOpacity(id, opacity) => {
oxigis_ui::map_gpu::set_local_layer_opacity(render_state, id, opacity)
}
LocalLayerOp::SetStyle(id, style) => {
oxigis_ui::map_gpu::set_local_layer_style(render_state, id, style)
}
LocalLayerOp::Reorder(order) => {
oxigis_ui::map_gpu::reorder_local_vector_layers(render_state, &order)
}
LocalLayerOp::Clear => oxigis_ui::map_gpu::clear_local_vector_layers(render_state),
};
if !applied {
tracing::debug!("OxiGIS desktop: a local layer op changed nothing");
}
}
}
const BACKGROUND_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200);
impl OxigisDesktopApp {
fn new(startup_paths: Vec<std::path::PathBuf>, session: session::SessionState) -> Self {
let mut inner = oxigis_ui::OxigisApp::new();
inner.set_native_project_io(true);
inner.set_tile_stack_shell(true);
inner.set_recent_projects(session.recent.clone());
Self {
inner,
fonts_installed: false,
cjk_font: None,
print_job: None,
startup_paths,
provider_watch: ProviderWatch::default(),
session,
path_prompt: None,
pending_pdf: None,
pending_project_save: None,
pending_file_write: None,
window_title: String::new(),
observed_geometry: None,
observed_maximized: false,
geometry_rescued: false,
closing: false,
}
}
fn background_work_pending(&self) -> bool {
self.print_job.is_some() || self.cjk_font.is_some() || self.inner.archive_probe_running()
}
fn asking_for_a_path(&self) -> bool {
self.path_prompt.is_some()
}
fn dialog_directory(&self) -> std::path::PathBuf {
let last = self.session.last_directory.clone().or_else(|| {
self.inner
.project_path()
.and_then(std::path::Path::parent)
.map(std::path::Path::to_path_buf)
});
file_dialog::default_directory(last.as_deref())
}
fn ask_for_path(&mut self, ask: Ask, suggested_name: &str) -> Option<std::path::PathBuf> {
let directory = self.dialog_directory();
if file_dialog::NATIVE_DIALOGS {
return file_dialog::ask_native(ask, suggested_name, Some(&directory));
}
self.path_prompt = Some(PathPrompt::new(ask, suggested_name, directory));
None
}
fn note_dialog_directory(&mut self, path: &std::path::Path) {
if let Some(parent) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
self.session.last_directory = Some(parent.to_path_buf());
}
}
fn start_pdf_export(&mut self, request: oxigis_ui::print::PrintRequest, ctx: &egui::Context) {
if self.print_job.is_some() || self.pending_pdf.is_some() {
self.inner
.set_status("A PDF export is already running — wait for it to finish.".to_string());
return;
}
if self.asking_for_a_path() {
self.inner
.set_status("Finish the open file prompt first.".to_string());
return;
}
let name = export_file_name();
match self.ask_for_path(Ask::SavePdf, &name) {
Some(path) => self.export_pdf_to_path(request, path, ctx),
None if self.path_prompt.is_some() => {
self.pending_pdf = Some(Box::new(request));
}
None => self.inner.set_status("PDF export cancelled.".to_string()),
}
}
fn export_pdf_to_path(
&mut self,
request: oxigis_ui::print::PrintRequest,
path: std::path::PathBuf,
ctx: &egui::Context,
) {
let path = project_file::with_pdf_extension(&path);
self.note_dialog_directory(&path);
let (tx, rx) = std::sync::mpsc::channel();
let thread_ctx = ctx.clone();
let thread_path = path.clone();
let spawned = std::thread::Builder::new()
.name("oxigis-pdf-export".to_owned())
.spawn(move || {
let result = export_pdf_to(&request, &thread_ctx, &thread_path);
let _ = tx.send(result);
thread_ctx.request_repaint();
});
match spawned {
Ok(_handle) => {
self.inner
.set_status(format!("Exporting PDF to {}…", path.display()));
self.print_job = Some(PrintJob { result: rx, path });
}
Err(error) => {
self.inner.set_status(format!(
"PDF export failed: could not start a thread: {error}"
));
}
}
}
fn poll_print_job(&mut self) {
let Some(job) = self.print_job.as_ref() else {
return;
};
let outcome = match job.result.try_recv() {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Ok(Ok(report)) => report.status(&job.path),
Ok(Err(error)) => format!("PDF export failed: {error}"),
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
"PDF export failed: the export thread died.".to_string()
}
};
self.inner.set_status(outcome);
self.print_job = None;
}
fn poll_path_prompt(&mut self, ctx: &egui::Context) {
let Some(prompt) = self.path_prompt.as_mut() else {
return;
};
let ask = prompt.ask();
match prompt.show(ctx) {
PromptOutcome::Pending => {}
PromptOutcome::Cancelled => {
self.path_prompt = None;
self.pending_pdf = None;
match ask {
Ask::SaveProject | Ask::OpenProject => {
self.pending_project_save = None;
self.inner.cancel_pending_project_io();
}
Ask::SavePdf => self.inner.set_status("PDF export cancelled."),
Ask::OpenArchive => self.inner.set_status("No archive was opened."),
Ask::SaveGeoJson | Ask::SaveCsv => {
if let Some(write) = self.pending_file_write.take() {
self.report_file_write_cancelled(&write, "Nothing was exported.");
}
}
}
}
PromptOutcome::Accepted(path) => {
if self.deliver_path(ask, path, ctx) {
self.path_prompt = None;
}
}
}
}
fn deliver_path(&mut self, ask: Ask, path: std::path::PathBuf, ctx: &egui::Context) -> bool {
match ask {
Ask::SavePdf => {
match self.pending_pdf.take() {
Some(request) => self.export_pdf_to_path(*request, path, ctx),
None => self
.inner
.set_status("The PDF export was no longer waiting for a destination."),
}
true
}
Ask::SaveProject => match self.pending_project_save.take() {
Some(request) => {
let written = self.write_project(&request.content, path);
if !written {
self.pending_project_save = Some(request);
}
written
}
None => {
self.inner
.set_status("The project save was no longer waiting for a destination.");
true
}
},
Ask::SaveGeoJson | Ask::SaveCsv => match self.pending_file_write.take() {
Some(write) => self.write_data_file(write, path),
None => {
self.inner
.set_status("The export was no longer waiting for a destination.");
true
}
},
Ask::OpenProject => self.read_project(path),
Ask::OpenArchive => {
self.open_archive(path);
true
}
}
}
fn resolve_project_save(&mut self, request: oxigis_ui::ProjectSaveRequest) {
if let Some(path) = request.path.clone() {
let _written = self.write_project(&request.content, path);
return;
}
if self.asking_for_a_path() {
self.inner.cancel_pending_project_io();
self.inner
.set_status("Finish the file prompt that is already open first.".to_string());
return;
}
let suggested = project_file::suggested_file_name(&self.inner.project().name);
match self.ask_for_path(Ask::SaveProject, &suggested) {
Some(path) => {
let _written = self.write_project(&request.content, path);
}
None if self.path_prompt.is_some() => self.pending_project_save = Some(request),
None => self.inner.cancel_pending_project_io(),
}
}
fn write_project(&mut self, content: &str, path: std::path::PathBuf) -> bool {
let path = project_file::with_project_extension(&path);
match project_file::write_project_atomically(&path, content) {
Ok(()) => {
tracing::info!(
path = %path.display(),
bytes = content.len(),
"OxiGIS desktop: project saved",
);
self.note_dialog_directory(&path);
self.session.note_recent(path.clone());
self.inner.confirm_project_saved(path);
self.session.recent = self.inner.recent_projects().to_vec();
true
}
Err(error) => {
tracing::error!(path = %path.display(), %error, "OxiGIS desktop: project save failed");
self.inner.report_project_save_failed(&error);
if let Some(prompt) = self.path_prompt.as_mut() {
prompt.reopen_with_error(error);
}
false
}
}
}
fn resolve_project_open(&mut self, request: oxigis_ui::ProjectOpenRequest) {
if let Some(path) = request.path {
let _opened = self.read_project(path);
return;
}
if self.asking_for_a_path() {
self.inner.cancel_pending_project_io();
self.inner
.set_status("Finish the file prompt that is already open first.".to_string());
return;
}
let suggested = project_file::suggested_file_name(&self.inner.project().name);
match self.ask_for_path(Ask::OpenProject, &suggested) {
Some(path) => {
let _opened = self.read_project(path);
}
None if self.path_prompt.is_some() => {}
None => self.inner.cancel_pending_project_io(),
}
}
fn read_project(&mut self, path: std::path::PathBuf) -> bool {
let text = match project_file::read_project_text(&path) {
Ok(text) => text,
Err(error) => {
tracing::error!(path = %path.display(), %error, "OxiGIS desktop: project open failed");
self.inner.set_status(format!("Could not open: {error}"));
if let Some(prompt) = self.path_prompt.as_mut() {
prompt.reopen_with_error(error);
}
return false;
}
};
if !self.inner.load_project_from_text(&text) {
if let Some(reason) = self.inner.status().map(str::to_owned)
&& let Some(prompt) = self.path_prompt.as_mut()
{
prompt.reopen_with_error(reason);
}
return false;
}
tracing::info!(path = %path.display(), "OxiGIS desktop: project opened");
self.note_dialog_directory(&path);
self.session.note_recent(path.clone());
self.inner.set_project_path(Some(path));
self.session.recent = self.inner.recent_projects().to_vec();
true
}
fn open_archive(&mut self, path: std::path::PathBuf) {
self.note_dialog_directory(&path);
let path = path.display().to_string();
let format = oxigis_ui::format_for_url(&path);
let _accepted = self
.inner
.request_archive_probe(oxigis_core::ArchiveRef::Path { path }, format);
}
fn intercept_close(&mut self, ctx: &egui::Context) {
if self.closing {
return;
}
if self.inner.take_confirmed_close() {
self.closing = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
return;
}
if !ctx.input(|input| input.viewport().close_requested()) {
return;
}
self.inner.request_window_close();
if self.inner.take_confirmed_close() {
self.closing = true;
return;
}
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
fn sync_window_title(&mut self, ctx: &egui::Context) {
let title = self.inner.window_title();
if title == self.window_title {
return;
}
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.window_title = title;
}
fn observe_geometry(&mut self, ctx: &egui::Context) {
let (rect, monitor, maximized, minimized) = ctx.input(|input| {
let viewport = input.viewport();
(
viewport.outer_rect.or(viewport.inner_rect),
viewport.monitor_size,
viewport.maximized.unwrap_or(false),
viewport.minimized.unwrap_or(false),
)
});
self.rescue_offscreen_window(ctx, rect, monitor);
self.observed_maximized = maximized;
if maximized || minimized {
return;
}
let Some(rect) = rect else {
return;
};
let geometry = session::WindowGeometry {
x: rect.min.x,
y: rect.min.y,
width: rect.width(),
height: rect.height(),
};
if let Some(sane) = geometry.sanitized() {
self.observed_geometry = Some(sane);
}
}
fn rescue_offscreen_window(
&mut self,
ctx: &egui::Context,
rect: Option<egui::Rect>,
monitor: Option<egui::Vec2>,
) {
if self.geometry_rescued {
return;
}
let (Some(rect), Some(monitor)) = (rect, monitor) else {
return;
};
self.geometry_rescued = true;
let geometry = session::WindowGeometry {
x: rect.min.x,
y: rect.min.y,
width: rect.width(),
height: rect.height(),
};
if geometry.is_reachable_on([monitor.x, monitor.y]) {
return;
}
let x = ((monitor.x - geometry.width) / 2.0).max(0.0);
let y = ((monitor.y - geometry.height) / 2.0).max(0.0);
tracing::info!(
from = ?(geometry.x, geometry.y),
to = ?(x, y),
"OxiGIS desktop: the remembered window position is off every monitor; recentring",
);
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(x, y)));
}
}
impl eframe::App for OxigisDesktopApp {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let mut attached = None;
self.inner
.attach_gpu_map_using(frame.wgpu_render_state(), |config| {
let built = build_tile_provider(config, ctx)?;
attached = Some(built.watch);
Some(built.provider)
});
if let Some(watch) = attached {
self.provider_watch.install_raster(watch);
}
if !self.startup_paths.is_empty() {
let paths = core::mem::take(&mut self.startup_paths);
open_startup_paths(&mut self.inner, paths);
}
if let Some(render_state) = frame.wgpu_render_state() {
if !self.fonts_installed {
self.fonts_installed = install_label_fonts(render_state);
if self.fonts_installed {
self.cjk_font = start_cjk_font_scan(ctx);
}
}
if let Some(rx) = self.cjk_font.as_ref()
&& drain_cjk_font(render_state, rx, ctx)
{
self.cjk_font = None;
}
}
if let Some(render_state) = frame.wgpu_render_state() {
if let Some(work) = self.inner.pending_raster_work() {
let outcome = match build_tile_provider(&work.basemap, ctx) {
Some(built) => {
if oxigis_ui::map_gpu::replace_provider(render_state, built.provider) {
self.provider_watch.install_raster(built.watch);
Ok(())
} else {
Err("the GPU map is not attached".to_string())
}
}
None => Err("the tile provider could not be built".to_string()),
};
self.inner.settle_raster_work(work, outcome);
}
let installed = oxigis_ui::map_gpu::installed_tile_stack(render_state);
if let Some(work) = self.inner.tile_stack_work(&installed) {
match work {
oxigis_ui::TileStackWork::Install(plan) => {
let (bytes, reader) = stack_archive_data(&self.inner, &plan);
let layer = plan.layer;
let (source, watch) = match build_stack_source(&plan, ctx, bytes, reader) {
Ok((source, watch)) => (Ok(source), Some(watch)),
Err(reason) => (Err(reason), None),
};
let outcome =
oxigis_ui::map_gpu::install_tile_layer(render_state, plan, source);
match outcome {
Ok(()) => {
if let Some(watch) = watch {
self.provider_watch.install_stack(layer, watch);
}
}
Err(reason) => {
self.provider_watch.remove_stack(&[layer]);
self.inner.set_status(reason);
}
}
}
oxigis_ui::TileStackWork::Remove(layers) => {
let _ = oxigis_ui::map_gpu::remove_tile_layers(render_state, &layers);
self.provider_watch.remove_stack(&layers);
}
oxigis_ui::TileStackWork::Reorder(order) => {
let _ = oxigis_ui::map_gpu::reorder_tile_layers(render_state, &order);
}
}
}
let app = &self.inner;
oxigis_ui::map_gpu::sync_tile_layer_opacities(render_state, |id| {
app.tile_layer_opacity(id)
});
self.inner
.set_tile_layer_refusals(oxigis_ui::map_gpu::tile_layer_refusals(render_state));
if self.inner.take_tile_layer_retry() {
let dropped = oxigis_ui::map_gpu::retry_refused_tile_layers(render_state);
if dropped > 0 {
tracing::info!(dropped, "OxiGIS desktop: refused stack entries re-offered");
}
}
}
if self.inner.take_pending_archive_pick() {
if self.asking_for_a_path() {
self.inner
.set_status("Finish the open file prompt first.".to_string());
} else {
match self.ask_for_path(Ask::OpenArchive, "") {
Some(path) => self.open_archive(path),
None if self.path_prompt.is_some() => {}
None => self.inner.set_status("No archive was opened."),
}
}
}
if let Some(request) = self.inner.take_pending_archive_probe() {
let config = ArchiveLayerConfig::new(request.archive.clone(), request.format);
let bytes = self.inner.archive_bytes(config.location());
match build_archive_transport(&config, bytes) {
Some(transport) => {
self.inner.attach_archive_probe(ArchiveProbe::start(
config.location().to_owned(),
request.format,
ctx,
transport,
));
}
None => self
.inner
.set_status("The archive could not be read: no worker thread started."),
}
}
let _created = self.inner.poll_archive_probe();
self.poll_print_job();
if let Some(request) = self.inner.take_pending_print() {
self.start_pdf_export(request, ctx);
}
drain_dropped_paths(&mut self.inner);
apply_local_ops(&mut self.inner, frame.wgpu_render_state());
if let Some(message) = self.provider_watch.poll() {
self.inner.set_status(message);
}
if let Some(request) = self.inner.take_pending_project_save() {
self.resolve_project_save(request);
}
if let Some(request) = self.inner.take_pending_project_open() {
self.resolve_project_open(request);
}
if let Some(request) = self.inner.take_pending_processing_save() {
self.resolve_file_write(PendingFileWrite::Processing(request));
}
if let Some(request) = self.inner.take_pending_export() {
self.resolve_file_write(PendingFileWrite::Export(request));
}
self.sync_window_title(ctx);
self.observe_geometry(ctx);
self.intercept_close(ctx);
if self.background_work_pending() {
ctx.request_repaint_after(BACKGROUND_POLL_INTERVAL);
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.inner.ui(ui);
let ctx = ui.ctx().clone();
self.poll_path_prompt(&ctx);
}
fn on_exit(&mut self) {
self.session.window = self.observed_geometry;
self.session.maximized = self.observed_maximized;
self.session.recent = self.inner.recent_projects().to_vec();
match session::store(&self.session) {
Ok(()) => tracing::info!("OxiGIS desktop: session saved"),
Err(error) => tracing::warn!(%error, "OxiGIS desktop: could not save the session"),
}
}
}
const APP_ID: &str = "io.cooljapan.oxigis";
fn viewport_builder(session: &session::SessionState) -> egui::ViewportBuilder {
let mut viewport = egui::ViewportBuilder::default()
.with_title("OxiGIS")
.with_app_id(APP_ID);
match session.window.and_then(session::WindowGeometry::sanitized) {
Some(window) => {
viewport = viewport
.with_inner_size(egui::vec2(window.width, window.height))
.with_position(egui::pos2(window.x, window.y));
}
None => {
viewport = viewport.with_inner_size(egui::vec2(
session::DEFAULT_WINDOW_SIZE[0],
session::DEFAULT_WINDOW_SIZE[1],
));
}
}
if session.maximized {
viewport = viewport.with_maximized(true);
}
viewport
}
fn main() -> std::process::ExitCode {
let (paths, log_file) = match cli::parse_args(std::env::args_os().skip(1)) {
Startup::Run { paths, log_file } => (paths, log_file),
Startup::Help => {
println!("{USAGE}");
return std::process::ExitCode::SUCCESS;
}
Startup::Version => {
println!("{}", cli::version_line());
return std::process::ExitCode::SUCCESS;
}
Startup::Refused(complaint) => {
eprintln!("oxigis: {complaint}\n\n{USAGE}");
return std::process::ExitCode::FAILURE;
}
};
cli::init_logging(log_file.as_deref());
tracing::info!(
core = oxigis_core::VERSION,
render = oxigis_render::VERSION,
ui = oxigis_ui::VERSION,
paths = paths.len(),
"OxiGIS desktop shell starting",
);
let session = session::load();
let native_options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu,
viewport: viewport_builder(&session),
..Default::default()
};
let mut startup = Some((paths, session));
let run_result = eframe::run_native(
"OxiGIS",
native_options,
Box::new(move |_creation_context| {
let (paths, session) = startup.take().unwrap_or_default();
Ok(Box::new(OxigisDesktopApp::new(paths, session)))
}),
);
match run_result {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(error) => {
tracing::error!(%error, "OxiGIS desktop shell exited with an error");
std::process::ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "network: fetches real tiles from tile.openstreetmap.org and writes a PDF"]
fn live_pdf_export_round_trip() {
let view = oxigis_render::MapView::new(
oxigis_render::LonLat::new(139.767, 35.681),
10.0,
[1024.0, 768.0],
)
.expect("a valid viewport");
let request = oxigis_ui::print::PrintRequest {
stack: Vec::new(),
title: "OxiGIS live export — 東京".to_string(),
attribution: oxigis_ui::OSM_ATTRIBUTION.to_string(),
view,
basemap: oxigis_ui::BasemapConfig::openstreetmap(),
cog: None,
archive: None,
vector: None,
layers: Vec::new(),
options: oxigis_ui::print::PrintOptions::default(),
};
let ctx = egui::Context::default();
let path = std::env::temp_dir().join(export_file_name());
export_pdf_to(&request, &ctx, &path).expect("the export must succeed");
let bytes = std::fs::read(&path).expect("the exported file exists");
let _removed = std::fs::remove_file(&path);
assert!(bytes.starts_with(b"%PDF-"), "the magic bytes lead");
assert!(
bytes.len() > 50_000,
"a real Tokyo basemap image is not {} bytes",
bytes.len()
);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.matches("/Subtype /Type0").count() >= 2,
"expected the Latin face AND a system CJK face to be embedded"
);
assert!(text.contains("/Encoding /Identity-H"));
assert!(text.contains("/FontFile2"), "a subset program is embedded");
}
#[test]
#[ignore = "network: fetches real tiles from demotiles.maplibre.org and OSM, writes a PDF"]
fn live_pdf_export_with_vector_tiles() {
let view = oxigis_render::MapView::new(
oxigis_render::LonLat::new(10.0, 45.0),
3.0,
[1024.0, 768.0],
)
.expect("a valid viewport");
let request = oxigis_ui::print::PrintRequest {
stack: Vec::new(),
title: "OxiGIS vector live export".to_string(),
attribution: oxigis_ui::MAPLIBRE_ATTRIBUTION.to_string(),
view,
basemap: oxigis_ui::BasemapConfig::openstreetmap(),
cog: None,
archive: None,
vector: Some(oxigis_ui::VectorTileConfig::maplibre_demo()),
layers: Vec::new(),
options: oxigis_ui::print::PrintOptions::default(),
};
let ctx = egui::Context::default();
let path = std::env::temp_dir().join(export_file_name());
export_pdf_to(&request, &ctx, &path).expect("the export must succeed");
let bytes = std::fs::read(&path).expect("the exported file exists");
let _removed = std::fs::remove_file(&path);
let text = String::from_utf8_lossy(&bytes);
assert!(
text.contains("/GV0"),
"the vector rules' alpha states must be registered — were tiles drawn?"
);
}
#[test]
fn the_cjk_chains_are_resolved_once() {
assert!(std::sync::Arc::ptr_eq(
&cjk_regular_paths(),
&cjk_regular_paths()
));
assert!(std::sync::Arc::ptr_eq(&cjk_bold_paths(), &cjk_bold_paths()));
}
#[test]
fn the_export_status_names_what_is_missing() {
let path = std::path::Path::new("map.pdf");
let clean = ExportReport {
missing_raster: 0,
missing_vector: 0,
total: 12,
};
assert_eq!(clean.status(path), "PDF exported to map.pdf.");
let short = ExportReport {
missing_raster: 3,
missing_vector: 2,
total: 12,
};
let status = short.status(path);
assert!(status.contains("3 of 12 basemap tiles"), "{status}");
assert!(status.contains("2 of 12 vector tiles"), "{status}");
}
#[test]
fn the_tile_budget_grows_with_the_page_and_then_stops() {
assert_eq!(tile_budget(0), PRINT_TILE_TIMEOUT);
assert_eq!(
tile_budget(100),
PRINT_TILE_TIMEOUT + PRINT_TILE_ALLOWANCE * 100,
);
assert!(tile_budget(12) > tile_budget(4));
assert_eq!(tile_budget(100_000), PRINT_TILE_MAX_WAIT);
assert_eq!(tile_budget(usize::MAX), PRINT_TILE_MAX_WAIT);
}
}