Follow me
RSS feed
My sources
My Viadeo

Créer un éditeur de texte plein écran avec MacRuby

Greg | 31 Mar 2010

Projets Après un petit passage récréatif sur l'utilisation de Core Animation et du QTKit avec MacRuby, je vous propose de revenir aujourd'hui à des choses un peu plus terre-à-terre en créant notre propre éditeur de texte. Pour éviter de rester sur un niveau trop basique, nous allons voir comment prendre en compte les différents encodages possibles ou l'impression. Et puisque nous y avons pris un peu goût, nous allons donner à notre application un petit côté fun. Pour ce dernier point, je vous propose de plagier honteusement l'excellent WriteRoom en permettant de travailler en mode plein écran.

Mise en place

Comme toujours, nous allons commencer par la partie interface graphique. Au préalable, il faut que nous voyions ce dont nous avons besoin. Je pense ici aux Outlets et aux actions.

Dans la plus simple expression, notre éditeur de texte est composé d'une vue texte (NSTextView) dans une fenêtre. Côté action, nous aurons besoin de pouvoir ouvrir, sauvegarder et imprimer un document. En ce qui concerne la sauvegarde, nous avons besoin de deux actions, une pour le "Save" et une pour le "Save As...". Pour l'impression, nous verrons un peu plus loin qu'en choisissant la simplicité, nous pouvons nous passer d'action.

Commençons !

Créez un nouveau projet de type MacRuby Application et ajoutez-lui une nouvelle classe AppController avec le code suivant :

AppController.rb
# AppController.rb
# MyWriteRoom
#
# Created by greg on 30/03/10.
# Copyright 2010 Grégoire Lejeune. All rights reserved.

class AppController
  attr_accessor :mainWindow, :textView

  def saveDocumentAs(sender)
    # TODO
  end

  def saveDocument(sender)
    # TODO
  end

  def openDocument(sender)
    # TODO
  end
end 

Nous pouvons maintenant mettre en place l'interface :

Vous pouvez déjà tester ce que nous venons de faire. Comme vous pourrez le constater, nous avons déjà une bonne base. Amusez vous avec les entrées du menu "Edit". Vous constaterez que, sans aucune intervention de notre par, le copier/coller fonctionne.

Impression

Comme je vous l'ai dit plus haut, pour la partie impression nous n'allons pas écrire la moindre ligne de code. Si vous regardez la documentation, vous verrez que l'impression demande de connaitre bon nombre de classes du framework Cocoa. Cet apprentissage est utile si nous souhaitons personnaliser cette impression. Dans notre cas, nous allons nous contenter d'utiliser ce qui nous est offert par défaut. Pour cela, retourner dans Interface Builder et liez l'entrée Print... du menu File à l'action print: de la vue texte.

Enregistrer

Nous allons maintenant écrire le contenu des méthodes saveDocument: et saveDocumentAs:. La différence entre ces deux actions vient simplement du fait que dans la seconde, nous allons demander à l'utilisateur de choisir le nom du fichier alors que dans la première, nous utiliserons le nom du fichier courant. Bien entendu, si l'utilisateur appelle l'action saveDocument: sur un document qui n'a jamais été enregistré, il sera rerouté vers saveDocumentAs:.

Comme nous en aurons besoin tout au long de la vie de notre document, nous placerons le chemin et le nom du fichier dans une variable d'instance (fileName).

Dans la méthode saveDocumentAs: nous avons besoin d'afficher un panneau permettant à l'utilisateur de choisir le chemin et le nom du fichier dans lequel il souhaite sauvegarder son texte. Ceci se fait en utilisant un objet de type NSSavePanel. Nous pouvons préciser, avec la méthode setAllowedFileTypes: le type d'extension souhaité, cette méthode prenant en paramètre un tableau de chaines, chacune correspondant à une extension.

Une fois notre panneau créé, nous l'affichons via la méthode runModal. Cette méthode renvoie en retour la valeur NSFileHandlingPanelOKButton si l'utilisateur a bien saisi un nom de fichier et cliqué sur Save, dans le cas contraire elle renvoie NSFileHandlingPanelCancelButton indiquant que l'utilisateur souhaite annuler la sauvegarde.

Si en sortie de runModal nous recevons un NSFileHandlingPanelOKButton, nous pouvons faire la sauvegarde dans le fichier. Pour cela nous allons utiliser la méthode writeToFile:atomically:encoding:error: de NSString. Cette méthode prend quatre paramètres en entrée :

AppController.rb
# AppController.rb
# MyWriteRoom
#
# Created by greg on 30/03/10.
# Copyright 2010 Grégoire Lejeune. All rights reserved.

class AppController
  attr_accessor :mainWindow, :textView, :fileName

  def saveDocumentAs(sender)
    panel = NSSavePanel.savePanel()
    panel.setAllowedFileTypes(["txt"])
    if panel.runModal == NSFileHandlingPanelOKButton
      @fileName = panel.filename.to_s
      @mainWindow.setTitle( "MyWriteRoom - [" + @fileName + "]" )
      @textView.textStorage.string.writeToFile( @fileName, atomically:true, encoding:NSUTF8StringEncoding, error:nil )
    end
  end

  def saveDocument(sender)
    if @fileName.nil?
      saveDocumentAs(sender)
      return
    end
    
    @textView.textStorage.string.writeToFile( @fileName, atomically:true, encoding:NSUTF8StringEncoding, error:nil )
  end

  def openDocument(sender)
    # TODO
  end
end

Ouvrir un fichier

L'ouverture de fichier est comparable à ce que nous avons fait pour la sauvegarde.

Dans un premier temps, nous ouvrons le panneau permettant de sélectionner le fichier à ouvrir. Pour cela, nous utilisons un objet de type NSOpenPanel. La lecture du fichier se fait en utilisant la méthode initWithContentsOfFile:encoding:error: de la classe NSString.

AppController.rb (portion)
def openDocument(sender)
  panel = NSOpenPanel.openPanel()
  panel.setAllowedFileTypes(["txt"])
  if panel.runModal == NSFileHandlingPanelOKButton
    @fileName = panel.filename.to_s
    @mainWindow.setTitle( "MyWriteRoom - [" + @fileName + "]" )
    
    fileContent = NSString.alloc.initWithContentsOfFile( @fileName, encoding:NSUTF8StringEncoding, error:nil )
    @textView.textStorage.mutableString.setString( fileContent )
  end
end

Support de l'encodage

Dans les méthodes saveDocumentAs:, saveDocument: et openDocument:, nous avons systématiquement forcé l'encodage en UTF-8. Malheureusement, vous pourrez rapidement vous rendre compte que ce n'est absolument pas optimal comme solution. Pour vous en convaincre, créez un document (avec TextEdit par exemple) et sauvegardez-le avec un encodage de texte en Mac OS Roman. Essayez maintenant d'ouvrir ce même document avec notre éditeur. Comme vous pourrez le voir, le fichier ne s'ouvre pas et la console de debug nous injurie :

Full Screen

Ce problème est tout à fait normal. En effet, dans la version actuelle de notre éditeur, nous ne supportons que l'UTF-8. Nous devons modifier ce comportement pour prendre en compte l'encodage texte du fichier.

A ce jour, je n'ai pas trouvé de solution miracle autre que de tester l'ouverture avec l'ensemble des types d'encodage supportés. L'erreur que nous venons de voir vient du fait qu'en utilisant un mauvais encodage, nous recevons en retour de initWithContentsOfFile:encoding:error: un NULL (nil). La solution que j'utilise consiste donc à tester avec chaque encodage, tant que le retour n'est pas nil :

AppController.rb (portion)
def openDocument(sender)
  panel = NSOpenPanel.openPanel()
  panel.setAllowedFileTypes(["txt"])
  if panel.runModal == NSFileHandlingPanelOKButton
    @fileName = panel.filename.to_s
    
    fileContent = ""
    [
      NSUTF8StringEncoding,
      NSMacOSRomanStringEncoding,
      NSASCIIStringEncoding,
      NSNEXTSTEPStringEncoding,
      NSJapaneseEUCStringEncoding,
      NSISOLatin1StringEncoding,
      NSSymbolStringEncoding,
      NSNonLossyASCIIStringEncoding,
      NSShiftJISStringEncoding,
      NSISOLatin2StringEncoding,
      NSUnicodeStringEncoding,
      NSWindowsCP1251StringEncoding,
      NSWindowsCP1252StringEncoding,
      NSWindowsCP1253StringEncoding,
      NSWindowsCP1254StringEncoding,
      NSWindowsCP1250StringEncoding,
      NSISO2022JPStringEncoding,
      NSUTF16StringEncoding,
      NSUTF16BigEndianStringEncoding,
      NSUTF16LittleEndianStringEncoding,
      NSUTF32StringEncoding,
      NSUTF32BigEndianStringEncoding,
      NSUTF32LittleEndianStringEncoding
    ].each do |enc|
      fileContent = NSString.alloc.initWithContentsOfFile( @fileName, encoding:enc, error:nil )
      unless fileContent.nil?
        break
      end
    end
    
    unless fileContent.nil?
      @textView.textStorage.mutableString.setString( fileContent )
      @mainWindow.setTitle( "MyWriteRoom - [" + @fileName + "]" )
    else
      NSRunCriticalAlertPanel( "Error", "Can't open #{@fileName}", "Ok!", nil, nil )
    end
  end
  
  @textView.font = NSFont.fontWithName( "Courier", size:16 )
end

Maintenant que nous pouvons ouvrir n'importe quel type de fichier, en tenant compte de son encodage, il serait bon de conserver cette information afin d'enregistrer les modifications du fichier en conservant cet encodage. Pour cela, il nous suffit tout simplement de stocker l'information et de la réutiliser lors de l'enregistrement.

AppController.rb
# AppController.rb
# MyWriteRoom
#
# Created by greg on 30/03/10.
# Copyright 2010 Grégoire Lejeune. All rights reserved.

class AppController
  attr_accessor :mainWindow, :textView, :fileName

  def encoding
    @encoding||NSUTF8StringEncoding
  end
  def encoding=(x)
    @encoding=x
  end

  def saveDocumentAs(sender)
    panel = NSSavePanel.savePanel()
    panel.setAllowedFileTypes(["txt"])
    if panel.runModal == NSFileHandlingPanelOKButton
      @fileName = panel.filename.to_s
      @mainWindow.setTitle( "MyWriteRoom - [" + @fileName + "]" )
      @textView.textStorage.string.writeToFile( @fileName, atomically:true, encoding:encoding, error:nil )
    end
  end

  def saveDocument(sender)
    if @fileName.nil?
      saveDocumentAs(sender)
      return
    end
    @textView.textStorage.string.writeToFile( @fileName, atomically:true, encoding:encoding, error:nil )
  end

  def openDocument(sender)
  panel = NSOpenPanel.openPanel()
  panel.setAllowedFileTypes(["txt"])
  if panel.runModal == NSFileHandlingPanelOKButton
    @fileName = panel.filename.to_s
    
    fileContent = ""
    [
      NSUTF8StringEncoding,
      NSMacOSRomanStringEncoding,
      NSASCIIStringEncoding,
      NSNEXTSTEPStringEncoding,
      NSJapaneseEUCStringEncoding,
      NSISOLatin1StringEncoding,
      NSSymbolStringEncoding,
      NSNonLossyASCIIStringEncoding,
      NSShiftJISStringEncoding,
      NSISOLatin2StringEncoding,
      NSUnicodeStringEncoding,
      NSWindowsCP1251StringEncoding,
      NSWindowsCP1252StringEncoding,
      NSWindowsCP1253StringEncoding,
      NSWindowsCP1254StringEncoding,
      NSWindowsCP1250StringEncoding,
      NSISO2022JPStringEncoding,
      NSUTF16StringEncoding,
      NSUTF16BigEndianStringEncoding,
      NSUTF16LittleEndianStringEncoding,
      NSUTF32StringEncoding,
      NSUTF32BigEndianStringEncoding,
      NSUTF32LittleEndianStringEncoding
    ].each do |enc|
      fileContent = NSString.alloc.initWithContentsOfFile( @fileName, encoding:enc, error:nil )
      unless fileContent.nil?
        encoding = enc
        break
      end
    end
    
    unless fileContent.nil?
      @textView.textStorage.mutableString.setString( fileContent )
      @mainWindow.setTitle( "MyWriteRoom - [" + @fileName + "]" )
    else
      NSRunCriticalAlertPanel( "Error", "Can't open #{@fileName}", "Ok!", nil, nil )
    end
  end
  
  @textView.font = NSFont.fontWithName( "Courier", size:16 )
  end
end

Voilà, nous avons maintenant un éditeur de texte fonctionnel.

Travailler en plein écran

Avant de rentrer dans les détails de la solution à mettre en place pour un affichage plein écran, nous devons faire une petite modification au niveau de l'interface. En effet, pour nous permettre d'activer ce mode, nous allons ajouter une entrée Fullscreen dans le menu Window de notre application. Cette entrée de menu sera liée à l'action fullscreen: de notre contrôleur.

Ajoutez donc ceci dans AppController.rb :

def fullscreen(sender)
  # TODO
end

Puis modifiez l'interface :

Pour travailler en mode plein écran, il nous suffit tout simplement d'agrandir la fenêtre de façon à ce qu'elle utilise tout l'écran.

Pour faire ce travail, il est nécessaire de s'arrêter quelques minutes afin de comprendre certaines notions relatives à la définition des fenêtres.

Une fenêtre est caractérisée par trois choses :

Full Screen

Donc pour mettre notre fenêtre en plein écran, il suffit de modifier le contentRect pour qu'il fasse la taille de l'écran. La récupération des informations de l'écran se fait en utilisant la méthode screen de NSWindow. Donc pour passer notre fenêtre en plein écran, il faut modifier sa frame de façon à ce que son contentRect fasse la même taille que la frame de l'écran. Cette modification se fait en utilisant la méthode setFrame:display:animate: de NSWindow :

@mainWindow.setFrame(@mainWindow.frameRectForContentRect(@mainWindow.screen.frame), display:true, animate:true)

Pour cacher la barre de titre, il nous suffit simplement de modifier la fenêtre en lui appliquant le masque NSBorderlessWindowMask

A l'inverse, quand nous voudrons passer du mode plein écran au mode fenêtre, il faudra "tout remettre dans l'état initial". Nous devons donc mémoriser l'état avant le passage en plein écran et de le restituer au moment venu.

Nous sommes donc en mesure de coder le corps de la méthode fullscreen:

AppController.rb (partiel)
def fullscreen(sender)
  if @fullScreenWindow      
    @mainWindow.setFrame(@initialFrame, display:true, animate:true)
    @mainWindow.setStyleMask(@initialStyleMask)
    
    @fullScreenWindow = false
  else
    @initialFrame = @mainWindow.frameRectForContentRect( @mainWindow.frame )
    @initialStyleMask = @mainWindow.styleMask
    
    @mainWindow.setStyleMask(NSBorderlessWindowMask)
    @mainWindow.setFrame(@mainWindow.frameRectForContentRect(@mainWindow.screen.frame), display:true, animate:true)
    @fullScreenWindow = true
  end
end

Si vous testez, vous remarquerez très vite deux problèmes :

  1. Nous n'avons pas un "vrai" plein écran. En effet, le menu et le dock (si vous ne le masquez pas automatiquement) restent visibles. Ce "problème" se résout très facilement en utilisant la méthode setMenuBarVisible de NSMenu.
  2. Si vous avez "miniaturisé" la fenêtre et que vous passez en plein écran, vous aurez l'impression que rien ne se passe. Pour régler cela, nous allons donc "déminiaturiser" la fenêtre avant de passer en plein écran, via la méthode deminiaturize de NSWindow.
AppController.rb (partiel)
def fullscreen(sender)
  if @fullScreenWindow
    NSMenu.setMenuBarVisible(true)

    @mainWindow.setFrame(@initialFrame, display:true, animate:true)
    @mainWindow.setStyleMask(@initialStyleMask)
    
    @fullScreenWindow = false
  else
    @mainWindow.deminiaturize(nil)
    NSMenu.setMenuBarVisible(false)
    
    @initialFrame = @mainWindow.frameRectForContentRect( @mainWindow.frame )
    @initialStyleMask = @mainWindow.styleMask
    
    @mainWindow.setStyleMask(NSBorderlessWindowMask)
    @mainWindow.setFrame(@mainWindow.frameRectForContentRect(@mainWindow.screen.frame), display:true, animate:true)
    @fullScreenWindow = true
  end
end

Testez encore une fois...

Personnellement il reste une petite chose qui me perturbe. La perte du focus quand on passe du mode fenêtre au mode plein écran, et inversement.

J'ai résolu cela non plus en modifiant la fenêtre, mais en en créant une nouvelle et en modifiant sa frame :

AppController.rb (partiel)
def fullscreen(sender)
  if @fullscreenWindow != nil
    @fullscreenWindow.setFrame(@fullscreenWindow.frameRectForContentRect(@mainWindow.frame), display:true, animate:true)

    @mainWindow.setContentView( @fullscreenWindow.contentView );
    @mainWindow.makeKeyAndOrderFront(nil);

    @fullscreenWindow.close
    @fullscreenWindow = nil

    NSMenu.setMenuBarVisible(true)
  else
    @mainWindow.deminiaturize(nil)

    NSMenu.setMenuBarVisible(false)
    
    @fullscreenWindow = FSWindow.alloc.initWithContentRect(@mainWindow.contentRectForFrameRect(@mainWindow.frame), styleMask:NSBorderlessWindowMask, backing:NSBackingStoreBuffered, defer:true)
    @fullscreenWindow.setLevel(NSFloatingWindowLevel)
    @fullscreenWindow.setContentView(@mainWindow.contentView)
	  @fullscreenWindow.setTitle(@mainWindow.title)
    @fullscreenWindow.makeKeyAndOrderFront(nil)

    @fullscreenWindow.setFrame(@fullscreenWindow.frameRectForContentRect(@mainWindow.screen.frame), display:true, animate:true)
    @mainWindow.orderOut(nil)
  end
end

La fenêtre fullScreenWindow reprend toutes les caractéristiques de modifications que nous faisons dans la première version de fullscreen:. Nous avons ajouté une seule chose : le fait qu'elle récupère également le contentView de la fenêtre mainWindow et qu'elle le lui restitue quand nous quittons le mode plein écran.

Vous remarquerez que nous n'utilisons pas une NSWindow mais une FSWindow. En effet, par défaut les fenêtre borderless ne peuvent pas recevoir d'évènement clavier. Il est donc nécessaire de surclasser NSWindow de manière à autoriser cela. Ceci se fait très simplement en renvoyant true dans la méthode canBecomeKeyWindow de la classe FSWindow :

FSWindow.rb
# FSWindow.rb
# MyWriteRoom
#
# Created by greg on 30/03/10.
# Copyright 2010 Grégoire Lejeune. All rights reserved.

class FSWindow < NSWindow
  def canBecomeKeyWindow
    true
  end
end

Encore plus WriteRoom-esque

Histoire de rendre le mode plein écran plus proche de WriteRoom, nous pouvons nous amuser à modifier les couleurs de fond et de police lors du passage en plein écran. Ceci se fait très simplement via les méthodes setBackgroundColor:, setTextColor: et setInsertionPointColor: de NSTextView. Je ne détaillerai pas plus ce point en vous laissant le plaisir de télécharger les sources de cet exemple pour regarder le code.

Pour aller plus loin...

Si vous êtes en train de lire ceci, tout d'abord merci !

Certains d'entre vous se demandent pourquoi je ne vous ai pas proposé de faire une application de type Document-based ? Il n'y a en effet pas plus classique qu'un éditeur de texte pour ce type d'application. En fait, il y a un petit lien avec la partie impression. Je suis passé très rapidement sur ce point et ceux qui découvrent la programmation sur Mac via cette série d'articles y auront peut-être vu un côté "magique" qui demanderait quelques explications. Je suis d'accord ! Je ne m'y suis jamais attardé, mais le First Responder et le File's Owner sont deux concepts dont il va être nécessaire de bien comprendre si nous voulons aller plus loin. Il est difficile de les ignorer quand on désire faire des applications de type Document-based. Dans la suite de cette série, je m'attarderai donc sur le First Responder et le File's Owner, afin de bien comprendre ce qui se cache derrière. Ce n'est qu'ensuite que nous modifierons notre éditeur de texte en le transformant en application utilisant l'architecture NSDocument.

Copyright © 2009 - 2011 Grégoire Lejeune.
All documents licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 2.5 License, except ones with specified licence.
Powered by Jekyll.