Document based app with Gorm and PC

From GNUstepWiki
Revision as of 20:53, 7 September 2024 by Buzzdee (talk | contribs)
Jump to navigation Jump to search

About

This tutorial is going to show off how to create a Document based app, it'll cover the following:

  • simple overview about MVC and document based apps
    • what classes to be used and how it fits into the MVC model
    • we'll use an AppDelegate to manage the main lifecycle
  • using ProjectCenter to manage the project and coding
    • simple introduction into PC, and some tips 'n tricks
    • how to enable ARC in ProjectCenter
  • using Gorm to create the UI
    • main .gorm file for the menu
    • separate .gorm files for the document types
  • KVC/KVO to keep the UI in sync with the model
  • the App we're going to create will be localized
    • we'll switch the default language to something else than English ;)
  • the app will handle multiple documents

This tutorial is not meant for long time Mac Cocoa Objective-C developers. It's more for people like me, lacking a Mac, but want to give the Cocoa Framework a try. To be honest, I read through "Cocoa Programming Developer's Handbook" by David Chisnall, but still, afterward it took me a couple of weeks with the help of ChatGPT to get the test app we're going over here, to finally work and being well structured. Reading the book, is very helpful in the first place, and dramatically helps talking to ChatGPT. ChatGPT is actually not too bad, it's even aware of some differences between GNUstep and Cocoa, but sometimes hallucinates methods that don't exist, or it might guide you into the wrong direction.

Structure

  • first there will be an overview about MVC, and the classes we're going to use in the App, and how these fit into the paradigm
    • how things are wiring up
  • then continue with a quick introduction into PC and Gorm
    • some small tips 'n tricks to make using them a bit easier
  • then we'll start implementing the application
    • step by step, dealing with one type of document to be managed in the app
  • at the end, you should be able to implement handling of a second type of document

Code for the example application can be found here: https://github.com/buzzdeee/TestDocument

MVC and document based apps

Cocoa MVC (Model-View-Controller) is a design pattern used in Apple's Cocoa framework to separate concerns in an application. The Model represents the data and business logic, the View displays the data and handles user interface elements, and the Controller manages communication between the Model and View, updating the UI based on changes in the data. This separation promotes modularity, making the code more maintainable and reusable.

In a Cocoa (or GNUstep) document-based app that supports multiple documents, the classes involved can be mapped to the Model-View-Controller (MVC) pattern. Here’s how the various components like NSDocument, NSDocumentController, NSWindowController, document model classes, and UI built with Gorm fit into MVC:

  1. Model (M) The model represents the application's data and business logic. In a document-based app, the following components are part of the model:
    1. Plain classes for the document models: These are your custom classes that contain the data and logic specific to your document. These classes are independent of the UI and are responsible for holding the document's data, performing any processing, and managing state.
    2. For example, if you’re building a text editor, the model class might store the text, keep track of formatting, and manage metadata (like the document title or file path).
    3. NSDocument: While NSDocument primarily fits into the controller part of MVC, it also has some aspects of the model in Cocoa’s architecture. NSDocument handles saving, loading, and managing the undo functionality, so it acts as a mediator between the model data and persistence (reading/writing the file). It interacts closely with the document model classes to manage the actual content.
  2. View (V) The view is responsible for displaying the data and handling user input.
    1. UI built with Gorm: Gorm is used to build the UI components in the app, which form the view. These include:
      1. Windows, menus, buttons, text fields, etc. created in Gorm are part of the view layer. They display the content of your document and allow users to interact with it.
      2. For example, if you're using a text editor, the NSTextView or custom views to display text are part of the view layer.
    2. NSWindowController: The NSWindowController is responsible for managing the app’s windows and handling the interaction between the view and the document (controller). It loads the interface from a .gorm file (which contains the window and its views) and binds the UI elements to the document data. In strict MVC, it is part of the controller, but because it manages UI and views, it has aspects of both the controller and view layers.
  3. Controller (C) The controller manages the interactions between the model and the view, mediating user input, updating the view, and manipulating the model as needed.
    1. NSDocument: As mentioned, NSDocument serves as the document controller in this architecture. It communicates with the model (plain document classes) to retrieve and save the document data and updates the view when necessary. It also handles document-specific tasks like managing undo/redo, saving, and opening documents. It doesn't directly manage the UI but works closely with the NSWindowController to coordinate the display of the document.
    2. NSDocumentController: This class is responsible for managing all documents in the application. It keeps track of which documents are open, handles creating new documents, and ensures that the correct document is presented in the appropriate window. NSDocumentController also manages interactions like opening recent files, handling document lifecycle events (new, open, close), and interacting with NSDocument objects. It plays a key role in coordinating the flow between the user’s actions and the document management.
    3. NSWindowController: Though it has a close relationship with the view, NSWindowController also has controller responsibilities. It coordinates with NSDocument to ensure that the correct data is presented in the view. It manages the window’s lifecycle and keeps the UI in sync with the document data.

Breakdown of the MVC components

  • Model: Plain document classes (contain the data and business logic).
  • View: UI components built with Gorm, which display the document’s data.
  • Controller:
    • NSDocument: Bridges the model and the view, managing document-specific tasks (loading, saving, undo/redo).
    • NSWindowController: Manages windows and interfaces with NSDocument to update the views.
    • NSDocumentController: Oversees multiple documents, handles document creation, and ensures that the correct documents are open/active.

Workflow example in MVC context

  1. User Action: A user opens an existing document from the File menu (View).
  2. Controller Response: The NSDocumentController coordinates opening the file by creating or loading the corresponding NSDocument object (Controller).
  3. Model Interaction: The NSDocument interacts with the plain document model class to load the data from the file (Model).
  4. UI Update: The NSWindowController ensures that the document’s data is reflected in the views loaded from the Gorm interface file (View).
  5. User Edits: The user modifies the document content via the UI (View), and the NSDocument updates the document model class accordingly (Model).

In this way, each class in a Cocoa (GNUstep) document-based app has a clear responsibility within the MVC framework, ensuring separation of concerns and maintainability of the application’s architecture.

Development Environment

This tutorial was developed on OpenBSD, with just the main GNUstep packages installed. If you're up to it, and it's probably one of the easiest ways to get a a modern GNUstep development environment supporting libobjc2 and ARC up and running ;)

  1. Install OpenBSD on a spare machine or VM, enable xdm
  2. install GNUstep as easy as: pkg_add gnustep-desktop
  3. configure your .xsession alike:
if [ -f /usr/local/share/GNUstep/Makefiles/GNUstep.sh ];then
       . /usr/local/share/GNUstep/Makefiles/GNUstep.sh
fi
export GNUSTEP_STRING_ENCODING=NSUTF8StringEncoding
export LC_ALL='en_EN.UTF-8'
export LC_CTYPE='en_US.UTF-8'
if [ -x /usr/local/bin/gpbs ];then
       /usr/local/bin/gpbs
fi
if [ -x /usr/local/bin/gdnc ];then
       /usr/local/bin/gdnc
fi
wmaker &
if [ -x /usr/local/bin/GWorkspace ];then
       /usr/local/bin/make_services
       /usr/local/bin/GWorkspace
fi

With these simple steps, you'll have a working GNUstep development environment.

Debugging

If you want to debug, you may want to install egdb from packages as well. To ease debugging, it might make sense to have the gnustep library sources around, and those build with debugging symbols, to do so:

  1. add the following to your /etc/doas.conf file:
          permit nopass keepenv :wsrc
          permit nopass keepenv :wheel
    1. NOTE: this allows passwordless root for the main user
  1. echo "echo 'DEBUG=-g -O0'" > /etc/mk.conf
  2. echo "echo 'SUDO=doas'" >> /etc/mk.conf
  3. doas pkg_delete gnustep-base gnustep-libobjc2 gnustep-make
  4. download and extract the ports.tar.gz to /usr/ports
  5. cd /usr/ports/x11/gnustep/
  6. make install
  7. make patch

just reboot, or relogin, then you should have your GNUstep packages around, built with debugging symbols, as well as the sources on disk. This will dramatically ease debugging using egdb.

ProjectCenter and Gorm

GNUstep ProjectCenter and Gorm are tools for developing applications using the GNUstep framework.

  • ProjectCenter is an integrated development environment (IDE) that helps developers manage their projects, providing tools to create, organize, and compile GNUstep applications. It simplifies the setup and management of project files, source code, and build configurations, making it easier to develop Objective-C applications.
  • Gorm: (GNUstep Object Relationship Modeler) is a visual interface builder similar to Apple's Interface Builder. It allows developers to design user interfaces by dragging and dropping UI components, connecting them to the application's code, and managing the relationships between UI elements and objects, without writing the UI code manually.

Together, these tools streamline the development of GNUstep-based applications by providing a cohesive environment for both project management and user interface design.

Creating your Project

With all that prepared, you're now ready to create the basic project:

ProjectCenter

With either just gnustep-desktop package(s) installed, or even built by yourself from ports, you should now be able to just start ProjectCenter from the command line, i.e. xterm window.

Once it's started, you want to configure it a bit, to ensure a smooth experience ;)

  • Open the Preferences Build menu
    • ensure External Build Tool points to /usr/local/bin/gmake or wherever your GNU Make is when you're not on OpenBSD. A BSD make just won't work.
  • Open the Preferences Miscellaneous menu
    • ensure Debugger points to /usr/local/bin/egdb or whatever is the right one for your platform
    • ensure Editor is ProjectCenter. That should be the default, you may use others, i.e. Gemas...


Now Open the Project -> New menu, select a suitable place where you want to have your project reside. Ensure Project Type is set to Application and give it a name. We'll name our project DocumentBasedApp as you can see in the screenshot below. (You may actually not want to choose /tmp as your destination folder ;)


Voila, your first project.

Now do some configurations, to add support for ARC, debugging, multiple languages...

  • Open the Project Inspector -> Project Languages
    • and add a second language, i.e. German
  • Open the Project Inspector -> Project Attributes
    • switch the Language to German
    • you may specify a bundle identifier, i.e. com.mydomain.documentbasedapp
    • open the Document Types... button
      • activate the Document-based checkbox
      • enter the fields below (always hit return when finished editing):
        • Type: MyDoc1
          • As it's identified throughout the application in code
        • Name: My Doc 1
          • Not exactly sure where this is used
        • Icon: not yet
          • we don't need it yet
        • Extension: doc1
          • the file extension for your document type, without the leading .
        • Role: Editor
          • might be Editor or Viewer depending on whether your app will edit or only view your documents
        • Class: MyDoc1
          • the class in your code, that will handle these types of documents
      • finally hit the + sign to add it to the list of document types
      • you may now do the same for a second document type in a similar way: MyDoc2, or do it later
  • Open the Project Inspector -> Build Attributes
    • ObjC Compiler Flags: -fobjc-arc -g
      • to tell it to use ARC and add debugging symbols when building
    • Install Domain: you may change it to User
  • You may Open the Project Inspector -> Project Description
    • update there as well as you see fit

With that, your project should be well configured.

ARC support finished

  • find the AppController.m in the Classes and remove the [super dealloc]; line from the dealloc method
  • find the DocumentBasedApp_main.m in the Other Sources and add @autoreleasepool to it, so it looks alike:
int 
main(int argc, const char *argv[])
{
  @autoreleasepool
    {
      return NSApplicationMain (argc, argv);
    }
}

With ARC, explicit calls to dealloc are prohibited, and the @autoreleasepool takes care about the memory management within your application.

Build the app

  • open the build window
    • configure the Build Options
      • enable verbose output'
    • hit the build button

With a bit of luck and no typos, the build should have succeeded!

Launch the app

  • from the main project window, hit the launcher icon to open the launcher window
    • NSLog output will show up here when you add it for your debugging aid
  • hit the launch icon

Voila, your first (quite useless) app is up and running.

Add some classes we need

We're going to add some initial classes, put in a number of stub methods, we'll fill in with code later on. We won't need all methods, but NSLog messages in them, will show the flow the application takes.


Separate AppDelegate and AppController responsibilities

GNUstep ProjectCenter default applications just provide a AppController class. We're going to separate out some life-cycle management from the AppController into the AppDelegate.

In a document-based application, both AppDelegate and AppController play essential roles in the lifecycle and behavior of the app, but they serve different purposes. Let's clarify their jobs:

AppDelegate: The AppDelegate class is responsible for handling the high-level app lifecycle and state transitions. It is defined in the Application Kit as a delegate for NSApplication. This class manages events such as the launch, termination, and background/foreground state of the application. In a document-based app, the AppDelegate is not directly involved with managing individual documents (that's the job of NSDocument and NSDocumentController), but it coordinates the application-wide behavior.

We'll let the AppDelegate handle Application Lifecycle Events:

  • applicationDidFinishLaunching: This method is called after the application has launched, and you can use it to perform setup tasks, like initializing application-wide data or settings.
  • applicationWillTerminate: Called before the application terminates, and you can use it to save data or clean up resources.

AppController: The AppController class, though not a part of the default AppKit architecture, is a custom controller you can introduce. Its primary function is to act as the controller in the MVC pattern but on an application-wide level. This class often handles custom application logic that is not tied to a specific document or the app's lifecycle, unlike AppDelegate.

In a document-based app, AppController might:

  • Coordinate Document Management: It may interact with NSDocumentController to manage app-wide document-related features, like managing multiple types of documents or integrating additional functionality across all documents.
  • Setup Application-Specific Logic: It could handle custom actions like opening recent documents, setting up application-wide notifications, or dealing with specific app-wide user interface elements.
  • Interface with UI Components: It might be responsible for handling non-document-specific UI components like toolbars, menus, or status indicators that aren't directly tied to an individual document.

Create AppDelegate

  • in the main project window select Classes
  • from the File menu open New in Project
    • select Objective-C class
    • enter the name: AppDelegate
    • activate checkbox: Add Header File
  • click the create

Find and open your AppDelegate.h in the Headers

Update it to include the following:

#import <AppKit/AppKit.h>

@class AppController;

// the AppDelegate shall implement the NSApplicationDelegate protocol
@interface AppDelegate : NSObject <NSApplicationDelegate>
{

}

// to link up the AppController
@property (nonatomic, strong) AppController *appController;

@end

Find the corresponding AppDelegate.m file in the Classes section. Update it to include the following:

#import "AppDelegate.h"
#import "AppController.h"

@implementation AppDelegate

- (void)applicationWillFinishLaunching:(NSNotification *)notification
{
  // Initialize app here
  
  NSLog(@"AppDelegate: applicationWillFinishLaunching: was called");
}

- (void)applicationDidFinishLaunching:(NSNotification *)notification
{
  // Initialize app here
  NSLog(@"AppDelegate: applicationDidFinishLaunching: was called");

  // link up the AppController 
  self.appController = [[AppController alloc] init];  
  [self.appController setupApplication];
}

// Invoked just before the application terminates. 
// Use this method to perform cleanup tasks, save data, or release resources.
- (void)applicationWillTerminate:(NSNotification *)notification
{
  NSLog(@"AppDelegate: applicationWillTerminate: %@", notification);
}


// Asks the delegate whether the application should terminate. 
// Useful for prompting the user to save unsaved changes or cancel termination under certain conditions.
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender
{
//    if ([self.appController hasUnsavedChanges]) {
        // Prompt the user to save changes
        // Return NSTerminateLater if waiting for user input
//        return NSTerminateCancel; // Or NSTerminateNow based on user response
//    }
  NSLog(@"AppDelegate: applicationShouldTerminate: %@", sender);
  return NSTerminateNow;
}

// Determines whether the application should quit when the last window is closed. 
// Returning YES makes the app quit automatically, which is common for utility applications.
- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender
{
  NSLog(@"AppDelegate: applicationShouldTerminateAfterLastWindowClosed: was called");
  return NO; // Return YES if the app should quit after the last window closes
}


// Handles requests to open files, typically when the user double-clicks a 
// file associated with your app or drags a file onto the app icon.
- (void)application:(NSApplication *)sender openFiles:(NSArray<NSString *> *)filenames
{
  NSLog(@"AppDelegate: openFiles: was called");
//    for (NSString *filename in filenames) {
        // Open each file using your NSDocumentController
//        [[NSDocumentController sharedDocumentController] openDocumentWithContentsOfURL:[NSURL fileURLWithPath:filename]
//                                                                                display:YES
//                                                                                  error:nil];
//    }
//    [sender replyToOpenOrPrint:NSApplicationDelegateReplySuccess];
}


// Handles opening multiple URLs (including files) in response to user actions.
- (void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls
{
  NSLog(@"AppDelegate: openURLs: was called");
//    for (NSURL *url in urls) {
        // Open each URL using your NSDocumentController
//        [[NSDocumentController sharedDocumentController] openDocumentWithContentsOfURL:url
//                                                                                display:YES
//                                                                                  error:nil];
//    }
}

// Called when the user tries to reopen the application (e.g., clicking the app icon in the Dock) 
// while it’s already running. Useful for restoring windows or bringing the app to the foreground.
- (BOOL)applicationShouldHandleReopen:(NSApplication *)sender hasVisibleWindows:(BOOL)flag
{
  NSLog(@"AppDelegate: applicationShouldHandleReopen: was called");
//    if (!flag) {
        // No visible windows, so open a new document window
//        [[NSDocumentController sharedDocumentController] newDocument:self];
//    }
  return YES;
}


// Called when the application is about to become active. 
// Useful for refreshing UI elements or updating data when the app gains focus.
- (void)applicationWillBecomeActive:(NSNotification *)notification
{
  NSLog(@"AppDelegate: Application will become active.");
  // Prepare for activation
}

// Called when the application has become active. 
// Useful for refreshing UI elements or updating data when the app gains focus.
- (void)applicationDidBecomeActive:(NSNotification *)notification
{
  NSLog(@"AppDelegate: Application did become active.");
  // Refresh UI or data
}

// Called when the application is about to resign its active status. 
// Useful for pausing ongoing tasks or saving transient state.
- (void)applicationWillResignActive:(NSNotification *)notification
{
  NSLog(@"AppDelegate: Application will resign active.");
  // Pause tasks or disable certain UI elements
}

// Called when the application has resigned its active status. 
// Useful for pausing ongoing tasks or saving transient state.
- (void)applicationDidResignActive:(NSNotification *)notification
{
  NSLog(@"AppDelegate: Application did resign active.");
  // Perform actions after resigning active status
}


// Indicates whether the application supports secure state restoration. 
// Returning YES allows your app to participate in state restoration, 
// which can restore windows and their states after the app restarts.
- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app
{
  NSLog(@"AppDelegate: applicationSupportsSecureRestorableState was called");
  return NO;
}


// Allows the delegate to encode additional information into the state restoration process.
- (void)application:(NSApplication *)app willEncodeRestorableState:(NSCoder *)state
{
  NSLog(@"AppDelegate: willEncodeRestorableState was called");
  // Encode additional state information
}

// Allows the delegate to respond after the state has been decoded.
- (void)application:(NSApplication *)app didDecodeRestorableState:(NSCoder *)state
{
  NSLog(@"AppDelegate: didDecodeRestorableState was called");
  // Decode and apply additional state information
}

// Handles registration for remote notifications (e.g., push notifications). 
// While more common in iOS, macOS applications can also use these for similar purposes.
- (void)application:(NSApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  NSLog(@"Registered for remote notifications with device token: %@", deviceToken);
  // Send device token to server
}

// Handles registration for remote notifications (e.g., push notifications). 
// While more common in iOS, macOS applications can also use these for similar purposes.
- (void)application:(NSApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
  NSLog(@"Failed to register for remote notifications: %@", error);
  // Handle the failure
}


// Allows the delegate to customize error presentation to the user.
- (NSError *)application:(NSApplication *)app willPresentError:(NSError *)error
{
  NSLog(@"AppDelegate: willPresentError");
  // Customize error before it’s presented
  return nil;
}

// Allows the delegate to customize to respond after an error has been presented to the user.
- (void)application:(NSApplication *)app didPresentError:(NSError *)error
{
  NSLog(@"AppDelegate: didPresentError");
  // Perform actions after error presentation
}


Clean-up and update AppController

Open your AppController.h file, and clean-up methods we don't need, and add setupApplication method, so it will look like the following:

#import <AppKit/AppKit.h>

@interface AppController : NSObject
{
}

+ (void)  initialize;

- (id) init;
- (void) dealloc;

- (void) showPrefPanel: (id)sender;

// called by the AppDelegate on startup
- (void)setupApplication;

@end

Clean-up and update your AppController.m class file as well, so it's going to look like this:

#import "AppController.h"

@implementation AppController

+ (void) initialize
{
  NSMutableDictionary *defaults = [NSMutableDictionary dictionary];

  /*
   * Register your app's defaults here by adding objects to the
   * dictionary, eg
   *
   * [defaults setObject:anObject forKey:keyForThatObject];
   *
   */
  
  [[NSUserDefaults standardUserDefaults] registerDefaults: defaults];
  [[NSUserDefaults standardUserDefaults] synchronize];
}

- (id) init
{
  if ((self = [super init]))
    {
    }
  return self;
}

- (void) dealloc
{
}


- (void) showPrefPanel: (id)sender
{
}

- (void)setupApplication
{
  // Setup global settings, preferences, or shared resources here
  NSLog(@"AppController is setting up the application");
}

@end

Try building application, eventually it should succeed.

Given that, the AppController will take care of the NSUserDefaults for the application, so the global configuration, taking care of the preferences Panel, and setting up global application specific stuff.

Custom Document Controller

Now we need to get our custom document controller into play.

Important facts about the custom NSDocumentController:

  • It should be a subclass of NSDocumentController
  • it's a Singleton, means, there's ever only one NSDocumentController class or subclass thereof in your application
  • You might want to load it early, so the first instantiated NSDocumentController, will be your custom subclas.
    • We're using the CustomInitializer class to help with that.