Overblog Suivre ce blog
Administration Créer mon blog
6 novembre 2013 3 06 /11 /novembre /2013 20:22

先生、本当にありがとうございました!

 

photo.JPG

Repost 0
Published by Olivier - dans Projets personnels
commenter cet article
8 septembre 2013 7 08 /09 /septembre /2013 17:45

Une des nouvelles fonctionnalités que j'ai ajoutées à Rossignol dans la version 0.2 est la cohabitation avec Firefox. Il ne s'agit bien évidemment pas d'une version de Rossignol réécrite en JavaScript et XUL, mais d'un ensemble de fonctionnalités ajoutées permettant d'interagir sans peine avec le navigateur.

 

État des lieux des flux avec Firefox

Lorsque l'on affiche un flux RSS ou Atom avec Firefox, celui-ci nous propose de s'y abonner, via son système de marque-pages dynamiques, mais propose aussi d'utiliser une application tierce pour cela. Il suffit alors de parcourir l'arborescence du disque et d'indiquer à Firefox l'exécutable à utiliser.

 

screen.png

 

Lorsque l'on s'abonne à un flux de cette manière, Firefox se contente de lancer l'exécutable sélectionné et de lui passer comme paramètre l'URL du flux avec le préfixe d'URI feed :

 

 rossignol feed://www.monsite.com/flux.xml 

 

À charge alors à l'application de s'abonner le flux passé en paramètre. Jusqu'ici, tout va bien.

Ce qui se complique un petit peu, c'est ce qui se passe lorsque l'application en question est déjà en cours d'exécution. Il faut bien comprendre qu'en l'état actuel des choses, nous risquons d'avoir une instance de l'application avec N flux et une autre avec N+1 flux. Tout ce petit monde doit donc communiquer pour synchroniser sa configuration afin que :

  • L'instance 1 n'écrase pas la configuration de l'instance 2 avec la sienne (qui compte un flux de moins).
  • L'utilisateur ait toujours à l'écran quelque chose de cohérent par rapport à ses actions, et non pas un état antérieur celles-ci.

 

La solution retenue

Rossignol ne sauvegarde sa configuration qu'à la fermeture de l'application. Le cas n°1 pourrait donc se produire si l'utilisateur fermait la première instance de Rossignol après la deuxième. J'ai donc choisi volontairement d'interdire l'ouverture de plusieurs instances de Rossignol par un même utilisateur. Au démarrage, on vérifie que l'on est la seule instance en cours d'exécution, si c'est le cas, on démarre normalement. Sinon, on se connecte à l'autre instance par un mécanisme de communication interprocessus et on lui envoie nos arguments de ligne de commande avant de s'arrêter promptement.

 

Comment tester que la présence d'une autre instance de l'application ?

Sous Windows, il existe plusieurs possibilités, mais la solution recommandée par Microsoft est d'utiliser un Mutex nommé. Le nom du mutex doit être préfixé par « Global\ » pour indiquer au système que l'on veut un mutex visible pour tout le système.

 
 auto handle = CreateNamedMutexW(null, true, name);

if (handle is null)
{
// erreur
}
else if (GetLastError() == ERROR_ALREADY_EXISTS)
{
// une autre instance est en cours d'exécution
}
else
{
// On est la seule instance de l'application
}

 

Sous Linux, il existe une fonction similaire pour créer un sémaphore, mais ce n'est pas la solution que j'ai retenue. Il faut savoir que sous Linux, en cas de crash de l'application, le sémaphore n'est pas libéré. C'est à l'utilisateur de saisir une commande shell pour libérer le sémaphore. J'ai donc préféré utiliser un simple fichier ouvert en création exclusive.

 
 auto handle = open(name, O_WRONLY | O_CREAT | O_EXCL);
if (handle < 0)
{
if (errno == EEXIST)
{
// Une autre instance est en cours d'exécution
}
else
{
// erreur
}
}
else
{
// On est la seule instance de l'application
}

 

Si le programme venait à planter et à ne pas supprimer le fichier, l'utilisateur n'aura qu'à supprimer ce fichier, ce qui est beaucoup plus accessible pour lui. Si vous connaissez une méthode plus sûre que celle-là, je suis preneur.

 

Comment communiquer entre processus ?

 

Sous Windows, la communication entre les processus est assurée par un pipe nommé. C'est assez simple à mettre en œuvre, surtout pour une communication dans un seul sens (client vers serveur). Je vous épargne le code de gestion des erreurs :

 

 // Code serveur
// Creation d'un pipe
auto handle = CreateNamedPipeW(toUTF16z(name),
PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE | FILE_FLAG_OVERLAPPED,
PIPE_TYPE_BYTE | PIPE_WAIT,
1,
2048,
2048,
0,
null);

// Attente d'une connexion d'un client
auto success = ConnectNamedPipe(m_handle, null);

// Lecture d'un message
success = ReadFile(handle, buffer.ptr, buffer.length, &bytesRead, null);

// Déconnecte immédiatement le client
DisconnectNamedPipe(m_handle);



// Code client
// Création d'un pipe
auto handle = CreateFileW(toUTF16z(name),
GENERIC_WRITE,
0,
null,
OPEN_EXISTING,
0,
null);
// Envoi d'un message
auto success = WriteFile(m_handle,
msg.ptr,
msg.length,
&bytesWritten,
null);

 

Sous Linux, les pipes nommés sont assez différents conceptuellement de leur homonymes Windows. J'ai donc utilisé les sockets UNIX à la place, qui fonctionnent comme les pipes nommés sous Windows. Là aussi, pour des raisons de concision, j'ai enlevé le code de gestion des erreurs.

 

 // Code serveur
// Création d'un socket serveur
auto handle = socket(AF_UNIX, SOCK_STREAM, 0);
sockaddr_un local;
local.sun_family = AF_UNIX;
strcpy(cast(char*)local.sun_path.ptr, szName);
unlink(szName);
uint len = cast(uint)(strlen(name) + local.sun_family.sizeof);
auto success = bind(ipc.m_handle, cast(const sockaddr*)&local, len);

success = listen(ipc.m_handle, 1);

// Attente d'une connexion d'un client
sockaddr_un remote;
uint t = cast(uint)remote.sizeof;
auto client_handle = accept(m_handle, cast(sockaddr*)&remote, &t);

// lecture d'un message
auto bytesRead = recv(client_handle, buffer.ptr, buffer.length, 0);

// déconnecte immédiatement le client
close(client_handle);



// Code client
// Création d'un socket client
auto handle = socket(AF_UNIX, SOCK_STREAM, 0);

// connexion
sockaddr_un remote;
remote.sun_family = AF_UNIX;
strcpy(cast(char*)remote.sun_path, szName);
uint len = cast(uint)(strlen(szName) + remote.sun_family.sizeof);
auto success = connect(ipc.m_handle, cast(const sockaddr*)&remote, len);

// Envoi d'un message
auto bytesWritten = send(m_handle, msg.ptr, msg.length, 0);

Conclusion

La mise en place d'une bonne intégration de Rossignol dans le menu des flux de Firefox a nécessité quelques aménagements dans le code, notament du code spécifique à chaque plate-forme. La capacité de D à appeler du code C directement sans passer par un wrapper à la JNI ou P/Invoke prend ici tout son sens.

 

Comme en C++, le RAII reste la meilleure méthode pour s'assurer de la bonne libération des ressources, que ce soit en utilisant le destructeur d'une structure sur la pile ou l'instruction scope.

Repost 0
Published by Olivier - dans Projets personnels
commenter cet article
4 septembre 2013 3 04 /09 /septembre /2013 15:12

 

J'ai sorti hier une deuxième version alpha de Rossignol. Pour fêter cela, j'ai décidé de consacrer un autre petit billet à ce nouveau projet. Nous avions vu la dernière fois les choix que j'avais fait en matière de bibliothèque graphique et de parsing XML. Cette fois je vais vous parler de threading.

Garder une interface réactive

 

Dans un logiciel avec une interface graphique, la tâche principale de l'application est une boucle d'événements. L'application se contente d'attendre un message que lui envoie le gestionnaire de fenêtre (un événement), le traite et se remet en attente d'un message suivant. Selon le système, cette boucle de message peut prendre plusieurs formes, mais avec SWT, ça ressemble à ça :

 

while (!window.isDisposed())

{

  if (!display.readAndDispatch())

    display.sleep();

}

 

 

Si l'application met trop de temps à répondre à un événement, elle aura du retard pour traiter l'événement suivant. Les conséquences pour l'utilisateur en seront immédiates : l'interface du programme (ses fenêtres et boutons) ne s'actualisera plus pendant quelques temps. Elle ne réagira pas à aucune sollicitation. Elle sera comme figée et il semblera à l'utilisateur que le programme a planté, jusqu'à ce que le traitement sont terminé.

 

Pour pallier ce problème, une solution consiste à faire effectuer tous les traitements longs dans des threads séparés (on parle de « worker threads »). Ainsi, pendant que le thread principal continue de traiter les nouveaux messages qui lui arrivent, l'application peut effectuer des traitements longs. C'est pour cela que les ingénieurs de chez Microsoft, en concevant la nouvelle API Windows RT, ont imposé que toute opération pouvant prendre plus de 50μs se fasse dans un thread séparé. Même si je n'utilise pas leur API ni leur nouveau système d'exploitation, c'est une consigne que j'ai souhaité suivre pour la réalisation de Rossignol.

 

À l'issue de ces traitements, les worker threads ont souvent le besoin de modifier l'affichage de l'application, par exemple, pour afficher le résultat d'un calcul. Et c'est là que les choses se compliquent. La plupart des gestionnaires de fenêtre ne gèrent pas la modification concurrente de l'interface par des threads séparés. La plupart de bibliothèques imposent donc la contrainte suivante : toute modification de l'interface doit être faite dans le thread principal (celui qui s'occupe de la boucle de messages).

 

Chaque bibliothèque propose donc en général d'un mécanisme pour faire renvoyer du « travail à faire » d'un worker thread vers le thread principal. En SWT, ce sont les méthodes syncExec et asyncExec de la classe Display.

 

Tout ceci est fort joli, mais il s'agit d'une solution Java, un langage étranger à Rossignol (qui ne contient aucune ligne de Java). Voyons donc comment D gère les threads, dans tout ça…

 

 

Le threading en D

 

 

D est un langage moderne dont la première version stable est apparue après l'avènement des microprocesseurs multicœurs grand public. Il est a donc tout naturellement inclus les problématiques de gestion du multitâche dans son système de type. Les concepteurs de D ont fait plusieurs constats :

  • Les bugs liés au multithreading sont essentiellement dus à une mauvaise synchronisation des accès à des ressources partagées entre les threads.

  • Dans la plupart des langages, la mémoire est implicitement partagée entre les threads.

 

Dès lors, ils ont choisi d'inverser le problème en interdisant par défaut le partage de variable entre les threads :

  • Par défaut, les variables globales sont thread-local. Sauf si elles sont implicitement marquées shared (qui est un qualificateur de type).

  • Les constantes (objets immuables) sont implicitement partagées et n'ont pas besoin d'être synchronisées.

  • Les objets shared doivent faire l'objet de précautions particulières dans leur manipulation.

 

Le threading de bas niveau est implémenté dans DRuntime (modules core.*). C'est l'environnement d'exécution de D, qui est constitué de code spécifique à un système ou un compilateur donné. La classe core.thread.Thread en particulier se charge de créer un thread au moyen de sa méthode start. C'est le pendant de java.lang.Thread. C'est la solution à utiliser à bas niveau, si l'on veut avoir le maximum de contrôle ou si l'on doit écrire du code pas vraiment idiomatique, comme par exemple utiliser certaines fonctionnalités écrites à l'origine pour un autre langage.

 

Au-dessus de ceci, la bibliothèque standard (modules std.*) propose deux modules distincts.

 

Le premier s'appelle std.concurrency. C'est historiquement le premier à avoir été implémenté. Il propose un système de messagerie entre threads. Le module se charge de vérifier à la compilation que les threads ne puissent s'échanger que :

  • Des types valeurs

  • Des types références thread-safe (classes synchronized ou immutable par exemple).

 

Ces contraintes permettent de garantir que l'accès à des ressources partagées est explicite et contrôlé. Ce module est utile lorsqu'un nombre défini de threads hétérogènes ont des gros besoins de communication. C'est encore malgré tout un module d'assez bas niveau puisque la création de threads est explicite et contrôlée par l'utilisateur.

 

Le second module s'appelle std.parallelism. Celui-ci est basé sur des primitives de haut niveau : foreach parallèle, map et reduce parallèles, ainsi qu'un concept de tâches similaires aux futures et promises de C++11.

 

Si vous avez l'expérience du package java.util.concurrent de Java 5 ou de Grand Central Dispatch de Objective-C, vous serez également en terrain connu : la classe TaskPool se comporte de manière similaire à Executor de Java ou aux dispatch queues d'Apple : c'est une FIFO à laquelle on soumet des tâches à exécuter qui seront dispatchées automatiquement sur un nombre donné de threads.

 

Ce module est donc approprié lorsque la même opération doit être exécutée en parallèle sur des données différentes, ou qu'une opération doit être effectuée en tâche de fond avant d'être retournée à un thread bien spécifique.

 

 

Et Rossignol dans tout ça ?

 

Si je ne vous ai pas déjà perdus, vous aurez compris de quelles solutions on a besoin en développant une application DWT :

  • Pour toute tâche où l'on aurait besoin d'un java.lang.Thread ou si l'on doit contourner le système de type pour utiliser SWT, il faut utiliser core.thread.Thread.

  • Pour le reste, quand on a besoin d'un traitement long à lancer en tâche de fond, std.parallelism et ses classes Task et TaskPool font merveille.

 

Repost 0
Published by Olivier - dans Projets personnels
commenter cet article
30 août 2013 5 30 /08 /août /2013 13:21

Bonjour,

Je prends la plume aujourd'hui pour vous présenter un nouveau projet sur lequel j'œuvre depuis environ un mois : Rossignol, un agrégateur de flux RSS pour environnements de bureau.

 

screen_rossignol.png

 

L'envie de tester un peu les bibliothèques d'interface utilisateur graphique en D m'a pris. D'après les forums, l'une des plus abouties est DWT, un binding de SWT, la bibliothèque du projet Eclipse, l'environnement de développement intégré populaire dans le monde Java. Point n'est question de Java ici, puisque DWT est 100% native, mais le code à écrire sera très Java-esque, et pas vraiment idiomatique.

Mais revenons à nos moutons, ou devrais-je dire, à nos rossignols. Quand j'ai vu avec quelle relative facilité j'arrivais à faire une interface graphique fonctionnelle avec DWT, il m'a pris l'envie de lancer un petit projet autour de ça. Rossignol était né.

Je n'ai jamais trouvé chaussure à mon pied en matière d'agrégateur RSS. Du coup, pourquoi ne pas faire le mien ? Après tout, ça ne doit pas être très compliqué…

 

XML, mon amour

 

Autant le dire tout de suite, le module std.xml de la bibliothèque standard de D est mauvais. Sa documentation le reconnait comme obsolète et voué à être remplacé par quelque chose de mieux. Dès lors, il me semblait totalement absurde de l'utiliser. J'avais donc le choix entre utiliser un binding vers une bibliothèque C/C++ existante et réécrire un parser moi-même. C'est cette dernière option que j'ai choisie.

Contrairement à C et C++, D a une manière bien à lui de gérer les chaines de caractères. Ce sont des tableaux de caractères Unicode immuables. En outre, les tableaux de D ont des propriétés intéressantes que n'ont pas les tableaux C :

  • Ils connaissent leur taille
  • Ils sont découpables en sous-tableaux (slices), un peu comme les listes en Python. Un slice est simplement une paire de pointeurs qui fonctionne comme une vue d'un ensemble plus grand.

Ces propriétés permettent de tirer quelques conclusions :

  • Dans un programme multithread, on n'a pas besoin de synchroniser les accès aux chaines de caractères. Elles peuvent être partagées librement entre les threads, puisqu'elles sont immuables.
  • Pour prendre une sous-chaine à partir d'une chaine plus grande, on ne copie pas celle-ci, on la slice (ce qui revient à copier deux pointeurs). Dans le cadre d'un parseur événementiel (à la SAX), cela augure d'excellentes performances par rapport au C (où il faut forcément faire une copie pour pouvoir rajouter le 0 terminal).

Ce sont ces deux conclusions m'ont incité à tenter d'écrire moi-même le parsing XML de Rossignol. XML n'étant pas un format atrocement compliqué, j'avais un prototype fonctionnel en moins d'une semaine. La prise en charge d'Unicode directement par le langage, la programmation par contrats et les tests unitaires intégrés sont les trois fonctionnalités de D que j'ai le plus appréciées durant cet exercice. Le langage permet de spécifier des contraintes à vérifier avant et après chaque brique de la chaine logicielle. Avec ça, dès que l'on casse quelque chose, on reçoit une exception AssertError au démarrage nous indiquant quelle contrainte a été violée, ce qui fait gagner un temps fou en mise au point.

 

DWT, la bibliothèque graphique

 

Venons en maintenant à DWT. Comme je l'ai déjà dit, c'est en quelque sorte un portage en D de SWT. On écrit du D comme si on écrivait du Java. Les langages étant très proches du point de vue syntaxique, on peut presque copier/coller les snippets de code de SWT et les recompiler tels quels. Ça rend la documentation très simple, c'est un bon point.

En revanche, le style Java n'est pas le meilleur qui soit pour D : devoir instancier une classe pour gérer un événement alors qu'on pourrait utiliser un delegate n'est pas optimal. En plus, cela nécessite d'utiliser le ramasse-miettes là où on pourrait s'en passer (allocation sur la pile pour les classes à sémantique de valeur comme Point ou Size, par exemple) : On met inutilement la pression sur le GC.

A contrario, certaines fonctionnalités de D sont les bienvenues. Là où  SWT prohibe l'héritage en Java de ses widgets, le alias this de D permet d'ajouter des fonctionnalités à une classe sans en dériver.

J'ai opté pour une interface épurée. Seules les fonctionnalités essentielles doivent être mises en avant. Nous autres geeks avons souvent le défaut de vouloir rajouter le plus de fonctionnalités possibles dans l'interface. Je crois que c'est un défaut. Il n'y a qu'à comparer les interfaces des navigateurs Web entre les années 1990 et les années 2010. Alors que le nombre de fonctionnalités a explosé, les interfaces se sont faites de moins en moins chargées et vont directement à l'essentiel. C'est cette ligne directrice que je me suis fixée pour ce projet. Je me refuse par exemple à rajouter une quatrième colonne dans le panneau de droite alors que j'aimerai bien pouvoir classer les articles par catégorie. Je vais devoir réfléchir à une autre manière de fournir cette fonctionnalité (les suggestions sont les bienvenues).

 

Le bazar des flux

 

Connaissez-vous cette petite BD xkcd sur les standards ? Et bien elle pourrait coller à merveille pour le monde merveilleux de la syndication de contenu Web. Si l'histoire de RSS vous intéresse, je vous suggère de lire sa page Wikipédia, mais pour faire les chose simplement, voici l'état des lieux des formats les plus utilisés aujourd'hui :

  • L'évolution historique du premier format RSS de Netscape est RSS 1.1 (RSS veut ici dire Rich Site Summary). Il utilise des éléments de l'espace de nom RDF et pallie souvent son manque de fonctionnalités avec des extensions, par exemple des tags du Dublin Core.
  • Le format RSS 2.0 (RSS voulant dire ici Really Simple Syndication) est le format le plus utilisé de nos jours. Il est incompatible avec le premier et fournit les fonctionnalités les plus courantes absentes de l'autre. Comme son nom l'indique, c'est un format simple.
  • Atom est un standard IETF (RFC 4287) dont la vocation est de fournir un format très détaillé et le plus exhaustif en fonctionnalités possible. Par exemple, un même article peut avoir plusieurs auteurs et/ou contributeurs, il sait gérer de multiples types de lien (internes et externes) ainsi que de multiples langues.


Autour de ces trois familles de flux gravitent des formats hybrides (comme des flux RSS 2.0 utilisant des dates issues du Dublin Core) ainsi que des microformats dédiés à une application particulière. Bref c'est le bazar ! :)

 

Conclusion

 

Voilà pour cette petite introduction à mon nouveau projet. Il y a encore beaucoup à dire mais je ne voudrais pas saturer mon auditoire. Je consacrerai un prochain billet à expliquer comment est géré le parallélisme dans Rossignol. Le langage D propose plusieurs approches du threading (de plus ou moins haut niveau) et je vous parlerai une prochaine fois des solutions que j'ai retenues.

Repost 0
Published by Olivier - dans Projets personnels
commenter cet article
29 octobre 2010 5 29 /10 /octobre /2010 11:48

 

Ayant égaré mon accordeur, je me suis lancé dans la réalisation d'un équivalent logiciel. J'ai une première version fonctionnelle au bout d'une journée de travail. N'ayant pas d'adaptateur pour brancher mon instrument sur mon ordinateur (on ne se moque pas), j'ai mis de côté l'idée de réaliser un accordeur à reconnaissance de fréquence, pour me rabattre – provisoirement – sur un générateur sonore. En gros l'équivalent numérique d'un de ces vieux accordeurs à sifflets de grand-papa, ce qui aura l'avantage de faire travailler l'oreille :

 

accordeur01

 

J'ai prévu l'utilisation d'une bibliothèque d'accordages la plus complète possible. Nos amis guitaristes étant très inventifs à ce niveau-là, celle-ci est extensible.

 

accordeur

Génération du son

 

Le framework .net « out-of-the-box » n'étant pas très riche en fonctionnalités multimédias, j'ai décidé de faire appel au composant dédié au son de DirectX, j'ai nommé DirectSound. Il existe un excellent binding open source pour accéder à DirectX depuis .net, SlimDX. La génération du son se fait en réservant un tampon dans la mémoire de la carte son et en écrivant un signal sinusoïdal de la bonne fréquence dans celui-ci.

 

 SecondarySoundBuffer buffer = new SecondarySoundBuffer(directSound, desc2);

short[] data = new short[44100 * 8];
double angle = 0.0;
for (int i = 0; i < data.Length; ++i)
{
data[i] = (short)(32767 * System.Math.Sin(angle));
angle += 2 * System.Math.PI * freq / 44100;
if (angle > 2 * System.Math.PI)
{
angle -= 2 * System.Math.PI;
}
}
buffer.Write<short>(data, 0, LockFlags.None);
buffer.Play(0, PlayFlags.None);

 

Calcul de la fréquence d'une note de musique

 

Pour trouver la fréquence à utiliser pour chaque note de musique, un petit calcul s'impose. La note de référence internationalement admise est le LA 5e octave à 440Hz*.

Sachant que la fréquence d'une note double lorsqu'on monte d'une octave, et qu'une octave est divisée en 12 demi-tons égaux**, on en déduit facilement la formule de calcul de la fréquence :

 

F = A * pow(pow(2, 1.0 / 12.0), n)

 

Où A est la fréquence de la note de référence (ici 440) et n le nombre de demi-tons qui séparent la note courante de la note de référence.

 

* : Certains musiciens (comme Francis Darizcuren) aiment prendre une note de référence un peu plus haute, comme 442Hz afin de rajouter un peu de brillance au son. C'est une fonctionnalité que je prévois d'implémenter dans une future version.

** : Une guitare utilise un tempérament modéré, on ne fait donc pas de distinction entre les demi-tons chromatiques et les demi-tons diatoniques.

Repost 0
Published by Olivier - dans Projets personnels
commenter cet article
3 octobre 2010 7 03 /10 /octobre /2010 13:31

Voici billet plutôt court pour signaler la sortie de ComiqueBouc en version 1.1.



ComiqueBouc est — comme son nom peut l'indiquer pour un francophone ayant quelques notions de la langue de Shakespeare et un bon coup dans le nez — un lecteur de bandes dessinées numériques.

Cette version est la première publiée officiellement sur Internet, sous licence GPL. C'est avec une certaine satisfaction personnelle que je franchis le cap en publiant à la fois ses sources et des binaires prêts à l'emploi.

Pourquoi ce projet ?

Je suis un utilisateur convaincu de CDisplay sous Windows depuis plusieurs années. Malheureusement, le développement de ce dernier semble s'être arrêté depuis quelque temps et il lui manque certaines fonctionnalités modernes. Il n'est par exemple pas possible d'ouvrir des documents compressés avec l'algorithme de 7zip. J'ai donc pris sur moi de développer rapidement un équivalent à CDisplay qui serait en mesure de répondre à ce besoin.

Prérequis

Un système Windows moderne avec .net version 3.5 ou supérieur installé. C'est la version livrée de base avec Windows 7. Je suppose qu'il doit être possible de l'exécuter également avec Mono, mais je n'ai pas essayé.

Pour le développement, les sources contiennent un projet Visual C# 2010 prêt à l'emploi. Deux DLLs supplémentaires (livrées dans les binaires) sont en outre nécessaires exécuter le projet.

Nouveautés

Cette version apporte l'affichage/navigation par aperçus de vignettes. On peut sélectionner une page et l'atteindre en double-cliquant sur la vignette correspondante. Comme une illustration vaut mieux qu'un long discours, voici une capture d'écran illustrant cette fonctionnalité.


screenshot-cb-1.1

Téléchargement

Vous pouvez télécharger ComiqueBouc et/ou ses sources sur la page SourceForge du projet.

Repost 0
Published by Olivier - dans Projets personnels
commenter cet article

Présentation

Recherche

Liens