package com.keenwrite;
import com.keenwrite.editors.TextDefinition;
import com.keenwrite.editors.TextEditor;
import com.keenwrite.editors.TextResource;
import com.keenwrite.editors.common.ScrollEventHandler;
import com.keenwrite.editors.common.VariableNameInjector;
import com.keenwrite.editors.definition.DefinitionEditor;
import com.keenwrite.editors.definition.TreeTransformer;
import com.keenwrite.editors.definition.yaml.YamlTreeTransformer;
import com.keenwrite.editors.markdown.MarkdownEditor;
import com.keenwrite.events.*;
import com.keenwrite.io.MediaType;
import com.keenwrite.preferences.Workspace;
import com.keenwrite.preview.HtmlPreview;
import com.keenwrite.processors.HtmlPreviewProcessor;
import com.keenwrite.processors.Processor;
import com.keenwrite.processors.ProcessorContext;
import com.keenwrite.processors.ProcessorFactory;
import com.keenwrite.processors.r.Engine;
import com.keenwrite.processors.r.RBootstrapController;
import com.keenwrite.service.events.Notifier;
import com.keenwrite.ui.explorer.FilePickerFactory;
import com.keenwrite.ui.heuristics.DocumentStatistics;
import com.keenwrite.ui.outline.DocumentOutline;
import com.keenwrite.util.GenericBuilder;
import com.panemu.tiwulfx.control.dock.DetachableTab;
import com.panemu.tiwulfx.control.dock.DetachableTabPane;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.collections.ListChangeListener;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.TreeItem.TreeModificationEvent;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.FlowPane;
import javafx.stage.Stage;
import javafx.stage.Window;
import org.greenrobot.eventbus.Subscribe;
import java.io.File;
import java.io.FileNotFoundException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.keenwrite.ExportFormat.NONE;
import static com.keenwrite.Launcher.terminate;
import static com.keenwrite.Messages.get;
import static com.keenwrite.constants.Constants.*;
import static com.keenwrite.constants.GraphicsConstants.ICON_DIALOG_NODE;
import static com.keenwrite.events.Bus.register;
import static com.keenwrite.events.StatusEvent.clue;
import static com.keenwrite.io.MediaType.*;
import static com.keenwrite.preferences.AppKeys.*;
import static com.keenwrite.processors.IdentityProcessor.IDENTITY;
import static com.keenwrite.processors.ProcessorContext.Mutator;
import static com.keenwrite.processors.ProcessorContext.builder;
import static com.keenwrite.processors.ProcessorFactory.createProcessors;
import static java.lang.String.format;
import static java.lang.System.getProperty;
import static java.util.concurrent.Executors.newFixedThreadPool;
import static java.util.concurrent.Executors.newScheduledThreadPool;
import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.stream.Collectors.groupingBy;
import static javafx.application.Platform.runLater;
import static javafx.scene.control.Alert.AlertType.ERROR;
import static javafx.scene.control.ButtonType.*;
import static javafx.scene.control.TabPane.TabClosingPolicy.ALL_TABS;
import static javafx.scene.input.KeyCode.SPACE;
import static javafx.scene.input.KeyCombination.CONTROL_DOWN;
import static javafx.util.Duration.millis;
import static javax.swing.SwingUtilities.invokeLater;
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
public final class MainPane extends SplitPane {
private static final ExecutorService sExecutor = newFixedThreadPool( 1 );
private static final Notifier sNotifier = Services.load( Notifier.class );
private static final Set<MediaType> PLAIN_TEXT_FORMAT = Set.of(
TEXT_MARKDOWN, TEXT_R_MARKDOWN, UNDEFINED
);
private final ScheduledExecutorService mSaver = newScheduledThreadPool( 1 );
private final AtomicReference<ScheduledFuture<?>> mSaveTask =
new AtomicReference<>();
private final Map<TextResource, Processor<String>> mProcessors =
new HashMap<>();
private final Workspace mWorkspace;
private final List<TabPane> mTabPanes = new ArrayList<>();
private final HtmlPreview mPreview;
private final DocumentOutline mOutline = new DocumentOutline();
private final ObjectProperty<TextEditor> mTextEditor =
createActiveTextEditor();
private final ObjectProperty<TextDefinition> mDefinitionEditor;
private final EventHandler<TreeModificationEvent<Event>> mTreeHandler =
event -> {
process( getTextEditor() );
save( getTextDefinition() );
};
private byte mWindowCount;
private final VariableNameInjector mVariableNameInjector;
private final RBootstrapController mRBootstrapController;
private final DocumentStatistics mStatistics;
public MainPane( final Workspace workspace ) {
mWorkspace = workspace;
mPreview = new HtmlPreview( workspace );
mStatistics = new DocumentStatistics( workspace );
mTextEditor.set( new MarkdownEditor( workspace ) );
mDefinitionEditor = createActiveDefinitionEditor( mTextEditor );
mVariableNameInjector = new VariableNameInjector( mWorkspace );
mRBootstrapController = new RBootstrapController(
mWorkspace, this::getDefinitions );
open( collect( getRecentFiles() ) );
viewPreview();
setDividerPositions( calculateDividerPositions() );
runLater( () -> getWindow().setOnCloseRequest( event -> {
mWorkspace.save();
if( closeAll() ) {
Platform.exit();
terminate( 0 );
}
event.consume();
} ) );
register( this );
initAutosave( workspace );
}
@Subscribe
public void handle( final TextEditorFocusEvent event ) {
mTextEditor.set( event.get() );
}
@Subscribe
public void handle( final TextDefinitionFocusEvent event ) {
mDefinitionEditor.set( event.get() );
}
@Subscribe
public void handle( final FileOpenEvent event ) {
final File eventFile;
final var eventUri = event.getUri();
if( eventUri.isAbsolute() ) {
eventFile = new File( eventUri.getPath() );
}
else {
final var activeFile = getTextEditor().getFile();
final var parent = activeFile.getParentFile();
if( parent == null ) {
clue( new FileNotFoundException( eventUri.getPath() ) );
return;
}
else {
final var parentPath = parent.getAbsolutePath();
eventFile = Path.of( parentPath, eventUri.getPath() ).toFile();
}
}
runLater( () -> open( eventFile ) );
}
@Subscribe
public void handle( final CaretNavigationEvent event ) {
runLater( () -> {
final var textArea = getTextEditor().getTextArea();
textArea.moveTo( event.getOffset() );
textArea.requestFollowCaret();
textArea.requestFocus();
} );
}
@Subscribe
@SuppressWarnings( "unused" )
public void handle( final ExportFailedEvent event ) {
final var os = getProperty( "os.name" );
final var arch = getProperty( "os.arch" ).toLowerCase();
final var bits = getProperty( "sun.arch.data.model" );
final var title = Messages.get( "Alert.typesetter.missing.title" );
final var header = Messages.get( "Alert.typesetter.missing.header" );
final var version = Messages.get(
"Alert.typesetter.missing.version",
os,
arch
.replaceAll( "amd.*|i.*|x86.*", "X86" )
.replaceAll( "mips.*", "MIPS" )
.replaceAll( "armv.*", "ARM" ),
bits );
final var text = Messages.get( "Alert.typesetter.missing.installer.text" );
final var content = format( "%s %s", text, version );
final var flowPane = new FlowPane();
final var link = new Hyperlink( text );
final var label = new Label( version );
flowPane.getChildren().addAll( link, label );
final var alert = new Alert( ERROR, content, OK );
alert.setTitle( title );
alert.setHeaderText( header );
alert.getDialogPane().contentProperty().set( flowPane );
alert.setGraphic( ICON_DIALOG_NODE );
link.setOnAction( ( e ) -> {
alert.close();
final var url = Messages.get( "Alert.typesetter.missing.installer.url" );
runLater( () -> HyperlinkOpenEvent.fire( url ) );
} );
alert.showAndWait();
}
private void initAutosave( final Workspace workspace ) {
final var rate = workspace.integerProperty( KEY_EDITOR_AUTOSAVE );
rate.addListener(
( c, o, n ) -> {
final var taskRef = mSaveTask.get();
if( taskRef != null ) {
taskRef.cancel( false );
}
initAutosave( rate );
}
);
initAutosave( rate );
}
private void initAutosave( final IntegerProperty rate ) {
mSaveTask.set(
mSaver.scheduleAtFixedRate(
() -> {
if( getTextEditor().isModified() ) {
runLater( this::save );
}
}, 0, rate.intValue(), SECONDS
)
);
}
private double[] calculateDividerPositions() {
final var ratio = 100f / getItems().size() / 100;
final var positions = getDividerPositions();
for( int i = 0; i < positions.length; i++ ) {
positions[ i ] = ratio * i;
}
return positions;
}
public void open( final List<File> files ) {
files.forEach( this::open );
}
private void open( final File inputFile ) {
if( !inputFile.isFile() && inputFile.exists() ) {
return;
}
final var tab = createTab( inputFile );
final var node = tab.getContent();
final var mediaType = MediaType.valueFrom( inputFile );
final var tabPane = obtainTabPane( mediaType );
tab.setTooltip( createTooltip( inputFile ) );
tabPane.setFocusTraversable( false );
tabPane.setTabClosingPolicy( ALL_TABS );
tabPane.getTabs().add( tab );
if( !getItems().contains( tabPane ) ) {
addTabPane(
node instanceof TextDefinition ? 0 : getItems().size(), tabPane
);
}
if( inputFile.isFile() ) {
getRecentFiles().add( inputFile.getAbsolutePath() );
}
}
public void newTextEditor() {
open( DOCUMENT_DEFAULT );
}
public void newDefinitionEditor() {
open( DEFINITION_DEFAULT );
}
public void saveAll() {
mTabPanes.forEach(
tp -> tp.getTabs().forEach( tab -> {
final var node = tab.getContent();
if( node instanceof final TextEditor editor ) {
save( editor );
}
} )
);
}
public void save() {
save( getTextEditor() );
}
public void saveAs( final List<File> files ) {
assert files != null;
assert !files.isEmpty();
final var editor = getTextEditor();
final var tab = getTab( editor );
final var file = files.get( 0 );
editor.rename( file );
tab.ifPresent( t -> {
t.setText( editor.getFilename() );
t.setTooltip( createTooltip( file ) );
} );
save();
}
private void save( final TextResource resource ) {
try {
resource.save();
} catch( final Exception ex ) {
clue( ex );
sNotifier.alert(
getWindow(), resource.getPath(), "TextResource.saveFailed", ex
);
}
}
public boolean closeAll() {
var closable = true;
for( final var tabPane : mTabPanes ) {
final var tabIterator = tabPane.getTabs().iterator();
while( tabIterator.hasNext() ) {
final var tab = tabIterator.next();
final var resource = tab.getContent();
if( !(resource instanceof TextEditor) ) {
continue;
}
if( canClose( (TextEditor) resource ) ) {
tabIterator.remove();
close( tab );
}
else {
closable = false;
}
}
}
return closable;
}
private void close( final Tab tab ) {
assert tab != null;
final var handler = tab.getOnClosed();
if( handler != null ) {
handler.handle( new ActionEvent() );
}
}
public void close() {
final var editor = getTextEditor();
if( canClose( editor ) ) {
close( editor );
}
}
private void close( final TextResource resource ) {
getTab( resource ).ifPresent(
( tab ) -> {
close( tab );
tab.getTabPane().getTabs().remove( tab );
}
);
}
private boolean canClose( final TextResource editor ) {
final var editorTab = getTab( editor );
final var canClose = new AtomicBoolean( true );
if( editor.isModified() ) {
final var filename = new StringBuilder();
editorTab.ifPresent( ( tab ) -> filename.append( tab.getText() ) );
final var message = sNotifier.createNotification(
Messages.get( "Alert.file.close.title" ),
Messages.get( "Alert.file.close.text" ),
filename.toString()
);
final var dialog = sNotifier.createConfirmation( getWindow(), message );
dialog.showAndWait().ifPresent(
save -> canClose.set( save == YES ? editor.save() : save == NO )
);
}
return canClose.get();
}
private ObjectProperty<TextEditor> createActiveTextEditor() {
final var editor = new SimpleObjectProperty<TextEditor>();
editor.addListener( ( c, o, n ) -> {
if( n != null ) {
mPreview.setBaseUri( n.getPath() );
process( n );
}
} );
return editor;
}
public void viewPreview() {
viewTab( mPreview, TEXT_HTML, "Pane.preview.title" );
}
public void viewOutline() {
viewTab( mOutline, APP_DOCUMENT_OUTLINE, "Pane.outline.title" );
}
public void viewStatistics() {
viewTab( mStatistics, APP_DOCUMENT_STATISTICS, "Pane.statistics.title" );
}
public void viewFiles() {
try {
final var factory = new FilePickerFactory( getWorkspace() );
final var fileManager = factory.createModeless();
viewTab( fileManager, APP_FILE_MANAGER, "Pane.files.title" );
} catch( final Exception ex ) {
clue( ex );
}
}
private void viewTab(
final Node node, final MediaType mediaType, final String key ) {
final var tabPane = obtainTabPane( mediaType );
for( final var tab : tabPane.getTabs() ) {
if( tab.getContent() == node ) {
return;
}
}
tabPane.getTabs().add( createTab( get( key ), node ) );
addTabPane( tabPane );
}
public void viewRefresh() {
mPreview.refresh();
Engine.clear();
mRBootstrapController.update();
}
private Optional<Tab> getTab( final TextResource editor ) {
return mTabPanes.stream()
.flatMap( pane -> pane.getTabs().stream() )
.filter( tab -> editor.equals( tab.getContent() ) )
.findFirst();
}
private ObjectProperty<TextDefinition> createActiveDefinitionEditor(
final ObjectProperty<TextEditor> textEditor ) {
final var defEditor = new SimpleObjectProperty<>(
createDefinitionEditor()
);
defEditor.addListener( ( c, o, n ) -> {
final var editor = textEditor.get();
if( editor.isMediaType( TEXT_R_MARKDOWN ) ) {
mRBootstrapController.update();
}
process( editor );
} );
return defEditor;
}
private Tab createTab( final String filename, final Node node ) {
return new DetachableTab( filename, node );
}
private Tab createTab( final File file ) {
final var r = createTextResource( file );
final var tab = createTab( r.getFilename(), r.getNode() );
r.modifiedProperty().addListener(
( c, o, n ) -> tab.setText( r.getFilename() + (n ? "*" : "") )
);
tab.setOnClosed(
( __ ) -> getRecentFiles().remove( file.getAbsolutePath() )
);
tab.selectedProperty().addListener( ( c, o, n ) -> {
if( n != null && n ) {
final var pane = tab.getTabPane();
if( pane != null ) {
pane.requestFocus();
}
}
} );
tab.tabPaneProperty().addListener( ( cPane, oPane, nPane ) -> {
if( nPane != null ) {
nPane.focusedProperty().addListener( ( c, o, n ) -> {
if( n != null && n ) {
final var selected = nPane.getSelectionModel().getSelectedItem();
final var node = selected.getContent();
node.requestFocus();
}
} );
}
} );
return tab;
}
private List<File> collect( final SetProperty<String> paths ) {
final Function<MediaType, MediaType> bin =
m -> PLAIN_TEXT_FORMAT.contains( m ) ? TEXT_PLAIN : m;
final var bins = paths
.stream()
.collect(
groupingBy(
path -> bin.apply( MediaType.fromFilename( path ) ),
() -> new TreeMap<>( Enum::compareTo ),
Collectors.toList()
)
);
bins.putIfAbsent( TEXT_YAML, List.of( DEFINITION_DEFAULT.toString() ) );
bins.putIfAbsent( TEXT_PLAIN, List.of( DOCUMENT_DEFAULT.toString() ) );
final var result = new LinkedList<File>();
bins.forEach( ( mediaType, files ) -> result.addAll(
files.stream().map( File::new ).toList() )
);
return result;
}
private void process( final TextEditor editor ) {
final var task = new Task<Void>() {
@Override
public Void call() {
try {
final var p = mProcessors.getOrDefault( editor, IDENTITY );
p.apply( editor == null ? "" : editor.getText() );
} catch( final Exception ex ) {
clue( ex );
}
return null;
}
};
task.setOnSucceeded(
e -> invokeLater( () -> mPreview.scrollTo( CARET_ID ) )
);
sExecutor.execute( task );
}
private TabPane obtainTabPane( final MediaType mediaType ) {
for( final var pane : mTabPanes ) {
for( final var tab : pane.getTabs() ) {
final var node = tab.getContent();
if( node instanceof TextResource r && r.supports( mediaType ) ) {
return pane;
}
}
}
final var pane = createTabPane();
mTabPanes.add( pane );
return pane;
}
private TabPane createTabPane() {
final var tabPane = new DetachableTabPane();
initStageOwnerFactory( tabPane );
initTabListener( tabPane );
return tabPane;
}
private void initStageOwnerFactory( final DetachableTabPane tabPane ) {
tabPane.setStageOwnerFactory( ( stage ) -> {
final var title = get(
"Detach.tab.title",
((Stage) getWindow()).getTitle(), ++mWindowCount
);
stage.setTitle( title );
return getScene().getWindow();
} );
}
private void initTabListener( final TabPane tabPane ) {
tabPane.getTabs().addListener(
( final ListChangeListener.Change<? extends Tab> listener ) -> {
while( listener.next() ) {
if( listener.wasAdded() ) {
final var tabs = listener.getAddedSubList();
tabs.forEach( tab -> {
final var node = tab.getContent();
if( node instanceof TextEditor ) {
initScrollEventListener( tab );
}
} );
final var index = tabs.size() - 1;
if( index >= 0 ) {
final var tab = tabs.get( index );
tabPane.getSelectionModel().select( tab );
tab.getContent().requestFocus();
}
}
}
}
);
}
private void initScrollEventListener( final Tab tab ) {
final var editor = (TextEditor) tab.getContent();
final var scrollPane = editor.getScrollPane();
final var scrollBar = mPreview.getVerticalScrollBar();
final var handler = new ScrollEventHandler( scrollPane, scrollBar );
handler.enabledProperty().bind( tab.selectedProperty() );
}
private void addTabPane( final int index, final TabPane tabPane ) {
final var items = getItems();
if( !items.contains( tabPane ) ) {
items.add( index, tabPane );
}
}
private void addTabPane( final TabPane tabPane ) {
addTabPane( getItems().size(), tabPane );
}
private GenericBuilder<Mutator, ProcessorContext> createProcessorContextBuilder() {
final var w = getWorkspace();
return builder()
.with( Mutator::setDefinitions, this::getDefinitions )
.with( Mutator::setLocale, w::getLocale )
.with( Mutator::setMetadata, w::getMetadata )
.with( Mutator::setThemePath, w::getThemePath )
.with( Mutator::setCaret,
() -> getTextEditor().getCaret() )
.with( Mutator::setImageDir,
() -> w.getFile( KEY_IMAGES_DIR ) )
.with( Mutator::setImageOrder,
() -> w.getString( KEY_IMAGES_ORDER ) )
.with( Mutator::setImageServer,
() -> w.getString( KEY_IMAGES_SERVER ) )
.with( Mutator::setSigilBegan,
() -> w.getString( KEY_DEF_DELIM_BEGAN ) )
.with( Mutator::setSigilEnded,
() -> w.getString( KEY_DEF_DELIM_ENDED ) )
.with( Mutator::setRScript,
() -> w.getString( KEY_R_SCRIPT ) )
.with( Mutator::setRWorkingDir,
() -> w.getFile( KEY_R_DIR ).toPath() )
.with( Mutator::setCurlQuotes,
() -> w.getBoolean( KEY_TYPESET_TYPOGRAPHY_QUOTES ) )
.with( Mutator::setAutoClean,
() -> w.getBoolean( KEY_TYPESET_CONTEXT_CLEAN ) );
}
public ProcessorContext createProcessorContext() {
return createProcessorContext( null, NONE );
}
public ProcessorContext createProcessorContext(
final Path outputPath, final ExportFormat format ) {
final var textEditor = getTextEditor();
final var inputPath = textEditor.getPath();
return createProcessorContextBuilder()
.with( Mutator::setInputPath, inputPath )
.with( Mutator::setOutputPath, outputPath )
.with( Mutator::setExportFormat, format )
.build();
}
private ProcessorContext createProcessorContext( final Path inputPath ) {
return createProcessorContextBuilder()
.with( Mutator::setInputPath, inputPath )
.with( Mutator::setExportFormat, NONE )
.build();
}
private TextResource createTextResource( final File file ) {
return MediaType.valueFrom( file ) == TEXT_YAML
? createDefinitionEditor( file )
: createMarkdownEditor( file );
}
private TextResource createMarkdownEditor( final File inputFile ) {
final var editor = new MarkdownEditor( inputFile, getWorkspace() );
mProcessors.computeIfAbsent(
editor, p -> createProcessors(
createProcessorContext( inputFile.toPath() ),
createHtmlPreviewProcessor()
)
);
editor.addDirtyListener( ( c, o, n ) -> {
if( n ) {
clue();
process( getTextEditor() );
CaretMovedEvent.fire( editor.getCaret() );
}
} );
editor.addEventListener(
keyPressed( SPACE, CONTROL_DOWN ), this::autoinsert
);
mTextEditor.set( editor );
return editor;
}
private Processor<String> createHtmlPreviewProcessor() {
return new HtmlPreviewProcessor( getPreview() );
}
private void autoinsert( final KeyEvent keyEvent ) {
autoinsert();
}
public void autoinsert() {
mVariableNameInjector.autoinsert( getTextEditor(), getTextDefinition() );
}
private TextDefinition createDefinitionEditor() {
return createDefinitionEditor( DEFINITION_DEFAULT );
}
private TextDefinition createDefinitionEditor( final File file ) {
final var editor = new DefinitionEditor( file, createTreeTransformer() );
editor.addTreeChangeHandler( mTreeHandler );
return editor;
}
private TreeTransformer createTreeTransformer() {
return new YamlTreeTransformer();
}
private Tooltip createTooltip( final File file ) {
final var path = file.toPath();
final var tooltip = new Tooltip( path.toString() );
tooltip.setShowDelay( millis( 200 ) );
return tooltip;
}
public HtmlPreview getPreview() {
return mPreview;
}
public TextEditor getTextEditor() {
return mTextEditor.get();
}
public ReadOnlyObjectProperty<TextEditor> textEditorProperty() {
return mTextEditor;
}
public TextDefinition getTextDefinition() {
return mDefinitionEditor.get();
}
private Map<String, String> getDefinitions() {
return getTextDefinition().getDefinitions();
}
public Window getWindow() {
return getScene().getWindow();
}
public Workspace getWorkspace() {
return mWorkspace;
}
private <E> SetProperty<E> getRecentFiles() {
return getWorkspace().setsProperty( KEY_UI_RECENT_OPEN_PATH );
}
}