Programování v prostředí Cocoa (15) GUI a InterfaceBuilder - příklad V minulé části našeho volného seriálu o programování v API Cocoa jsme dokončili přehledný popis InterfaceBuilderu a slíbili si trochu složitější příklad. Dnes si jej tedy ukážeme, spolu s využitím speciálních "pseudoobjektů", jako je First Responder nebo File's Owner: připravíme velmi jednoduchý editor, který dokáže využívat doplňování podle slovníku. Editor Nejprve připravíme samotný editor. Díky flexibilitě a síle standardních knihovních služeb Cocoa to je práce na pár minut a na několik programových řádků. My se zde samozřejmě podrobně soustředíme na práci v InterfaceBuilderu a ostatní činnosti popíšeme jen natolik, aby bylo jasné, co se vlastně děje. Nejprve spustíme ProjectBuilder a vyžádáme si vytvoření nového projektu typu Cocoa Document-based Application. ProjectBuilder nám ušetří spoustu práce tím, že automaticky vytvoří základní kostru aplikace a umístí do ní potřebné soubory - včetně dvou NIB: MainMenu.nib obsahuje hlavní menu a je centrálním NIB celé aplikace; MyDocument.nib obsahuje GUI vztahující se k dokumentu, především tedy okno, v němž bude dokument zobrazován. Malá odbočka: Proč jsou NIB navrženy právě takto? To je jednoduché. Hlavní NIB se zavede hned při spuštění aplikace; dokumentový NIB se naproti tomu zavede vždy, když otvíráme nový dokument. Tak vlastně dostaneme úplně "zadarmo" to, že pro každý dokument se vytvoří jeho vlastní sada GUI objektů. File's Owner pro dokumentový NIB je vždy instance speciální třídy, která reprezentuje controller dokumentu (připomeňme strukturu MVC, o které jsme se bavili v předminulém dílu - model reprezentuje data, view jsou prvky uživatelského rozhraní uložené v NIB, a controller je logika aplikace, jež svazuje model a view dohromady). Tuto třídu pro nás ProjectBuilder už také připravil a nazval ji MyDocument - samozřejmě ji můžeme podle libosti přejmenovat. Protože budeme v dokumentu potřebovat textový editor, přidáme do interface třídy nový outlet text - to vidíme na obr. 1, spolu s oknem ProjectBuilderu. Povšimněte si v levé části okna seznamu souborů, jež jsou součástí projektu: vedle zdrojových textů třídy MyDocument zde vidíme oba NIB a pár dalších pomocných souborů. Jen pro zajímavost - například soubor Credits.rtf usnadňuje tvorbu standardního panelu "o aplikaci": součástí menu v MainMenu.nib už je odpovídající příkaz, nastavený tak, že otevře standardní panel, v němž se zobrazí ikona aplikace, její jméno a verze, a v samostatném poli obsah formátovaného textového souboru Credits.rtf. Žádné programování není zapotřebí... Zpět k naší práci. Po přidání outletu text otevřeme poklepáním MyDocument.nib a v InterfaceBuilderu do jeho okna přetáhneme z palety připravený objekt třídy NSTextView (obr. 2). Za povšimnutí stojí modré čáry, jimiž InterfaceBuilder automaticky vyznačí ideální polohu objektu v okně, aby vzhled aplikace odpovídal standardům Mac OS X. Samozřejmě že můžeme tuto "nápovědu" ignorovat a objekt umístit kamkoli, ale za normálních okolností je to významná pomoc. Z okna ProjectBuilderu vhodíme soubor MyDocument.h do okna InterfaceBuilderu (aby věděl o novém outletu). Pak textový objekt roztáhneme přes celé okno, určíme jeho atributy pomocí inspektoru a technikou známou už z minulého dílu jej navážeme na outlet text ve File's Owneru (obr. 3). Jak už víme, vlastníkem dokumentového NIB je vždy instance třídy, jež je controller daného typu dokumentů, v našem případě tedy právě MyDocument. Pro dokončení editoru už stačí přidat jen šest programových řádků, v nichž určíme způsob, jakým se budou dokumenty ukládat na disk a načítat z disku. Prvním je pomocná proměnná loaded typu NSString, kterou přidáme mezi proměnné instance MyDocument. Zbývajících pět doplníme do připravených metod ve zdrojovém souboru MyDocument.m (přidané řádky jsou označeny tučně): - (NSData *)dataReprezentationOfType:(NSString *)aType { return [[text string] dataUsingEncoding:NSUnicodeStringEncoding]; } - (BOOL)loadDataReprezentation:(NSData *)data ofType:(NSString *)aType { loaded=[[NSString alloc] initWithData:data encoding:NSUnicodeStringEncoding]; return loaded!=nil; } - (void)windowControllerDidLoadNib:(NSWindowController *) aController { [super windowControllerDidLoadNib:aController]; if (loaded) [text setString:[loaded autorelease]]; } Podrobnostem třídy NSDocument, již zde vlastně využíváme (podívejte se na obr. 1, kde je vidět, že MyDocument je její podtřída), se budeme věnovat později; zatím proto jen telegraficky: * Metoda dataReprezentationOfType: připraví data pro uložení na disk; my prostě řekneme našemu textovému editoru (outlet text), aby nám dal svůj obsah (zpráva string). Od něj si pak vyžádáme reprezentaci Unicode. To je vše, co je zapotřebí pro ukládání. * Prvním krokem načítání souboru z disku je zpracování načtených dat. O to se stará metoda loadDataReprezentation:ofType: - v ní nejprve data zkusíme interpretovat jako Unicode a převést na textový řetězec (initWithData:encoding:), který dočasně uložíme do proměnné loaded. Pokud se to podaří (loaded!=nil), vrátí metoda YES, jímž indikuje, že načtení dat se podařilo. * Je-li tomu tak, zavede se (automaticky) dokumentový NIB (přitom se mj. korektně inicializuje outlet text) a zavolá se poslední metoda windowControllerDidLoadNib:. V ní už jen vložíme text z proměnné loaded do textového editoru (setString:) a uvolníme jej (autorelease). Tím jsme dokončili prakticky kompletní textový editor (jediná ze základních služeb, jež zatím nefunguje, je vyhledávání; jinak díky skvěle navrženému objektovému systému Cocoa funguje vše, od práce se soubory a se schránkou přes korektor pravopisu až po Services). Slovník Nyní k editoru přidáme slovník pro automatické doplňování. Nejprve si připravíme model slovníku, tj. implementaci jeho služeb - přidáme do projektu soubory Dict.h a Dict.m, a začneme tím, že v souboru Dict.h navrhneme interface, tj. seznam služeb. Vzhledem k tomu, že jde o velmi jednoduchou úlohu, bude i rozhraní triviální - ani nebudeme připravovat instanci, sestavíme rozhraní jen z metod třídy: @interface Dict : NSObject +(NSString*)wordForPrefix:(NSString*)prefix; // najít slovo pro daný prefix +(void)addWord:(NSString*)word; // přidat do slovníku nové slovo +(void)removeWord:(NSString*)word; // odebrat ze slovníku slovo @end Konkrétní implementace není pro náš příklad důležitá; vzhledem k tomu, že včetně ukládání slovníku do aplikačních předvoleb nezabere ani třicet programových řádků, umístili jsme ji pro případné zájemce do vloženého boxu. Klidně jej však můžete ignorovat - z hlediska toho, čím se v tomto článku zabýváme, není nikterak důležitý. Vazba slovníku na GUI Ačkoli slovník jako takový máme hotový, je třeba ještě napsat pár řádků, které zajistí jeho vazbu na grafické rozhraní. Asi budeme chtít, aby se automaticky hledalo slovo k prefixu, který jsme právě napsali - je tedy zapotřebí se v objektu text podívat, kde je kurzor, najít (částečné) slovo vlevo od něj a to použít jako hledaný prefix... Kromě toho musíme do menu přidat příkazy Complete a Add Word a nějakým způsobem je připojit k odpovídajícím službám. Na to právě využijeme First Responder. Otevřeme hlavní NIB, pomocí inspektoru tříd do First Responderu přidáme služby dictWordAdd: a dictWordComplete: a do menu - standardním "nataháním" z palety - přidáme nové podmenu s patřičnými příkazy. Pak už jen "nadrátujeme" položky menu na odpovídající služby First Responderu; spojení položky Add Word se službou dictWordAdd: ilustruje obr. 4. Uvědomme si, co jsme právě zařídili: kdykoli uživatel vybere položku menu Add Word, najde systém view (ve smyslu MVC) objekt, ve kterém je právě kurzor (tj. First Responder), a pokusí se mu poslat zprávu dictWordAdd:. Pokud to není možné (protože objekt, ve kterém je kurzor, takové zprávě nerozumí), zkouší systém postupně další objekty - především jeho controller. V našem případě se tedy systém pokusí nejprve předat zprávu objektu NSTextView, který jsme umístili do okna v InterfaceBuilderu; pokud se to nepodaří, předá zprávu odpovídajícímu objektu MyDocument. Pro dokončení editoru tedy stačí implementovat metody dictWordAdd: a dictWordComplete: ve třídě MyDocument. Možností je samozřejmě řada; my jsme zvolili nekomplikovanou implementaci, využívající pomocné metody _currentWord, která nalezne vhodné slovo: -(NSString*)_currentWord { NSRange selection=[text selectedRange]; if (selection.length==0) { if (selection.location>0) selection.location-; selection=[[text textStorage] doubleClickAtIndex:selection.location]; [text setSelectedRange:selection]; } if (selection.length==0) return nil; return [[text string] substringWithRange:selection]; } Metoda je poměrně jednoduchá. Nejprve zjistíme, je-li již nějaký text označen. Není-li tomu tak (selection.length==0), pokusíme se najít slovo vlevo od kurzoru: vyžádáme si od textového systému Cocoa (textStorage) vyhledání slova, které by bylo označeno, kdybychom myší poklepali vlevo vedle kurzoru (doubleClickAtIndex: - pro "vlevo" je tam selection.location-). Nalezené slovo označíme (setSelectedRange:). Pak již jen vrátíme označené slovo, je-li jaké - ať již jsme jej označili programově, nebo bylo označeno uživatelem (substringWithRange:). Implementace vlastních akcí dictWordAdd: a dictWordComplete: je pak už triviální a měla by být srozumitelná i bez bližšího vysvětlení: -(void)dictWordComplete:sender { NSString *s=[self _currentWord]; if (s && (s=[Dict wordForPrefix:s])) // mám prefix && slovo ze slovníku [text replaceCharactersInRange:[text selectedRange] withString:s]; } -(void)dictWordAdd:sender { NSString *s=[self _currentWord]; if (s) [Dict addWord:s]; } To je všechno - můžeme aplikaci "zbuildovat" a vyzkoušet. Editor normálně funguje; libovolné slovo můžeme přidat do slovníku tak, že jej označíme a vyvoláme příkaz "Add Word" z menu. Pak stačí jen napsat prefix některého ze slov ze slovníku, vyvolat příkaz Complete a slovo se korektně doplní. Díky tomu, že jsme příkazy v hlavním NIB navázali na First Responder, funguje vše korektně v kterémkoli dokumentovém okně, v němž je právě kurzor, ať jich je najednou otevřeno sebevíc - prostřednictvím First Responderu se zprávy dictWordAdd: a dictWordComplete: pošlou vždy patřičné instanci třídy MyDocument. First Responder a Cocoa toho umějí ještě víc! Doplňování nám tedy funguje korektně ve všech dokumentech. Ale co jinde? Dejme tomu, že někdo otevře standardní panel korektoru pravopisu a pokusí se použít doplňování podle slovníku v jeho textovém poli (obr. 5) - máme problém, tam to nefunguje! Totéž platí pro textová pole panelů pro otvírání a ukládání souborů. Podobný problém také nastane, pokud aplikaci rozšíříme o další panely, třeba o panel pro vyhledávání nebo panel předvoleb... Je vůbec možné zařídit, aby služby Add Word a Complete fungovaly kdekoli, kde zrovna vkládáme text (i kdyby to náhodou bylo textové pole, které je součástí standardního systémového kódu, jako např. panel pro otvírání souborů nebo panel korektoru)? Inu, v klasických API postavených na statických jazycích jako C++ nebo Java by to asi byl neřešitelný problém. V Cocoa je to ale díky dynamické podstatě Objective C a díky skvělému designu First Responderu snadné - dokážeme to za dvě minutky! Především si znovu uvědomíme, co jsme si říkali v minulém odstavci: "... kdykoli uživatel vybere položku menu Add Word, najde systém view (ve smyslu MVC) objekt, ve kterém je právě kurzor (tj. First Responder), a pokusí se mu poslat zprávu dictWordAdd:..." Pokud tedy dokážeme zařídit, aby standardní systémový view objekt NSTextView - kdekoli je použit - rozuměl zprávám dictWordAdd: a dictWordComplete:, máme vyhráno. To ale v Objective C není nic těžkého - vzpomeňme si na třetí díl našeho seriálu, ve kterém jsme si ukazovali možnosti jazyka Objective C. Tehdy jsme si také ukázali kategorie, které umožňují přidávat další, nové služby k již existujícím třídám. Úplně proto stačí připravit kategorii třídy NSTextView, pomocí příkazů Cut a Paste do ní přenést implementaci metod _currentWord, dictWordAdd: a dictWordComplete: ze třídy MyDocument a příkazem "Find" nahradit odkazy na outlet text odkazem na sebe sama (self): @implementation NSTextView (MyWordCompletionCategory) -(NSString*)_currentWord { NSRange selection=[self selectedRange]; if (selection.length==0) { if (selection.location>0) selection.location-; selection=[[self textStorage] doubleClickAtIndex:selection.location]; [self setSelectedRange:selection]; } if (selection.length==0) return nil; return [[self string] substringWithRange:selection]; } -(void)dictWordComplete:sender { NSString *s=[self _currentWord]; if (s && (s=[Dict wordForPrefix:s])) // mám prefix && slovo ze slovníku [self replaceCharactersInRange:[self selectedRange] withString:s]; } -(void)dictWordAdd:sender { NSString *s=[self _currentWord]; if (s) [Dict addWord:s]; } @end To je celé - stačí aplikaci znovu "zbuildovat" a vše funguje: doplňovat můžeme kdekoli, v kterémkoli panelu, v jakémkoli textovém poli. Samozřejmě že stále funguje doplňování i v dokumentech - i ty využívají standardní třídy NSTextView. To je pro dnešek vše... ... příště se pustíme do přehledu tříd Application Kitu. Ondřej Čada Implementace třídy Dict @implementation Dict static NSMutableSet *_set=nil; +(void)_aboutToQuit { [[NSUserDefaults standardUserDefaults] setObject:[_set allObjects] forKey:@"Dictionary"]; } +(NSMutableSet*)_sharedSet { if (!_set) { NSUserDefaults *df=[NSUserDefaults standardUserDefaults]; _set=[[NSMutableSet setWithArray:[df arrayForKey:@"Dictionary"]] retain]; // zajistíme, aby se slovník uložil před ukončením aplikace: [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_aboutToQuit) name:NSApplicationWillTerminateNotification object:NSApp]; } return _set; } +(void)addWord:(NSString*)word { [[self _sharedSet] addObject:word]; } +(void)removeWord:(NSString*)word { [[self _sharedSet] removeObject:word]; } +(NSString*)wordForPrefix:(NSString*)prefix { NSEnumerator *en=[[self _sharedSet] objectEnumerator]; NSString *s; while (s=[en nextObject]) if ([s hasPrefix:prefix]) return s; return nil; } @end