From 9a89f97910fa9978de831beba359d945cfbf1cd0 Mon Sep 17 00:00:00 2001 From: Sergiotarxz Date: Tue, 26 Nov 2024 19:06:53 +0100 Subject: [PATCH] Adding kanji support, not to be released yet. --- lib/JapaChar.pm | 15 +- lib/JapaChar/DB.pm | 7 +- lib/JapaChar/DB/Migrations.pm | 41 +++ lib/JapaChar/Kanji.pm | 253 +++++++++++++++++ lib/JapaChar/Schema/Result/Kanji.pm | 99 +++++++ .../Schema/Result/KanjiKunReadings.pm | 33 +++ lib/JapaChar/Schema/Result/KanjiMeanings.pm | 33 +++ lib/JapaChar/Schema/Result/KanjiOnReadings.pm | 33 +++ lib/JapaChar/View/KanjiLesson.pm | 221 +++++++++++++++ lib/JapaChar/View/KanjiTestExercise.pm | 266 ++++++++++++++++++ lib/JapaChar/View/MainMenu.pm | 34 ++- lib/JapaChar/View/SelectKanjiLesson.pm | 146 ++++++++++ me.sergiotarxz.JapaChar.metainfo.xml | 4 +- 13 files changed, 1169 insertions(+), 16 deletions(-) create mode 100644 lib/JapaChar/Kanji.pm create mode 100644 lib/JapaChar/Schema/Result/Kanji.pm create mode 100644 lib/JapaChar/Schema/Result/KanjiKunReadings.pm create mode 100644 lib/JapaChar/Schema/Result/KanjiMeanings.pm create mode 100644 lib/JapaChar/Schema/Result/KanjiOnReadings.pm create mode 100644 lib/JapaChar/View/KanjiLesson.pm create mode 100644 lib/JapaChar/View/KanjiTestExercise.pm create mode 100644 lib/JapaChar/View/SelectKanjiLesson.pm diff --git a/lib/JapaChar.pm b/lib/JapaChar.pm index 42e7c2b..24a4f58 100644 --- a/lib/JapaChar.pm +++ b/lib/JapaChar.pm @@ -53,7 +53,12 @@ has _gresources_path => ( is => 'lazy', ); has _window => ( is => 'rw' ); has _on_resize_triggers => ( is => 'ro', default => sub { {}; } ); has accessibility => ( is => 'lazy' ); -has characters => ( is => 'lazy' ); +has characters => ( is => 'lazy' ); +has kanji => ( is => 'lazy' ); + +sub _build_kanji($self) { + return JapaChar::Kanji->new(app => $self); +} sub _build_characters($self) { require JapaChar::Characters; @@ -62,7 +67,7 @@ sub _build_characters($self) { sub _build_accessibility($self) { require JapaChar::Accessibility; - return JapaChar::Accessibility->new(app => $self); + return JapaChar::Accessibility->new( app => $self ); } sub root($self) { @@ -70,7 +75,7 @@ sub root($self) { } sub _build__gresources_path($self) { - my $root = $self->root; + my $root = $self->root; my $gresources = $root->child('resources.gresource'); 0 == system( 'which', 'glib-compile-resources' ) && system( 'glib-compile-resources', $root->child('resources.xml') ); @@ -83,7 +88,7 @@ sub _build__gresources_path($self) { } sub launch_discord($self) { - my $launcher = Gtk::UriLauncher->new( 'https://discord.gg/qsvzSJPX' ); + my $launcher = Gtk::UriLauncher->new('https://discord.gg/qsvzSJPX'); $launcher->launch( $self->_window, undef, undef ); } @@ -124,7 +129,7 @@ sub window_set_child( $self, $child ) { sub _application_start( $self, $app ) { my $main_window = Adw::ApplicationWindow->new($app); $self->_window($main_window); - $main_window->set_default_size( 1200, 600 ); + $main_window->set_default_size( 1200, 800 ); $main_window->signal_connect( notify => sub( $object, $param ) { if ( $param->{name} eq 'default-width' ) { diff --git a/lib/JapaChar/DB.pm b/lib/JapaChar/DB.pm index c1a1f87..466e624 100644 --- a/lib/JapaChar/DB.pm +++ b/lib/JapaChar/DB.pm @@ -99,7 +99,12 @@ sub _apply_migration { $current_migration->($dbh); next; } - $dbh->do($current_migration); + eval { + $dbh->do($current_migration); + }; + if ($@) { + die "$current_migration\n$@" + } } $dbh->do( <<'EOF', undef, 'current_migration', $migration_number ); INSERT INTO options diff --git a/lib/JapaChar/DB/Migrations.pm b/lib/JapaChar/DB/Migrations.pm index 2d83cdc..650d52c 100644 --- a/lib/JapaChar/DB/Migrations.pm +++ b/lib/JapaChar/DB/Migrations.pm @@ -25,6 +25,47 @@ sub MIGRATIONS { );', 'INSERT INTO options (name, value) VALUES (\'user_score\', \'0\');', 'ALTER TABLE basic_characters ADD consecutive_failures INTEGER NOT NULL DEFAULT 0', + 'CREATE TABLE kanji ( + id INTEGER PRIMARY KEY, + kanji TEXT NOT NULL UNIQUE, + grade INTEGER NOT NULL, + started BOOLEAN NOT NULL DEFAULT 0, + score INTEGER NOT NULL DEFAULT 0, + consecutive_success INTEGER NOT NULL DEFAULT 0 + );', + 'CREATE TABLE kanji_meanings ( + id INTEGER PRIMARY KEY, + id_kanji INTEGER NOT NULL, + meaning TEXT NOT NULL, + FOREIGN KEY (id_kanji) REFERENCES kanji(id) + );', + 'CREATE TABLE kanji_on_readings ( + id INTEGER PRIMARY KEY, + id_kanji INTEGER NOT NULL, + reading TEXT NOT NULL, + FOREIGN KEY (id_kanji) REFERENCES kanji(id) + );', + 'CREATE TABLE kanji_kun_readings ( + id INTEGER PRIMARY KEY, + id_kanji INTEGER NOT NULL, + reading TEXT NOT NULL, + FOREIGN KEY (id_kanji) REFERENCES kanji(id) + );', + 'INSERT INTO options (name, value) VALUES (\'kanji_version\', \'0\');', + 'INSERT INTO options (name, value) VALUES (\'want_kanji_version\', \'1\');', + 'ALTER TABLE kanji ADD consecutive_failures INTEGER NOT NULL DEFAULT 0;', + 'CREATE TABLE kanji2 ( + id INTEGER PRIMARY KEY, + kanji TEXT NOT NULL UNIQUE, + grade INTEGER, + started BOOLEAN NOT NULL DEFAULT 0, + score INTEGER NOT NULL DEFAULT 0, + consecutive_success INTEGER NOT NULL DEFAULT 0, + consecutive_failures INTEGER NOT NULL DEFAULT 0 + );', + 'INSERT INTO kanji2 SELECT * FROM kanji;', + 'DROP TABLE kanji;', + 'ALTER TABLE kanji2 RENAME TO kanji;', ); } 1; diff --git a/lib/JapaChar/Kanji.pm b/lib/JapaChar/Kanji.pm new file mode 100644 index 0000000..ebcfd15 --- /dev/null +++ b/lib/JapaChar/Kanji.pm @@ -0,0 +1,253 @@ +package JapaChar::Kanji; + +use v5.40.0; + +use strict; +use warnings; + +use Data::Dumper; + +use Mojo::DOM; + +use JapaChar::Schema; + +use Moo; + +use Encode qw/decode/; + +has app => ( is => 'ro', required => 1 ); +has _kanji_schema => ( is => 'lazy' ); +has _options_schema => ( is => 'lazy' ); +has _schema => ( is => 'lazy' ); + +sub _build__schema($self) { + return JapaChar::Schema->Schema; +} + +sub _build__kanji_schema($self) { + return $self->_schema->resultset('Kanji'); +} + +sub _build__options_schema($self) { + return $self->_schema->resultset('Option'); +} + +sub grades($self) { + my @grades = + grep { defined $_ } + map { $_->grade } + $self->_kanji_schema->search( {}, + { columns => ['grade'], distinct => 1 } ); + return \@grades; +} + +sub get_4_incorrect_answers( $self, $char, $guess) { + if ($guess->isa('JapaChar::Schema::Result::KanjiMeanings')) { + my %already_present_guesses; + my $invalid_results = [map { $_->meaning } $char->meanings]; + my $meanings_resultset = $self->_schema->resultset('KanjiMeanings'); + my @possible_meanings = map { $_->meaning } $meanings_resultset->search({ + -bool => 'kanji.started', + meaning => { -not_in => $invalid_results }, + }, + { + order_by => { -asc => \'RANDOM()' }, + rows => 4, + join => 'kanji', + } + ); + return \@possible_meanings; + } + if ($guess->isa('JapaChar::Schema::Result::KanjiOnReadings')) { + my %already_present_guesses; + my $invalid_results = [map { $_->reading } $char->on_readings]; + my $readings_resultset = $self->_schema->resultset('KanjiOnReadings'); + my @possible_readings = map { decode 'utf-8', $_->reading } $readings_resultset->search({ + -bool => 'kanji.started', + reading => { -not_in => $invalid_results }, + }, + { + order_by => { -asc => \'RANDOM()' }, + rows => 4, + join => 'kanji', + } + ); + return \@possible_readings; + } + if ($guess->isa('JapaChar::Schema::Result::KanjiKunReadings')) { + my %already_present_guesses; + my $invalid_results = [map { $_->reading } $char->kun_readings]; + my $readings_resultset = $self->_schema->resultset('KanjiOnReadings'); + my @possible_readings = map { decode 'utf-8', $_->reading } $readings_resultset->search({ + -bool => 'kanji.started', + reading => { -not_in => $invalid_results }, + }, + { + order_by => { -asc => \'RANDOM()' }, + rows => 4, + join => 'kanji', + } + ); + return \@possible_readings; + } +} + +sub migrated($self) { + my ($option_want_kanji_version) = + $self->_options_schema->search( { name => 'want_kanji_version' } ); + my ($option_kanji_version) = + $self->_options_schema->search( { name => 'kanji_version' } ); + if ( $option_kanji_version->value >= $option_want_kanji_version->value ) { + return 1; + } + return 0; +} + +sub populate_kanji( $self, $parent_pid, $write ) { + $self->_schema->txn_do( + sub { + my ($option_want_kanji_version) = + $self->_options_schema->search( + { name => 'want_kanji_version' } ); + my ($option_kanji_version) = + $self->_options_schema->search( { name => 'kanji_version' } ); + if ( $self->migrated ) { + say 'You already have the kanji database'; + return; + } + say 'Populating Kanji database, please wait...'; + my $schema = $self->_kanji_schema; + my $root = $self->app->root; + my $dom = + Mojo::DOM->new( $root->child('kanjidic2.xml')->slurp_raw ); + $dom->xml(1); + my @characters; + my $i = 0; + + my @characters_dom = + grep { $_->type eq 'tag' && $_->tag eq 'character' } + $dom->at('kanjidic2')->child_nodes->each; + $write->syswrite( ( scalar @characters_dom ) . "\n" ); + $write->flush; + for my $character_dom (@characters_dom) { + if ( !kill 0, $parent_pid ) { + die 'Parent died'; + } + my $literal = $character_dom->at('literal')->text; + my $grade; + my $grade_dom = $character_dom->at('grade'); + if ( defined $grade_dom ) { + $grade = $grade_dom->text; + } + my @meanings; + for my $meaning_dom ( $character_dom->find('meaning')->each ) { + next if scalar %{ $meaning_dom->attr }; + push @meanings, { meaning => $meaning_dom->text, }; + } + my @readings_on; + my @readings_kun; + for my $reading_dom ( $character_dom->find('reading')->each ) { + if ( $reading_dom->attr('r_type') eq 'ja_on' ) { + push @readings_on, { reading => $reading_dom->text, }; + } + if ( $reading_dom->attr('r_type') eq 'ja_kun' ) { + push @readings_on, { reading => $reading_dom->text, }; + } + } + push @characters, + { + id => $i++, + kanji => $literal, + grade => $grade, + meanings => \@meanings, + on_readings => \@readings_on, + kun_readings => \@readings_kun, + }; + if ( $i % 300 == 0 ) { + $self->_kanji_schema->populate( \@characters ); + $write->syswrite( $i . "\n" ); + $write->flush; + @characters = (); + } + } + $self->_kanji_schema->populate( \@characters ); + $write->syswrite( scalar @characters_dom . "\n" ); + $write->flush; + $option_kanji_version->update( + { value => $option_want_kanji_version->value } ); + say 'Populated kanji database'; + } + ); +} + +sub next_char( $self, $accesibility, $type = undef ) { + my $next_review = $self->_next_review_char($type); + my $next_learning = $self->_next_learning_char($type); + if ( !defined $next_review ) { + return $next_learning; + } + if ( !defined $next_learning ) { + return $next_review; + } + my $rng = JapaChar::Random->new->get( 1, 100 ); + if ( $rng > 20 ) { + return $next_learning; + } + return $next_review; +} + +sub _next_review_char( $self, $type = undef ) { + my $kanji_resultset = + JapaChar::Schema->Schema->resultset('Kanji'); + my @chars = $kanji_resultset->search( + { + score => { '>=' => 300 }, + ( ( $type ne 'all' ) ? ( grade => { is => $type} ) : () ) + }, + { + order_by => { -asc => \'RANDOM()' }, + rows => 1 + } + ); + if ( !@chars ) { + return; + } + return $chars[0]; +} + +sub _next_learning_char( $self, $type = undef ) { + my $kanji_resultset = + JapaChar::Schema->Schema->resultset('Kanji'); + my @candidate_chars = $self->_retrieve_started_chars_not_finished($type); + if ( @candidate_chars < 5 ) { + my @new_chars = $kanji_resultset->search( + { + -not_bool => 'started', + ( ( $type ne 'all' ) ? ( grade => { is => $type } ) : () ) + }, + { + order_by => { -asc => ['grade', 'id'] }, + rows => 5 - scalar @candidate_chars, + } + ); + for my $char (@new_chars) { + $char->update( { started => 1 } ); + } + @candidate_chars = $self->_retrieve_started_chars_not_finished($type); + } + my $char = $candidate_chars[ int( rand( scalar @candidate_chars ) ) ]; + return $char; +} + +sub _retrieve_started_chars_not_finished( $self, $type ) { + my $kanji_resultset = + JapaChar::Schema->Schema->resultset('Kanji'); + return $kanji_resultset->search( + { + ( ( $type ne 'all' ) ? ( grade => { is => $type } ) : () ), + score => { '<' => 100 }, + -bool => 'started', + } + ); +} +1; diff --git a/lib/JapaChar/Schema/Result/Kanji.pm b/lib/JapaChar/Schema/Result/Kanji.pm new file mode 100644 index 0000000..232d765 --- /dev/null +++ b/lib/JapaChar/Schema/Result/Kanji.pm @@ -0,0 +1,99 @@ +package JapaChar::Schema::Result::Kanji; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use parent 'DBIx::Class::Core'; + +use Encode qw/decode/; + +__PACKAGE__->table('kanji'); + +__PACKAGE__->add_columns( + id => { + data_type => 'INTEGER', + is_auto_increment => 1, + }, + kanji => { + data_type => 'TEXT', + is_nullable => 0, + accessor => '_kanji', + }, + grade => { + data_type => 'INTEGER', + }, + started => { + data_type => 'BOOLEAN', + }, + score => { + data_type => 'INTEGER', + }, + consecutive_success => { + data_type => 'INTEGER', + }, + consecutive_failures => { + data_type => 'INTEGER', + }, +); + +__PACKAGE__->set_primary_key('id'); + +__PACKAGE__->has_many(meanings => 'JapaChar::Schema::Result::KanjiMeanings', 'id_kanji'); +__PACKAGE__->has_many(on_readings => 'JapaChar::Schema::Result::KanjiOnReadings', 'id_kanji'); +__PACKAGE__->has_many(kun_readings => 'JapaChar::Schema::Result::KanjiKunReadings', 'id_kanji'); + +sub kanji( $self, $kanji = undef ) { + if ( defined $kanji ) { + $self->_kanji($kanji); + } + return decode 'utf-8', $self->_kanji; +} + +sub fail($self) { + my $score = $self->score; + my $consecutive_success = 0; + my $consecutive_failures = $self->consecutive_failures + 1; + $score -= 25; + if ( $score < 0 ) { + $score = 0; + } + $self->update( + { + score => $score, + consecutive_failures => $consecutive_failures, + consecutive_success => 0, + } + ); +} + +sub get( $self, $what ) { + if ( $what eq 'kana' ) { + return $self->value; + } + if ( $what eq 'romanji' ) { + return $self->romanji; + } + return; +} + +sub success($self) { + my $score = $self->score; + my $consecutive_success = $self->consecutive_success + 1; + my $consecutive_failures = 0; + $score += 5 + 10 * $consecutive_success; + if ( $score > 300 ) { + $score = 300; + } + $self->update( + { + score => $score, + consecutive_success => $consecutive_success, + consecutive_failures => $consecutive_failures, + } + ); +} +1; diff --git a/lib/JapaChar/Schema/Result/KanjiKunReadings.pm b/lib/JapaChar/Schema/Result/KanjiKunReadings.pm new file mode 100644 index 0000000..8c56000 --- /dev/null +++ b/lib/JapaChar/Schema/Result/KanjiKunReadings.pm @@ -0,0 +1,33 @@ +package JapaChar::Schema::Result::KanjiKunReadings; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use parent 'DBIx::Class::Core'; + +use Encode qw/decode/; + +__PACKAGE__->table('kanji_kun_readings'); + +__PACKAGE__->add_columns( + id => { + data_type => 'INTEGER', + is_auto_increment => 1, + }, + id_kanji => { + data_type => 'INTEGER', + is_nullable => 0, + }, + reading => { + data_type => 'TEXT', + is_nullable => 0, + }, +); + +__PACKAGE__->set_primary_key('id'); +__PACKAGE__->belongs_to(kanji => 'JapaChar::Schema::Result::Kanji', 'id_kanji'); +1; diff --git a/lib/JapaChar/Schema/Result/KanjiMeanings.pm b/lib/JapaChar/Schema/Result/KanjiMeanings.pm new file mode 100644 index 0000000..aea0be7 --- /dev/null +++ b/lib/JapaChar/Schema/Result/KanjiMeanings.pm @@ -0,0 +1,33 @@ +package JapaChar::Schema::Result::KanjiMeanings; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use parent 'DBIx::Class::Core'; + +use Encode qw/decode/; + +__PACKAGE__->table('kanji_meanings'); + +__PACKAGE__->add_columns( + id => { + data_type => 'INTEGER', + is_auto_increment => 1, + }, + id_kanji => { + data_type => 'INTEGER', + is_nullable => 0, + }, + meaning => { + data_type => 'TEXT', + is_nullable => 0, + }, +); + +__PACKAGE__->set_primary_key('id'); +__PACKAGE__->belongs_to(kanji => 'JapaChar::Schema::Result::Kanji', 'id_kanji'); +1; diff --git a/lib/JapaChar/Schema/Result/KanjiOnReadings.pm b/lib/JapaChar/Schema/Result/KanjiOnReadings.pm new file mode 100644 index 0000000..4e6cb87 --- /dev/null +++ b/lib/JapaChar/Schema/Result/KanjiOnReadings.pm @@ -0,0 +1,33 @@ +package JapaChar::Schema::Result::KanjiOnReadings; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use parent 'DBIx::Class::Core'; + +use Encode qw/decode/; + +__PACKAGE__->table('kanji_on_readings'); + +__PACKAGE__->add_columns( + id => { + data_type => 'INTEGER', + is_auto_increment => 1, + }, + id_kanji => { + data_type => 'INTEGER', + is_nullable => 0, + }, + reading => { + data_type => 'TEXT', + is_nullable => 0, + }, +); + +__PACKAGE__->set_primary_key('id'); +__PACKAGE__->belongs_to(kanji => 'JapaChar::Schema::Result::Kanji', 'id_kanji'); +1; diff --git a/lib/JapaChar/View/KanjiLesson.pm b/lib/JapaChar/View/KanjiLesson.pm new file mode 100644 index 0000000..cc4e422 --- /dev/null +++ b/lib/JapaChar/View/KanjiLesson.pm @@ -0,0 +1,221 @@ +package JapaChar::View::KanjiLesson; + +use v5.40.0; + +use strict; +use warnings; + +use feature 'signatures'; + +use Moo; +use Path::Tiny; +use Glib::Object::Introspection; +use YAML::PP; +use JapaChar::DB; +use JapaChar::Characters; +use Pango; +use JapaChar::Random; +use JapaChar::Score; + +use Glib::IO; + +use constant PANGO_SCALE => 1024; +my $exit_the_lesson_id = 'exit-the-lesson'; + +Glib::Object::Introspection->setup( + basename => 'Gtk', + version => '4.0', + package => 'Gtk', +); + +Glib::Object::Introspection->setup( + basename => 'Gdk', + version => '4.0', + package => 'Gtk::Gdk', +); + +Glib::Object::Introspection->setup( + basename => 'Gsk', + version => '4.0', + package => 'Gtk::Gsk', +); + +Glib::Object::Introspection->setup( + basename => 'Adw', + version => '1', + package => 'Adw', +); + +has app => ( is => 'ro', required => 1 ); +has type => ( is => 'ro', default => sub { 'all' } ); +has counter => ( is => 'rw' ); +has _successes => ( is => 'rw' ); + +sub run($self) { + $self->counter(31); + $self->_show_start_lesson; +} + +sub _show_start_lesson($self) { + my $type = $self->type; + my $box = Gtk::Box->new( 'vertical', 0 ); + my $back_button = Gtk::Button->new_from_icon_name('go-previous-symbolic'); + my $intro = Gtk::Label->new('This lesson has 30 exercises.'); + my $intro2 = Gtk::Label->new('30 points on completion.'); + my $intro3 = Gtk::Label->new('30 extra points if you do it very well'); + $intro->set_margin_top(50); + $box->append($intro); + $box->append($intro2); + $box->append($intro3); + my $continue_button = Gtk::Button->new_with_label('Continue'); + $continue_button->add_css_class('accent'); + my $resize = sub { + my $attr_list = Pango::AttrList->new; + my $size_number = 30 * $self->app->get_width; + my $size_pango_number = PANGO_SCALE * 60; + my $size = Pango::AttrSize->new($size_number); + + if ( $size_pango_number < $size_number ) { + $size = Pango::AttrSize->new($size_pango_number); + } + $attr_list->insert($size); + $intro->set_attributes($attr_list); + $intro2->set_attributes($attr_list); + $intro3->set_attributes($attr_list); + $continue_button->get_child->set_attributes($attr_list); + }; + $resize->(); + $self->app->on_resize($resize); + $continue_button->signal_connect( + 'clicked', + sub { + $self->app->delete_on_resize($resize); + $continue_button->set_sensitive(0); + $self->_successes(0); + require JapaChar::View::KanjiTestExercise; + JapaChar::View::KanjiTestExercise->new( lesson => $self )->run; + } + ); + $box->append($continue_button); + $continue_button->set_halign('end'); + $continue_button->set_valign('end'); + $continue_button->set_vexpand(1); + $back_button->signal_connect( + 'clicked', + sub { + $self->app->delete_on_resize($resize); + require JapaChar::View::MainMenu; + JapaChar::View::MainMenu->new( app => $self->app )->run; + } + ); + $continue_button->set_margin_end(50); + $continue_button->set_margin_bottom(50); + $self->app->window_set_child($box); + $self->app->headerbar->pack_start($back_button); +} + +sub create_continue_lesson_button( $self, $on_click ) { + my $type = $self->type; + my $continue_button = Gtk::Button->new_with_label('Continue'); + $continue_button->set_valign('center'); + $continue_button->set_halign('end'); + $continue_button->set_sensitive(0); + $continue_button->add_css_class('accent'); + $continue_button->signal_connect( 'clicked', $on_click, ); + return $continue_button; +} + +sub add_one_success($self) { + $self->_successes( $self->_successes + 1 ); +} + +sub create_exit_lesson_back_button( $self, $on_exit ) { + my $back_button = Gtk::Button->new_from_icon_name('go-previous-symbolic'); + $back_button->signal_connect( + 'clicked', + sub { + $back_button->set_sensitive(0); + $self->_create_dialog_exit_lesson($on_exit); + } + ); + return $back_button; +} + +sub _create_dialog_exit_lesson( $self, $on_exit ) { + my $dialog = Adw::AlertDialog->new( 'Exit the lessson', + 'On exit you will lose your progress' ); + $dialog->add_response( 'close', 'Continue' ); + $dialog->add_response( $exit_the_lesson_id, 'Exit' ); + $dialog->set_response_appearance( $exit_the_lesson_id, 'destructive' ); + $dialog->signal_connect( + 'response', + sub( $obj, $response ) { + $self->_on_dialog_exit_lesson_response( $response, $on_exit ); + } + ); + $self->app->present_dialog($dialog); + return $dialog; +} + +sub _on_dialog_exit_lesson_response( $self, $response, $on_exit ) { + if ( $response eq $exit_the_lesson_id ) { + $on_exit->(); + require JapaChar::View::MainMenu; + JapaChar::View::MainMenu->new( app => $self->app )->run; + } +} + +sub finish_lesson_screen($self) { + my $notable_lesson = $self->_successes >= 7; + my $feedback_label; + my $box = Gtk::Box->new( 'vertical', 10 ); + if ($notable_lesson) { + $feedback_label = + Gtk::Label->new('You did it great, here you have your 60 points.'); + } + else { + $feedback_label = Gtk::Label->new( + 'You need to continue improving, we have 30 points for you'); + } + + my $continue_button = Gtk::Button->new_with_label('Continue'); + $continue_button->add_css_class('accent'); + $continue_button->set_halign('end'); + $continue_button->set_valign('end'); + $continue_button->set_vexpand(1); + $feedback_label->set_valign('center'); + $feedback_label->set_halign('center'); + $feedback_label->set_vexpand(1); + my $resize = sub { + my $attr_list = Pango::AttrList->new; + my $size_number = 20 * $self->app->get_width; + my $size_pango_number = PANGO_SCALE * 35; + my $size = Pango::AttrSize->new($size_number); + + if ( $size_pango_number < $size_number ) { + $size = Pango::AttrSize->new($size_pango_number); + } + $attr_list->insert($size); + $feedback_label->set_attributes($attr_list); + $continue_button->get_child->set_attributes($attr_list); + }; + $self->app->on_resize($resize); + $continue_button->set_margin_end(50); + $continue_button->set_margin_bottom(50); + $resize->(); + $box->append($feedback_label); + $box->append($continue_button); + $continue_button->signal_connect( + 'clicked', + sub { + $self->app->delete_on_resize($resize); + JapaChar::Score->sum( $notable_lesson ? 60 : 30 ); + $continue_button->set_sensitive(0); + require JapaChar::View::MainMenu; + JapaChar::View::MainMenu->new( app => $self->app )->run; + } + ); + $self->app->window_set_child($box); +} + +1; diff --git a/lib/JapaChar/View/KanjiTestExercise.pm b/lib/JapaChar/View/KanjiTestExercise.pm new file mode 100644 index 0000000..8daf6dd --- /dev/null +++ b/lib/JapaChar/View/KanjiTestExercise.pm @@ -0,0 +1,266 @@ +package JapaChar::View::KanjiTestExercise; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use Encode qw/decode/; + +use Moo; +use Path::Tiny; +use Glib::Object::Introspection; +use YAML::PP; +use JapaChar::DB; +use JapaChar::Characters; +use Pango; +use JapaChar::Random; +use JapaChar::Score; + +use Glib; +use Glib::IO; + +use constant PANGO_SCALE => 1024; + +Glib::Object::Introspection->setup( + basename => 'Gtk', + version => '4.0', + package => 'Gtk', +); + +Glib::Object::Introspection->setup( + basename => 'Gdk', + version => '4.0', + package => 'Gtk::Gdk', +); + +Glib::Object::Introspection->setup( + basename => 'Gsk', + version => '4.0', + package => 'Gtk::Gsk', +); + +Glib::Object::Introspection->setup( + basename => 'Adw', + version => '1', + package => 'Adw', +); + +has lesson => ( is => 'rw' ); +has _type => ( is => 'lazy' ); +has _app => ( is => 'lazy' ); +has _buttons => ( is => 'rw' ); +has _buttons_box => ( is => 'rw' ); +has _first_press_continue => ( is => 'rw', default => sub { 1 } ); +has _continue_button => ( is => 'rw' ); +has _on_resize_continue_button => ( is => 'lazy' ); +has _final_answer => ( is => 'rw' ); +has _on_resize_buttons => ( is => 'lazy' ); + +sub _counter($self) { + return $self->lesson->counter; +} + +sub _build__type($self) { + return $self->lesson->type; +} + +sub _build__app($self) { + return $self->lesson->app; +} + +sub run($self) { + $self->lesson->counter( $self->_counter - 1 ); + if ( $self->_counter < 1 ) { + $self->lesson->finish_lesson_screen(); + return; + } + my $rng = JapaChar::Random->new->get( 1, 100 ); + my $char = + $self->_app->kanji->next_char( $self->_app->accessibility, $self->_type ); + my @available_guessses = ($char->meanings, $char->on_readings, $char->kun_readings); + $rng = JapaChar::Random->new->get( 0, scalar(@available_guessses) - 1 ); + $self->_create_challenge($char, $available_guessses[$rng]); +} + +sub guess_to_string($self, $guess) { + return $guess->meaning if $guess->isa('JapaChar::Schema::Result::KanjiMeanings'); + return decode 'utf-8', $guess->reading; +} + +sub _create_challenge($self, $char, $guess) { + my $grid = $self->_create_grid_challenge; + my $kanji_label = $self->_get_label_featured_character( $char->kanji ); + $kanji_label->set_halign('center'); + $kanji_label->set_valign('center'); + my $box_kanji = Gtk::Box->new( 'vertical', 10 ); + $box_kanji->append( $self->_new_exercise_number_label ); + $box_kanji->append($kanji_label); + $grid->attach( $box_kanji, 0, 0, 12, 1 ); + $self->_app->window_set_child($grid); + my $back_button = $self->lesson->create_exit_lesson_back_button( + sub { + $self->_app->delete_on_resize( $self->_on_resize_continue_button ); + } + ); + $self->_app->headerbar->pack_start($back_button); + my $incorrect_answers = + $self->_app->kanji->get_4_incorrect_answers($char, $guess); + $self->_app->on_resize( $self->_on_resize_continue_button ); + my @buttons; + my $continue_button = $self->lesson->create_continue_lesson_button( + sub { + $self->_on_click_continue_button( $grid, $char, $guess ); + } + ); + $self->_continue_button($continue_button); + $self->_on_resize_continue_button->(); + my $on_answer = sub ($correct) { + $continue_button->set_sensitive(1); + }; + my $correct_answer_button = + Gtk::ToggleButton->new_with_label( $self->guess_to_string($guess) ); + $correct_answer_button->signal_connect( + 'clicked', + sub { + $self->_final_answer( $self->guess_to_string($guess) ); + $on_answer->(1); + } + ); + push @buttons, $correct_answer_button; + $self->_buttons( \@buttons ); + for my $bad_answer (@$incorrect_answers) { + my $incorrect_button = + Gtk::ToggleButton->new_with_label( $bad_answer ); + $incorrect_button->set_group($correct_answer_button); + $incorrect_button->signal_connect( + 'clicked', + sub { + $self->_final_answer( $bad_answer ); + $on_answer->(0); + } + ); + push @buttons, $incorrect_button; + } + @buttons = sort { rand() <=> rand() } @buttons; + my $box = Gtk::Box->new( 'horizontal', 10 ); + $box->set_valign('center'); + $box->set_halign('center'); + + for my $button (@buttons) { + $box->append($button); + } + $self->_buttons_box($box); + $self->_on_resize_buttons->(); + $self->_app->on_resize($self->_on_resize_buttons); + $grid->attach( $box, 0, 2, 12, 1 ); + $grid->attach( $continue_button, 6, 3, 5, 1 ); +} + +sub _build__on_resize_buttons($self) { + return sub { + return if !defined $self->_buttons_box; + my @buttons = $self->_buttons->@*; + my $window_size = $self->_app->get_width; + for my $button (@buttons) { + my $attr_list = Pango::AttrList->new; + my $size_number = 14 * $window_size; + my $size_pango_number = PANGO_SCALE * 60; + my $size = Pango::AttrSize->new($size_number); + if ( $size_pango_number < $size_number ) { + $size = Pango::AttrSize->new($size_pango_number); + } + $attr_list->insert($size); + $button->get_child->set_attributes($attr_list); + } + }; +} + +sub _create_grid_challenge($self) { + my $grid = Gtk::Grid->new; + $grid->set_column_homogeneous(1); + $grid->set_row_homogeneous(1); + return $grid; +} + +sub _get_label_featured_character( $self, $text ) { + my $label = Gtk::Label->new($text); + my $attr_list = Pango::AttrList->new; + my $size = Pango::AttrSize->new( 72 * PANGO_SCALE ); + my $color = Pango::Color->new; + + $attr_list->insert($size); + my $fore_attr = $self->_app->characters->get_color_attr($text); + + $label->set_attributes($attr_list); + $label->set_halign('center'); + return $label; +} + +sub _new_exercise_number_label($self) { + my $exercise_number = abs( $self->_counter - 31 ); + my $return = Gtk::Label->new( 'Exercise: ' . $exercise_number ); + $return->set_halign('start'); + return $return; +} + +sub _build__on_resize_continue_button($self) { + return sub { + my $continue_button = $self->_continue_button; + my $attr_list = Pango::AttrList->new; + my $size = Pango::AttrSize->new( 40 * $self->_app->get_width ); + + $attr_list->insert($size); + $continue_button->get_child->set_attributes($attr_list); + }; +} + +sub _on_exit($self) { + $self->_app->delete_on_resize( $self->_on_resize_buttons ); + $self->_app->delete_on_resize( $self->_on_resize_continue_button ); +} + +sub _on_click_continue_button( $self, $grid, $char, $guess ) { + my $continue_button = $self->_continue_button; + if ( defined $self->_buttons ) { + for my $button ( $self->_buttons->@* ) { + $button->set_sensitive(0); + } + } + if ( !$self->_first_press_continue ) { + $self->_on_exit; + $continue_button->set_sensitive(0); + $self->new( lesson => $self->lesson )->run; + return; + } + $self->_first_press_continue(0); + my $label_feedback; + { + if ( $self->_final_answer eq $self->guess_to_string($guess) ) { + $label_feedback = Gtk::Label->new('You are doing it great.'); + $label_feedback->add_css_class('success'); + $self->lesson->add_one_success; + $char->success; + next; + } + $label_feedback = Gtk::Label->new( + 'Meck!! The correct answer is ' . $self->guess_to_string($guess) ); + $label_feedback->add_css_class('error'); + $char->fail; + $continue_button->set_sensitive(0); + Glib::Timeout->add_seconds(1, sub { + $continue_button->set_sensitive(1); + return 0; + }); + } + + my $attr_list = Pango::AttrList->new; + my $size = Pango::AttrSize->new( 15 * $self->_app->get_width ); + $attr_list->insert($size); + $label_feedback->set_halign('center'); + $label_feedback->set_attributes($attr_list); + $grid->attach( $label_feedback, 0, 3, 7, 1 ); +} +1; diff --git a/lib/JapaChar/View/MainMenu.pm b/lib/JapaChar/View/MainMenu.pm index 2e89ecb..c09ea30 100644 --- a/lib/JapaChar/View/MainMenu.pm +++ b/lib/JapaChar/View/MainMenu.pm @@ -17,6 +17,7 @@ use Pango; use JapaChar::Random; use JapaChar::Score; use JapaChar::View::HiraganaKatakanaLesson; +use JapaChar::View::SelectKanjiLesson; use Glib::IO; @@ -74,6 +75,7 @@ sub run($self) { } ); my $button_start_katakana_lesson = Gtk::Button->new_with_label('Katakana'); + my $button_start_kanji_lesson = Gtk::Button->new_with_label('Kanji (BETA)'); $button_start_katakana_lesson->signal_connect( 'clicked', sub { @@ -84,14 +86,21 @@ sub run($self) { $lesson->run; } ); - for my $button ( $button_start_basic_lesson, $button_start_hiragana_lesson, - $button_start_katakana_lesson ) + for my $button ( + $button_start_basic_lesson, $button_start_hiragana_lesson, + $button_start_katakana_lesson, $button_start_kanji_lesson + ) { my $attr_list = Pango::AttrList->new; my $size = Pango::AttrSize->new( 25 * PANGO_SCALE ); $attr_list->insert($size); $button->get_child->set_attributes($attr_list); } + $button_start_kanji_lesson->signal_connect( + clicked => sub { + JapaChar::View::SelectKanjiLesson->new( app => $self->app, )->run; + } + ); my $box = Gtk::Box->new( 'horizontal', 10 ); my $box_score_basic_lesson = Gtk::Box->new( 'vertical', 10 ); my $score_label = @@ -110,18 +119,25 @@ sub run($self) { $box->set_margin_top(40); $box->append($button_start_hiragana_lesson); $box->append($button_start_katakana_lesson); + $button_start_kanji_lesson->set_halign('center'); + $button_start_kanji_lesson->set_valign('center'); $box->set_valign('start'); $box->set_halign('center'); - $grid->attach( $box, 0, 1, 5, 1 ); - $grid->attach( $button_assisted_mode, 0, 2, 5, 1 ); - $button_assisted_mode->signal_connect('clicked', sub { - $self->app->accessibility->show_assisted_mode_selection; - }); + $grid->attach( $box, 0, 1, 5, 1 ); + $grid->attach( $button_start_kanji_lesson, 0, 2, 5, 1 ); + $grid->attach( $button_assisted_mode, 0, 3, 5, 1 ); + $button_assisted_mode->signal_connect( + 'clicked', + sub { + $self->app->accessibility->show_assisted_mode_selection; + } + ); $button_assisted_mode->set_vexpand(1); $button_assisted_mode->set_hexpand(1); $button_assisted_mode->set_valign('center'); $button_assisted_mode->set_halign('center'); - my $button_discord_community = Gtk::Button->new_with_label('Join the discord community'); + my $button_discord_community = + Gtk::Button->new_with_label('Join the discord community'); $button_discord_community->set_vexpand(1); $button_discord_community->set_hexpand(1); $button_discord_community->set_valign('center'); @@ -131,7 +147,7 @@ sub run($self) { $self->app->launch_discord; } ); - $grid->attach( $button_discord_community, 0, 3, 5, 1 ); + $grid->attach( $button_discord_community, 0, 4, 5, 1 ); $self->app->window_set_child($grid); } 1; diff --git a/lib/JapaChar/View/SelectKanjiLesson.pm b/lib/JapaChar/View/SelectKanjiLesson.pm new file mode 100644 index 0000000..74aa684 --- /dev/null +++ b/lib/JapaChar/View/SelectKanjiLesson.pm @@ -0,0 +1,146 @@ +package JapaChar::View::SelectKanjiLesson; + +use v5.38.2; + +use strict; +use warnings; + +use feature 'signatures'; + +use Moo; + +use JapaChar; +use JapaChar::Kanji; +use JapaChar::View::KanjiLesson; + +use Glib::Object::Introspection; +use Glib::IO; +use POSIX qw/:sys_wait_h/; + +Glib::Object::Introspection->setup( + basename => 'Gtk', + version => '4.0', + package => 'Gtk', +); + +Glib::Object::Introspection->setup( + basename => 'Gdk', + version => '4.0', + package => 'Gtk::Gdk', +); + +Glib::Object::Introspection->setup( + basename => 'Gsk', + version => '4.0', + package => 'Gtk::Gsk', +); + +Glib::Object::Introspection->setup( + basename => 'Adw', + version => '1', + package => 'Adw', +); + +has app => ( is => 'ro' ); +has _kanji => ( is => 'lazy' ); + +sub _build__kanji($self) { + return JapaChar::Kanji->new(app => JapaChar->new); +} + +sub run($self) { + if (!$self->_kanji->migrated) { + $self->_migrate_kanji; + return; + } + $self->_select_kanji; +} + +sub _migrate_kanji($self) { + my $box = Gtk::Box->new( 'vertical', 10 ); + my $label = Gtk::Label->new('Populating Kanji database...'); + my $progress_bar = Gtk::ProgressBar->new; + $progress_bar->set_halign('center'); + $box->set_vexpand(1); + $box->set_valign('center'); + $label->set_valign('center'); + $box->append($label); + $box->append($progress_bar); + $self->app->window_set_child($box); + my ($read, $write); + pipe $read, $write; + my $parent_pid = $$; + my $pid = fork; + if (!$pid) { + $self->_kanji->populate_kanji($parent_pid, $write); + exit; + } + my $n_characters; + Glib::Timeout->add(1_000, sub { + $n_characters = <$read>; + chomp $n_characters; + say 'Copying ' . $n_characters . ' kanji'; + Glib::Timeout->add( + 100, + sub { + $read->blocking(0); + my $last_number; + my $line; + while ($line = <$read>) { + $last_number = $line; + } + if ($last_number) { + $progress_bar->set_fraction($last_number / $n_characters); + } + if (0 == waitpid $pid, WNOHANG) { + return 1; + } + $self->_select_kanji; + return 0; + } + ); + return 0; + }); +} + +sub _select_kanji($self) { + my $back_button = Gtk::Button->new_from_icon_name('go-previous-symbolic'); + $back_button->signal_connect( + 'clicked', + sub { + require JapaChar::View::MainMenu; + JapaChar::View::MainMenu->new( app => $self->app )->run; + } + ); + my $grades = $self->_kanji->grades; + my $box = Gtk::Box->new( 'vertical', 10 ); + + my $button = Gtk::Button->new_with_label("Study everything ordered by grade"); + $button->signal_connect(clicked => sub { + JapaChar::View::KanjiLesson->new(app => $self->app)->run; + }); + $button->set_margin_top(20); + $button->add_css_class('accent'); + $button->set_halign('center'); + $button->set_property('width-request', 330); + $box->append($button); + for my $grade (@$grades) { + my $button = Gtk::Button->new_with_label("Study kanji grade $grade"); + $button->signal_connect(clicked => sub { + JapaChar::View::KanjiLesson->new(app => $self->app, type => $grade)->run; + }); + $button->set_halign('center'); + $button->set_property('width-request', 330); + $box->append($button); + } + $button = Gtk::Button->new_with_label("Study unclassified kanjis"); + $button->signal_connect(clicked => sub { + JapaChar::View::KanjiLesson->new(app => $self->app, type => undef)->run; + }); + $button->set_property('width-request', 330); + $button->set_halign('center'); + $box->append($button); + $self->app->window_set_child($box); + $self->app->headerbar->pack_start($back_button); +} +1; diff --git a/me.sergiotarxz.JapaChar.metainfo.xml b/me.sergiotarxz.JapaChar.metainfo.xml index 7adff18..ac9e4ca 100644 --- a/me.sergiotarxz.JapaChar.metainfo.xml +++ b/me.sergiotarxz.JapaChar.metainfo.xml @@ -19,7 +19,9 @@ mild - mild + intense + intense + intense